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