Mercurial
comparison 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 |
comparison
equal
deleted
inserted
replaced
| 243:823f2a8b16c8 | 244:b8aa08503378 |
|---|---|
| 1 const assert = require('node:assert/strict'); | |
| 2 const fs = require('node:fs'); | |
| 3 const http = require('node:http'); | |
| 4 const net = require('node:net'); | |
| 5 const path = require('node:path'); | |
| 6 const { spawn } = require('node:child_process'); | |
| 7 | |
| 8 const RUNFILES = process.env.JS_BINARY__RUNFILES; | |
| 9 const WORKSPACE = process.env.JS_BINARY__WORKSPACE; | |
| 10 const runfilesWorkspace = path.join(RUNFILES, WORKSPACE); | |
| 11 const playwrightPath = path.join( | |
| 12 runfilesWorkspace, | |
| 13 'hg-web/e2e/node_modules/playwright-core', | |
| 14 ); | |
| 15 const { chromium } = require(playwrightPath); | |
| 16 | |
| 17 function findFreePort() { | |
| 18 return new Promise((resolve, reject) => { | |
| 19 const socket = net.createServer(); | |
| 20 socket.once('error', reject); | |
| 21 socket.listen(0, '127.0.0.1', () => { | |
| 22 const address = socket.address(); | |
| 23 socket.close(error => { | |
| 24 if (error) reject(error); | |
| 25 else resolve(String(address.port)); | |
| 26 }); | |
| 27 }); | |
| 28 }); | |
| 29 } | |
| 30 | |
| 31 async function waitForServer(server, baseUrl, logs) { | |
| 32 const deadline = Date.now() + 15000; | |
| 33 while (Date.now() < deadline) { | |
| 34 if (server.exitCode !== null) { | |
| 35 throw new Error(`Server exited with ${server.exitCode}\n${logs.join('')}`); | |
| 36 } | |
| 37 try { | |
| 38 const response = await fetch(baseUrl); | |
| 39 if (response.ok) return; | |
| 40 } catch { | |
| 41 // Keep waiting for startup. | |
| 42 } | |
| 43 await new Promise(resolve => setTimeout(resolve, 100)); | |
| 44 } | |
| 45 throw new Error(`Server startup timed out\n${logs.join('')}`); | |
| 46 } | |
| 47 | |
| 48 async function stopProcess(child) { | |
| 49 if (!child || child.exitCode !== null) return; | |
| 50 child.kill('SIGTERM'); | |
| 51 await new Promise(resolve => { | |
| 52 const timer = setTimeout(() => { | |
| 53 if (child.exitCode === null) child.kill('SIGKILL'); | |
| 54 }, 3000); | |
| 55 child.once('exit', () => { | |
| 56 clearTimeout(timer); | |
| 57 resolve(); | |
| 58 }); | |
| 59 }); | |
| 60 } | |
| 61 | |
| 62 async function render(baseUrl, source) { | |
| 63 return fetch(`${baseUrl}/api/latex/render`, { | |
| 64 method: 'POST', | |
| 65 headers: { 'Content-Type': 'text/plain; charset=utf-8' }, | |
| 66 body: source, | |
| 67 }); | |
| 68 } | |
| 69 | |
| 70 function requestWithoutReuse(baseUrl, method, requestPath, body = '') { | |
| 71 const url = new URL(requestPath, baseUrl); | |
| 72 return new Promise((resolve, reject) => { | |
| 73 const request = http.request({ | |
| 74 host: url.hostname, | |
| 75 port: url.port, | |
| 76 path: url.pathname, | |
| 77 method, | |
| 78 agent: false, | |
| 79 headers: { | |
| 80 Connection: 'close', | |
| 81 'Content-Type': 'text/plain; charset=utf-8', | |
| 82 'Content-Length': Buffer.byteLength(body), | |
| 83 }, | |
| 84 }, response => { | |
| 85 const chunks = []; | |
| 86 response.on('data', chunk => chunks.push(chunk)); | |
| 87 response.on('end', () => resolve({ | |
| 88 status: response.statusCode, | |
| 89 body: Buffer.concat(chunks), | |
| 90 })); | |
| 91 }); | |
| 92 request.on('error', reject); | |
| 93 request.end(body); | |
| 94 }); | |
| 95 } | |
| 96 | |
| 97 (async () => { | |
| 98 assert.ok(RUNFILES); | |
| 99 assert.ok(WORKSPACE); | |
| 100 const serverBinary = path.join( | |
| 101 runfilesWorkspace, | |
| 102 'mrjunejune/mrjunejune_server', | |
| 103 ); | |
| 104 const chromiumPath = path.resolve(process.env.CHROMIUM_PATH); | |
| 105 const port = await findFreePort(); | |
| 106 const baseUrl = `http://127.0.0.1:${port}`; | |
| 107 const logs = []; | |
| 108 let server; | |
| 109 let browser; | |
| 110 | |
| 111 try { | |
| 112 server = spawn(serverBinary, [], { | |
| 113 cwd: runfilesWorkspace, | |
| 114 env: { | |
| 115 ...process.env, | |
| 116 MRJUNEJUNE_PORT: port, | |
| 117 }, | |
| 118 stdio: ['ignore', 'pipe', 'pipe'], | |
| 119 }); | |
| 120 server.stdout.on('data', chunk => logs.push(chunk.toString())); | |
| 121 server.stderr.on('data', chunk => logs.push(chunk.toString())); | |
| 122 await waitForServer(server, baseUrl, logs); | |
| 123 | |
| 124 const valid = String.raw`\documentclass{article} | |
| 125 \begin{document} | |
| 126 Hello from the API. $a^2 + b^2 = c^2$ | |
| 127 \end{document} | |
| 128 `; | |
| 129 let response = await render(baseUrl, valid); | |
| 130 assert.equal(response.status, 200, await response.clone().text()); | |
| 131 assert.match(response.headers.get('content-type') || '', /^application\/pdf/); | |
| 132 assert.equal(response.headers.get('cache-control'), 'no-store'); | |
| 133 const pdf = Buffer.from(await response.arrayBuffer()); | |
| 134 assert.ok(pdf.length > 1000); | |
| 135 assert.equal(pdf.subarray(0, 5).toString(), '%PDF-'); | |
| 136 | |
| 137 response = await render( | |
| 138 baseUrl, | |
| 139 String.raw`\documentclass{article}\begin{document}\badcommand\end{document}`, | |
| 140 ); | |
| 141 assert.equal(response.status, 422); | |
| 142 assert.match(await response.text(), /Undefined control sequence/); | |
| 143 | |
| 144 response = await render(baseUrl, 'x'.repeat(64 * 1024 + 1)); | |
| 145 assert.equal(response.status, 413); | |
| 146 assert.match(await response.text(), /64 KiB/); | |
| 147 | |
| 148 response = await render( | |
| 149 baseUrl, | |
| 150 String.raw`\documentclass{article}\begin{document}\input{/etc/passwd}\end{document}`, | |
| 151 ); | |
| 152 assert.equal(response.status, 422); | |
| 153 assert.doesNotMatch(await response.text(), /root:x:/); | |
| 154 | |
| 155 const shellPath = `/tmp/mrjunejune-latex-browser-${process.pid}`; | |
| 156 response = await render( | |
| 157 baseUrl, | |
| 158 String.raw`\documentclass{article} | |
| 159 \begin{document} | |
| 160 \immediate\write18{touch ${shellPath}} | |
| 161 Shell escape stays disabled. | |
| 162 \end{document}`, | |
| 163 ); | |
| 164 assert.equal(response.status, 200); | |
| 165 assert.equal(fs.existsSync(shellPath), false); | |
| 166 | |
| 167 const infiniteRender = requestWithoutReuse( | |
| 168 baseUrl, | |
| 169 'POST', | |
| 170 '/api/latex/render', | |
| 171 String.raw`\documentclass{article}\begin{document}\loop\iftrue\repeat\end{document}`, | |
| 172 ); | |
| 173 await new Promise(resolve => setTimeout(resolve, 200)); | |
| 174 const healthStarted = Date.now(); | |
| 175 let isolatedResponse = await requestWithoutReuse(baseUrl, 'GET', '/tools'); | |
| 176 assert.equal(isolatedResponse.status, 200); | |
| 177 assert.ok(Date.now() - healthStarted < 1500); | |
| 178 isolatedResponse = await requestWithoutReuse( | |
| 179 baseUrl, | |
| 180 'POST', | |
| 181 '/api/latex/render', | |
| 182 valid, | |
| 183 ); | |
| 184 assert.equal(isolatedResponse.status, 429); | |
| 185 assert.match(isolatedResponse.body.toString(), /compiler is busy/); | |
| 186 isolatedResponse = await infiniteRender; | |
| 187 assert.equal(isolatedResponse.status, 504); | |
| 188 assert.match( | |
| 189 isolatedResponse.body.toString(), | |
| 190 /CPU or memory limit|8 second limit/, | |
| 191 ); | |
| 192 | |
| 193 browser = await chromium.launch({ | |
| 194 executablePath: chromiumPath, | |
| 195 headless: true, | |
| 196 args: ['--no-sandbox'], | |
| 197 }); | |
| 198 const context = await browser.newContext(); | |
| 199 await context.addInitScript(() => { | |
| 200 window.__pdfUrls = { created: [], revoked: [] }; | |
| 201 const createObjectURL = URL.createObjectURL.bind(URL); | |
| 202 const revokeObjectURL = URL.revokeObjectURL.bind(URL); | |
| 203 URL.createObjectURL = value => { | |
| 204 const url = createObjectURL(value); | |
| 205 window.__pdfUrls.created.push(url); | |
| 206 return url; | |
| 207 }; | |
| 208 URL.revokeObjectURL = url => { | |
| 209 window.__pdfUrls.revoked.push(url); | |
| 210 revokeObjectURL(url); | |
| 211 }; | |
| 212 }); | |
| 213 const page = await context.newPage(); | |
| 214 const browserErrors = []; | |
| 215 let testingExpectedFailure = false; | |
| 216 page.on('pageerror', error => browserErrors.push(error.message)); | |
| 217 page.on('console', message => { | |
| 218 if (message.type() !== 'error') return; | |
| 219 if (testingExpectedFailure && | |
| 220 message.text().startsWith('Failed to load resource:')) return; | |
| 221 browserErrors.push(message.text()); | |
| 222 }); | |
| 223 | |
| 224 await page.goto(`${baseUrl}/tools/latex_editor`, { | |
| 225 waitUntil: 'networkidle', | |
| 226 }); | |
| 227 await page.locator('#latexStatus[data-state="ready"]').waitFor({ | |
| 228 timeout: 15000, | |
| 229 }); | |
| 230 assert.match( | |
| 231 await page.locator('#pdfPreview').getAttribute('src'), | |
| 232 /^blob:/, | |
| 233 ); | |
| 234 assert.equal( | |
| 235 await page.locator('#downloadButton').getAttribute('download'), | |
| 236 'document.pdf', | |
| 237 ); | |
| 238 | |
| 239 await page.locator('#autoCompile').uncheck(); | |
| 240 await page.locator('#latexSource').fill( | |
| 241 String.raw`\documentclass{article}\begin{document}\badcommand\end{document}`, | |
| 242 ); | |
| 243 testingExpectedFailure = true; | |
| 244 await page.getByRole('button', { name: 'Compile PDF' }).click(); | |
| 245 await page.locator('#latexStatus[data-state="error"]').waitFor(); | |
| 246 testingExpectedFailure = false; | |
| 247 assert.match( | |
| 248 await page.locator('#latexDiagnostics').textContent(), | |
| 249 /Undefined control sequence/, | |
| 250 ); | |
| 251 | |
| 252 await page.locator('#latexSource').fill(valid); | |
| 253 await page.getByRole('button', { name: 'Compile PDF' }).click(); | |
| 254 await page.locator('#latexStatus[data-state="ready"]').waitFor(); | |
| 255 const objectUrls = await page.evaluate(() => window.__pdfUrls); | |
| 256 assert.ok(objectUrls.created.length >= 2, JSON.stringify(objectUrls)); | |
| 257 assert.ok(objectUrls.revoked.length >= 1, JSON.stringify(objectUrls)); | |
| 258 | |
| 259 let delayNextRender = true; | |
| 260 await page.route('**/api/latex/render', async route => { | |
| 261 if (!delayNextRender) { | |
| 262 await route.continue(); | |
| 263 return; | |
| 264 } | |
| 265 delayNextRender = false; | |
| 266 await new Promise(resolve => setTimeout(resolve, 300)); | |
| 267 try { | |
| 268 await route.fulfill({ | |
| 269 status: 200, | |
| 270 contentType: 'application/pdf', | |
| 271 body: pdf, | |
| 272 }); | |
| 273 } catch { | |
| 274 // The corrected client aborts this stale request. | |
| 275 } | |
| 276 }); | |
| 277 await page.locator('#latexSource').fill(valid); | |
| 278 await page.getByRole('button', { name: 'Compile PDF' }).click(); | |
| 279 await page.locator('#latexStatus[data-state="working"]').waitFor(); | |
| 280 await page.locator('#latexSource').fill('x'.repeat(64 * 1024 + 1)); | |
| 281 await page.locator('#latexStatus[data-state="error"]').waitFor(); | |
| 282 await page.waitForTimeout(500); | |
| 283 assert.equal( | |
| 284 await page.locator('#latexStatus').getAttribute('data-state'), | |
| 285 'error', | |
| 286 ); | |
| 287 await page.unroute('**/api/latex/render'); | |
| 288 | |
| 289 assert.deepEqual(browserErrors, []); | |
| 290 await context.close(); | |
| 291 } finally { | |
| 292 if (browser) await browser.close(); | |
| 293 await stopProcess(server); | |
| 294 } | |
| 295 })().catch(error => { | |
| 296 console.error(error.stack || error); | |
| 297 process.exitCode = 1; | |
| 298 }); |