view mrjunejune/test/latex_editor_test.js @ 273:e02e2036ef84 default tip

add Layer 2 JRPG component system Add reusable content and window modals, an isolated component sandbox, shared cyberpunk scroll areas, production-safe cache freshness, and server-rendered JRPG panel state. Co-authored-by: Copilot <[email protected]>
author MrJuneJune <me@mrjunejune.com>
date Sat, 08 Aug 2026 02:08:08 -0700
parents 70f2a3dafc1c
children
line wrap: on
line source

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.waitForFunction(() => {
      const state = document.querySelector('#latexStatus')?.dataset.state;
      return state === 'ready' || state === 'error';
    }, null, {
      timeout: 15000,
    });
    assert.equal(
      await page.locator('#latexStatus').getAttribute('data-state'),
      'ready',
      await page.locator('#latexDiagnostics').textContent(),
    );
    const editorAppearance = await page.locator('#latexSource').evaluate(element => {
      const style = getComputedStyle(element);
      const bounds = element.getBoundingClientRect();
      return {
        background: style.backgroundColor,
        color: style.color,
        display: style.display,
        height: bounds.height,
        value: element.value,
        width: bounds.width,
      };
    });
    assert.equal(editorAppearance.display, 'block');
    assert.ok(editorAppearance.width > 300, JSON.stringify(editorAppearance));
    assert.ok(editorAppearance.height > 400, JSON.stringify(editorAppearance));
    assert.notEqual(editorAppearance.background, editorAppearance.color);
    assert.match(editorAppearance.value, /\\documentclass/);
    assert.match(
      await page.locator('#pdfPreview').getAttribute('src'),
      /^blob:/,
    );
    assert.equal(
      await page.locator('#downloadButton').getAttribute('download'),
      'document.pdf',
    );

    let livePreviewRequests = 0;
    const countLivePreviewRequest = request => {
      if (request.url().endsWith('/api/latex/render')) livePreviewRequests++;
    };
    page.on('request', countLivePreviewRequest);
    const livePreviewResponse = page.waitForResponse(response =>
      response.url().endsWith('/api/latex/render') &&
      response.status() === 200
    );
    await page.locator('#latexSource').fill(valid.replace('API.', 'API change one.'));
    await page.waitForTimeout(100);
    await page.locator('#latexSource').fill(valid.replace('API.', 'API change two.'));
    await page.waitForTimeout(100);
    await page.locator('#latexSource').fill(valid.replace('API.', 'API final change.'));
    await livePreviewResponse;
    await page.locator('#latexStatus[data-state="ready"]').waitFor();
    page.off('request', countLivePreviewRequest);
    assert.equal(livePreviewRequests, 1);

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