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 });