Mercurial
diff mrjunejune/test/latex_editor_test.js @ 244:b8aa08503378
[tools] Add sandboxed online LaTeX editor
Co-authored-by: Copilot <[email protected]>
| author | MrJuneJune <me@mrjunejune.com> |
|---|---|
| date | Mon, 03 Aug 2026 16:56:25 -0700 |
| parents | |
| children | 3843bb6253ac |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mrjunejune/test/latex_editor_test.js Mon Aug 03 16:56:25 2026 -0700 @@ -0,0 +1,298 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const http = require('node:http'); +const net = require('node:net'); +const path = require('node:path'); +const { spawn } = require('node:child_process'); + +const RUNFILES = process.env.JS_BINARY__RUNFILES; +const WORKSPACE = process.env.JS_BINARY__WORKSPACE; +const runfilesWorkspace = path.join(RUNFILES, WORKSPACE); +const playwrightPath = path.join( + runfilesWorkspace, + 'hg-web/e2e/node_modules/playwright-core', +); +const { chromium } = require(playwrightPath); + +function findFreePort() { + return new Promise((resolve, reject) => { + const socket = net.createServer(); + socket.once('error', reject); + socket.listen(0, '127.0.0.1', () => { + const address = socket.address(); + socket.close(error => { + if (error) reject(error); + else resolve(String(address.port)); + }); + }); + }); +} + +async function waitForServer(server, baseUrl, logs) { + const deadline = Date.now() + 15000; + while (Date.now() < deadline) { + if (server.exitCode !== null) { + throw new Error(`Server exited with ${server.exitCode}\n${logs.join('')}`); + } + try { + const response = await fetch(baseUrl); + if (response.ok) return; + } catch { + // Keep waiting for startup. + } + await new Promise(resolve => setTimeout(resolve, 100)); + } + throw new Error(`Server startup timed out\n${logs.join('')}`); +} + +async function stopProcess(child) { + if (!child || child.exitCode !== null) return; + child.kill('SIGTERM'); + await new Promise(resolve => { + const timer = setTimeout(() => { + if (child.exitCode === null) child.kill('SIGKILL'); + }, 3000); + child.once('exit', () => { + clearTimeout(timer); + resolve(); + }); + }); +} + +async function render(baseUrl, source) { + return fetch(`${baseUrl}/api/latex/render`, { + method: 'POST', + headers: { 'Content-Type': 'text/plain; charset=utf-8' }, + body: source, + }); +} + +function requestWithoutReuse(baseUrl, method, requestPath, body = '') { + const url = new URL(requestPath, baseUrl); + return new Promise((resolve, reject) => { + const request = http.request({ + host: url.hostname, + port: url.port, + path: url.pathname, + method, + agent: false, + headers: { + Connection: 'close', + 'Content-Type': 'text/plain; charset=utf-8', + 'Content-Length': Buffer.byteLength(body), + }, + }, response => { + const chunks = []; + response.on('data', chunk => chunks.push(chunk)); + response.on('end', () => resolve({ + status: response.statusCode, + body: Buffer.concat(chunks), + })); + }); + request.on('error', reject); + request.end(body); + }); +} + +(async () => { + assert.ok(RUNFILES); + assert.ok(WORKSPACE); + const serverBinary = path.join( + runfilesWorkspace, + 'mrjunejune/mrjunejune_server', + ); + const chromiumPath = path.resolve(process.env.CHROMIUM_PATH); + const port = await findFreePort(); + const baseUrl = `http://127.0.0.1:${port}`; + const logs = []; + let server; + let browser; + + try { + server = spawn(serverBinary, [], { + cwd: runfilesWorkspace, + env: { + ...process.env, + MRJUNEJUNE_PORT: port, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + server.stdout.on('data', chunk => logs.push(chunk.toString())); + server.stderr.on('data', chunk => logs.push(chunk.toString())); + await waitForServer(server, baseUrl, logs); + + const valid = String.raw`\documentclass{article} +\begin{document} +Hello from the API. $a^2 + b^2 = c^2$ +\end{document} +`; + let response = await render(baseUrl, valid); +assert.equal(response.status, 200, await response.clone().text()); + assert.match(response.headers.get('content-type') || '', /^application\/pdf/); + assert.equal(response.headers.get('cache-control'), 'no-store'); + const pdf = Buffer.from(await response.arrayBuffer()); + assert.ok(pdf.length > 1000); + assert.equal(pdf.subarray(0, 5).toString(), '%PDF-'); + + response = await render( + baseUrl, + String.raw`\documentclass{article}\begin{document}\badcommand\end{document}`, + ); + assert.equal(response.status, 422); + assert.match(await response.text(), /Undefined control sequence/); + + response = await render(baseUrl, 'x'.repeat(64 * 1024 + 1)); + assert.equal(response.status, 413); + assert.match(await response.text(), /64 KiB/); + + response = await render( + baseUrl, + String.raw`\documentclass{article}\begin{document}\input{/etc/passwd}\end{document}`, + ); + assert.equal(response.status, 422); + assert.doesNotMatch(await response.text(), /root:x:/); + + const shellPath = `/tmp/mrjunejune-latex-browser-${process.pid}`; + response = await render( + baseUrl, + String.raw`\documentclass{article} +\begin{document} +\immediate\write18{touch ${shellPath}} +Shell escape stays disabled. +\end{document}`, + ); + assert.equal(response.status, 200); + assert.equal(fs.existsSync(shellPath), false); + + const infiniteRender = requestWithoutReuse( + baseUrl, + 'POST', + '/api/latex/render', + String.raw`\documentclass{article}\begin{document}\loop\iftrue\repeat\end{document}`, + ); + await new Promise(resolve => setTimeout(resolve, 200)); + const healthStarted = Date.now(); + let isolatedResponse = await requestWithoutReuse(baseUrl, 'GET', '/tools'); + assert.equal(isolatedResponse.status, 200); + assert.ok(Date.now() - healthStarted < 1500); + isolatedResponse = await requestWithoutReuse( + baseUrl, + 'POST', + '/api/latex/render', + valid, + ); + assert.equal(isolatedResponse.status, 429); + assert.match(isolatedResponse.body.toString(), /compiler is busy/); + isolatedResponse = await infiniteRender; + assert.equal(isolatedResponse.status, 504); + assert.match( + isolatedResponse.body.toString(), + /CPU or memory limit|8 second limit/, + ); + + browser = await chromium.launch({ + executablePath: chromiumPath, + headless: true, + args: ['--no-sandbox'], + }); + const context = await browser.newContext(); + await context.addInitScript(() => { + window.__pdfUrls = { created: [], revoked: [] }; + const createObjectURL = URL.createObjectURL.bind(URL); + const revokeObjectURL = URL.revokeObjectURL.bind(URL); + URL.createObjectURL = value => { + const url = createObjectURL(value); + window.__pdfUrls.created.push(url); + return url; + }; + URL.revokeObjectURL = url => { + window.__pdfUrls.revoked.push(url); + revokeObjectURL(url); + }; + }); + const page = await context.newPage(); + const browserErrors = []; + let testingExpectedFailure = false; + page.on('pageerror', error => browserErrors.push(error.message)); + page.on('console', message => { + if (message.type() !== 'error') return; + if (testingExpectedFailure && + message.text().startsWith('Failed to load resource:')) return; + browserErrors.push(message.text()); + }); + + await page.goto(`${baseUrl}/tools/latex_editor`, { + waitUntil: 'networkidle', + }); + await page.locator('#latexStatus[data-state="ready"]').waitFor({ + timeout: 15000, + }); + assert.match( + await page.locator('#pdfPreview').getAttribute('src'), + /^blob:/, + ); + assert.equal( + await page.locator('#downloadButton').getAttribute('download'), + 'document.pdf', + ); + + await page.locator('#autoCompile').uncheck(); + await page.locator('#latexSource').fill( + String.raw`\documentclass{article}\begin{document}\badcommand\end{document}`, + ); + testingExpectedFailure = true; + await page.getByRole('button', { name: 'Compile PDF' }).click(); + await page.locator('#latexStatus[data-state="error"]').waitFor(); + testingExpectedFailure = false; + assert.match( + await page.locator('#latexDiagnostics').textContent(), + /Undefined control sequence/, + ); + + await page.locator('#latexSource').fill(valid); + await page.getByRole('button', { name: 'Compile PDF' }).click(); + await page.locator('#latexStatus[data-state="ready"]').waitFor(); + const objectUrls = await page.evaluate(() => window.__pdfUrls); + assert.ok(objectUrls.created.length >= 2, JSON.stringify(objectUrls)); + assert.ok(objectUrls.revoked.length >= 1, JSON.stringify(objectUrls)); + + let delayNextRender = true; + await page.route('**/api/latex/render', async route => { + if (!delayNextRender) { + await route.continue(); + return; + } + delayNextRender = false; + await new Promise(resolve => setTimeout(resolve, 300)); + try { + await route.fulfill({ + status: 200, + contentType: 'application/pdf', + body: pdf, + }); + } catch { + // The corrected client aborts this stale request. + } + }); + await page.locator('#latexSource').fill(valid); + await page.getByRole('button', { name: 'Compile PDF' }).click(); + await page.locator('#latexStatus[data-state="working"]').waitFor(); + await page.locator('#latexSource').fill('x'.repeat(64 * 1024 + 1)); + await page.locator('#latexStatus[data-state="error"]').waitFor(); + await page.waitForTimeout(500); + assert.equal( + await page.locator('#latexStatus').getAttribute('data-state'), + 'error', + ); + await page.unroute('**/api/latex/render'); + + assert.deepEqual(browserErrors, []); + await context.close(); + } finally { + if (browser) await browser.close(); + await stopProcess(server); + } +})().catch(error => { + console.error(error.stack || error); + process.exitCode = 1; +});