import assert from 'node:assert/strict'; import { createServer } from 'node:http'; import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { spawn } from 'node:child_process'; import { gzipSync } from 'node:zlib'; import { delay, evaluate, launchChromium } from './raw-cdp-browser.mjs'; const host = '127.0.0.1'; const appRoot = resolve(import.meta.dirname, '..'); const viteEntrypoint = resolve(appRoot, 'node_modules/vite/bin/vite.js'); const browserProfile = await mkdtemp(join(tmpdir(), 'cartesium-graph-text-')); let vite; let chromium; let browser; try { const port = await findFreePort(); const origin = `http://${host}:${port}`; vite = spawn( process.execPath, [viteEntrypoint, 'dev', '--host', host, '--port', String(port), '--strictPort'], { cwd: appRoot, stdio: ['ignore', 'pipe', 'pipe'] } ); const viteOutput = []; collectOutput(vite, viteOutput); await waitForServer(`${origin}/calc/2d`, vite, viteOutput); const launched = await launchChromium(browserProfile); chromium = launched.process; browser = launched.browser; const browserLogs = []; browser.on('Runtime.consoleAPICalled', (event) => { browserLogs.push(event.args.map((argument) => argument.value ?? argument.description)); }); browser.on('Runtime.exceptionThrown', (event) => { browserLogs.push(['exception', event.exceptionDetails?.text, event.exceptionDetails?.url, event.exceptionDetails?.lineNumber, event.exceptionDetails?.columnNumber, event.exceptionDetails?.exception?.description]); }); browser.on('Log.entryAdded', (event) => browserLogs.push(['log', event.entry?.source, event.entry?.text, event.entry?.url, event.entry?.lineNumber])); const { targetId } = await browser.command('Target.createTarget', { url: 'about:blank' }); const { sessionId } = await browser.command('Target.attachToTarget', { targetId, flatten: true }); const command = (method, params = {}) => browser.command(method, params, sessionId); await command('Page.enable'); await command('Runtime.enable'); await command('Log.enable'); await command('Emulation.setDeviceMetricsOverride', { width: 1280, height: 900, deviceScaleFactor: 1, mobile: false }); await command('Page.navigate', { url: `${origin}/calc/2d` }); await waitForEvaluation( command, `(() => ({ ready: Boolean(document.querySelector('.plot-canvas') && [...document.querySelectorAll('button')].some(button => button.textContent.trim() === 'Export PNG')), body: document.body.innerText.slice(0, 500), pageError: document.querySelector('[role="alert"]')?.textContent }))()`, '2D graph toolbar', 30_000, (state) => state.ready ); await evaluate( command, `(() => { window.__graphTextPngs = []; window.__graphTextPngBlobs = []; HTMLAnchorElement.prototype.click = function () { if (this.download.endsWith('.png')) { window.__graphTextPngs.push(this.href); window.__graphTextPngBlobs.push(fetch(this.href).then(response => response.blob())); } }; })()` ); await clickExport(command); await waitForEvaluation(command, 'window.__graphTextPngs.length === 1', 'baseline PNG export'); await evaluate( command, `(async () => { const bitmap = await createImageBitmap(await window.__graphTextPngBlobs[0]); const canvas = document.createElement('canvas'); canvas.width = bitmap.width; canvas.height = bitmap.height; canvas.getContext('2d').drawImage(bitmap, 0, 0); window.__graphTextBaseline = canvas; return { width: canvas.width, height: canvas.height }; })()` ); await evaluate(command, `document.querySelector('.add-text').click()`); await waitForEvaluation(command, `Boolean(document.querySelector('.text-editor textarea'))`, 'text editor'); await evaluate( command, `(() => { const editor = document.querySelector('.text-editor textarea'); const source = 'Note **bold** and $x^2$ ![preview](/graph-previews/hello.png) ![missing](/missing-graph-text-image.png) [bad](javascript:alert(1))'; Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value').set.call(editor, source); editor.dispatchEvent(new Event('input', { bubbles: true })); })()` ); const sourceRowPreview = await evaluate( command, `document.querySelector('.text-row-preview')?.textContent ?? ''` ); assert(sourceRowPreview.includes('Note **bold**'), 'text rows should preview their Markdown and LaTeX source'); await evaluate(command, `document.querySelector('.text-row-container .toggle input').click()`); await waitForEvaluation(command, `!document.querySelector('.text-item')`, 'disabled text hidden from graph'); assert( (await evaluate(command, `document.querySelector('.text-row-container .toggle input').checked`)) === false, 'text visibility toggle should reflect the disabled state' ); await evaluate(command, `document.querySelector('.text-row-container .toggle input').click()`); await waitForEvaluation(command, `Boolean(document.querySelector('.text-item'))`, 'enabled text restored to graph'); assert( (await evaluate(command, `document.querySelector('.text-row').getAttribute('aria-expanded')`)) === 'true', 'new text should open for editing' ); await evaluate(command, `document.querySelector('.text-row').click()`); await waitForEvaluation( command, `!document.querySelector('.text-editor') && !document.querySelector('.text-item .resize-handle') && document.querySelector('.text-row').getAttribute('aria-expanded') === 'false'`, 'clicking an open text row should collapse its controls and graph handle' ); await evaluate(command, `document.querySelector('.text-row').click()`); await waitForEvaluation( command, `Boolean(document.querySelector('.text-item.selected') && document.querySelector('.text-item .resize-handle')) && !document.querySelector('.text-controls')`, 'sidebar selection enabling the graph text box without a toolbar' ); const centerBeforeEdit = await evaluate( command, `(() => { const input = document.querySelector('[data-text-field="position-x"]'); const rect = document.querySelector('.text-item').getBoundingClientRect(); return { x: input.value, left: rect.left }; })()` ); await setTextNumber(command, 'position-x', '2.5'); const centerAfterEdit = await evaluate( command, `(() => { const input = document.querySelector('[data-text-field="position-x"]'); const rect = document.querySelector('.text-item').getBoundingClientRect(); return { x: input.value, left: rect.left }; })()` ); assert(centerAfterEdit.x === '2.5', 'center x should accept a finite graph coordinate'); assert(centerAfterEdit.left > centerBeforeEdit.left, 'changing center x should move the graph text box'); await setTextNumber(command, 'position-x', '1e13'); assert( (await evaluate(command, `document.querySelector('[data-text-field="position-x"]').value`)) === '2.5', 'anchor x outside the supported range should be rejected' ); const beforeAnchorChange = await evaluate( command, `(() => { const rect = document.querySelector('.text-item').getBoundingClientRect(); return { left: rect.left, top: rect.top, width: rect.width, height: rect.height }; })()` ); await evaluate(command, `document.querySelector('[data-text-anchor="top-right"]').click()`); const afterAnchorChange = await evaluate( command, `(() => { const rect = document.querySelector('.text-item').getBoundingClientRect(); return { left: rect.left, top: rect.top, width: rect.width, height: rect.height, selected: document.querySelector('[data-text-anchor="top-right"]').getAttribute('aria-pressed') }; })()` ); assert(Math.abs(afterAnchorChange.left - beforeAnchorChange.left) < 1, 'changing anchor should keep the live box left edge fixed'); assert(Math.abs(afterAnchorChange.top - beforeAnchorChange.top) < 1, 'changing anchor should keep the live box top edge fixed'); assert(afterAnchorChange.width === beforeAnchorChange.width && afterAnchorChange.height === beforeAnchorChange.height, 'changing anchor should keep the live box size'); assert(afterAnchorChange.selected === 'true', 'sidebar should select the top-right anchor'); await setTextNumber(command, 'width', '20'); await setTextNumber(command, 'height', '3000'); await setTextNumber(command, 'font-size', '300'); const limitedDimensions = await evaluate( command, `[...document.querySelectorAll('[data-text-field]')].reduce((result, input) => (result[input.dataset.textField] = input.value, result), {})` ); assert(limitedDimensions.width === '80', 'text width should clamp to the snapshot minimum'); assert(limitedDimensions.height === '2000', 'text height should clamp to the snapshot maximum'); assert(limitedDimensions['font-size'] === '96', 'text font size should clamp to the snapshot maximum'); await setTextNumber(command, 'position-x', centerBeforeEdit.x); await setTextNumber(command, 'width', '240'); await setTextNumber(command, 'height', '96'); await setTextNumber(command, 'font-size', '16'); await evaluate(command, `document.querySelector('.add-text').click()`); await waitForEvaluation( command, `document.querySelectorAll('.text-row').length === 2 && document.querySelectorAll('.text-item').length === 2`, 'second text element' ); await setTextNumber(command, 'position-x', '-2'); await evaluate(command, `document.querySelectorAll('.text-row')[0].click()`); const secondTextRect = await evaluate( command, `(() => { const rect = document.querySelectorAll('.text-item')[1].getBoundingClientRect(); return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }; })()` ); await clickAt(command, secondTextRect); assert( (await evaluate(command, `document.querySelectorAll('.text-row')[1].getAttribute('aria-expanded')`)) === 'true', 'clicking a text box should select its sidebar row' ); await evaluate(command, `document.querySelector('.delete-text').click()`); await waitForEvaluation(command, `document.querySelectorAll('.text-row').length === 1`, 'delete text element'); await evaluate(command, `document.querySelector('.text-row').click()`); await setTextSource(command, 'Note **bold** and $x^2$ ![preview](/graph-previews/hello.png) ![missing](/missing-graph-text-image.png) [bad](javascript:alert(1))'); try { await waitForEvaluation( command, `(() => { const item = document.querySelector('.text-item'); const math = item?.querySelector('mjx-container'); const image = item?.querySelector('img'); return { ready: Boolean(math && image?.src.startsWith('data:image/png') && item?.querySelector('.graph-text-image-error')), itemText: item?.textContent, itemHtml: item?.innerHTML.slice(0, 500), imageSrc: image?.src.slice(0, 80), editorValue: document.querySelector('.text-editor textarea')?.value }; })()`, 'MathJax and inline-image rendering', 30_000, (state) => state.ready ); } catch (error) { console.error(JSON.stringify(browserLogs)); throw error; } const safety = await evaluate( command, `({ rawImageTags: document.querySelectorAll('.text-item img[src^="javascript:"]').length, unsafeLinks: document.querySelectorAll('.text-item a[href^="javascript:"]').length, remoteImages: [...document.querySelectorAll('.text-item img')].filter(image => !image.src.startsWith('data:')).length })` ); assert(safety.rawImageTags === 0, 'unsafe image URLs must not render'); assert(safety.unsafeLinks === 0, 'unsafe Markdown links must not render as active links'); assert(safety.remoteImages === 0, 'images must be inlined before live rendering and export'); const beforeMove = await evaluate( command, `(() => { const rect = document.querySelector('.text-item').getBoundingClientRect(); return { left: rect.left, top: rect.top, width: rect.width, height: rect.height }; })()` ); await dragPointer( command, { x: beforeMove.left + 45, y: beforeMove.top + 25 }, { x: beforeMove.left + 105, y: beforeMove.top + 65 } ); const afterMove = await evaluate( command, `(() => { const rect = document.querySelector('.text-item').getBoundingClientRect(); return { left: rect.left, top: rect.top, width: rect.width, height: rect.height }; })()` ); assert(Math.abs(afterMove.left - beforeMove.left - 60) < 3, 'selected text should drag horizontally'); assert(Math.abs(afterMove.top - beforeMove.top - 40) < 3, 'selected text should drag vertically'); assert(afterMove.width === beforeMove.width && afterMove.height === beforeMove.height, 'dragging should preserve the display box'); const beforeResize = await evaluate( command, `(() => { const rect = document.querySelector('.text-item').getBoundingClientRect(); const handle = document.querySelector('[data-text-resize-handle]').getBoundingClientRect(); return { rect: { width: rect.width, height: rect.height }, handle: { x: handle.right - 1, y: handle.bottom - 1 } }; })()` ); await dragPointer( command, beforeResize.handle, { x: beforeResize.handle.x + 60, y: beforeResize.handle.y + 40 } ); const afterResize = await evaluate( command, `(() => { const rect = document.querySelector('.text-item').getBoundingClientRect(); return { width: rect.width, height: rect.height }; })()` ); assert(afterResize.width > beforeResize.rect.width + 50, 'resize handle should increase text width'); assert(afterResize.height > beforeResize.rect.height + 30, 'resize handle should increase text height'); const fixtureResults = []; for (const [label, source] of [ ['plain text', 'Plain text export fixture'], ['math only', 'Math export fixture $x^2$'], ['display fraction and integral', '$$\\frac{1}{2 \\pi} \\int e^{-x^2} dx$$'], ['image only', 'Image export fixture ![preview](/graph-previews/hello.png)'] ]) { await setTextSource(command, source); await delay(1_000); const expectedCount = 2 + fixtureResults.length; await clickExport(command); let fixture; try { fixture = await waitForEvaluation( command, `({ count: window.__graphTextPngs.length, tainted: document.body.innerText.includes('Tainted canvases'), status: document.body.innerText.match(/Preparing PNG…|Downloaded [^\\n]+|Failed to execute[^\\n]+|MathJax SVG[^\\n]+|Could not load[^\\n]+/)?.[0] ?? '' })`, `${label} PNG export`, 10_000, (state) => (state.count === expectedCount || state.tainted || state.status.startsWith('MathJax') || state.status.startsWith('Could not')) && !state.status.startsWith('Preparing') ); } catch (error) { console.error(JSON.stringify(browserLogs)); throw error; } fixtureResults.push({ label, ...fixture, expectedCount }); } console.log(JSON.stringify({ fixtureResults })); assert( fixtureResults.every(({ count, expectedCount, tainted }) => count === expectedCount && !tainted), `text export fixtures must remain origin-clean: ${JSON.stringify(fixtureResults)}` ); const runtimeBuild = await evaluate(command, `(() => { const frame = document.querySelector('iframe[title="MathJax SVG export runtime"]'); const script = frame?.contentDocument?.querySelector('script[src*="mathjax-svg-runtime.js"]'); return { requested: script ? new URL(script.src).searchParams.get('v') : null, current: document.querySelector('[data-build-commit]')?.getAttribute('data-build-commit') }; })()`); assert(runtimeBuild.requested === runtimeBuild.current, `MathJax export runtime must match the app build: ${JSON.stringify(runtimeBuild)}`); await setTextSource( command, 'Note **bold** and $x^2$ ![preview](/graph-previews/hello.png) ![missing](/missing-graph-text-image.png) [bad](javascript:alert(1))' ); await delay(1_000); await clickExport(command); try { await waitForEvaluation( command, `({ count: window.__graphTextPngs.length, alerts: [...document.querySelectorAll('[role="alert"]')].map(node => node.textContent), buttons: [...document.querySelectorAll('button')].map(button => [button.textContent.trim(), button.disabled]), body: document.body.innerText.slice(-700) })`, 'annotated PNG export', 30_000, (state) => state.count === 6 ); } catch (error) { console.error(JSON.stringify(browserLogs)); throw error; } const exportResult = await evaluate( command, `(async () => { const bitmap = await createImageBitmap(await window.__graphTextPngBlobs[5]); const canvas = document.createElement('canvas'); canvas.width = bitmap.width; canvas.height = bitmap.height; const context = canvas.getContext('2d'); context.drawImage(bitmap, 0, 0); const current = context.getImageData(0, 0, canvas.width, canvas.height).data; const baseline = window.__graphTextBaseline.getContext('2d').getImageData(0, 0, canvas.width, canvas.height).data; const surface = document.querySelector('.graph-area .surface'); const surfaceRect = surface.getBoundingClientRect(); const textRect = document.querySelector('.text-item').getBoundingClientRect(); const scaleX = canvas.width / surfaceRect.width; const scaleY = canvas.height / surfaceRect.height; const left = Math.max(0, Math.floor((textRect.left - surfaceRect.left) * scaleX)); const top = Math.max(0, Math.floor((textRect.top - surfaceRect.top) * scaleY)); const right = Math.min(canvas.width, Math.ceil(left + textRect.width * scaleX)); const bottom = Math.min(canvas.height, Math.ceil(top + textRect.height * scaleY)); let changedPixels = 0; for (let y = top; y < bottom; y += 1) { for (let x = left; x < right; x += 1) { const index = (y * canvas.width + x) * 4; if (Math.abs(current[index] - baseline[index]) + Math.abs(current[index + 1] - baseline[index + 1]) + Math.abs(current[index + 2] - baseline[index + 2]) > 45) changedPixels += 1; } } return { width: canvas.width, height: canvas.height, changedPixels, nonCenterAnchor: document.querySelector('[data-text-anchor="top-right"]')?.getAttribute('aria-pressed') === 'true' }; })()` ); assert(exportResult.changedPixels > 40, `expected exported text pixels in the annotation bounds, got ${JSON.stringify(exportResult)}`); assert(exportResult.nonCenterAnchor, 'PNG export should include text using the selected non-center anchor'); const beforeZoom = await evaluate( command, `(() => { const rect = document.querySelector('.text-item').getBoundingClientRect(); const surface = document.querySelector('.graph-area .surface').getBoundingClientRect(); return { width: rect.width, height: rect.height, fontSize: getComputedStyle(document.querySelector('.text-item')).fontSize, x: surface.left + 20, y: surface.top + 20 }; })()` ); await command('Input.dispatchMouseEvent', { type: 'mouseWheel', x: beforeZoom.x, y: beforeZoom.y, deltaY: -500, deltaX: 0 }); await delay(500); const afterZoom = await evaluate( command, `(() => { const rect = document.querySelector('.text-item').getBoundingClientRect(); return { width: rect.width, height: rect.height, fontSize: getComputedStyle(document.querySelector('.text-item')).fontSize }; })()` ); assert(afterZoom.width === beforeZoom.width, 'zoom should preserve annotation width in CSS pixels'); assert(afterZoom.height === beforeZoom.height, 'zoom should preserve annotation height in CSS pixels'); assert(afterZoom.fontSize === beforeZoom.fontSize, 'zoom should preserve annotation font size'); await evaluate( command, `document.querySelector('article[data-expression-id] input[type="checkbox"]').click()` ); assert( (await evaluate(command, `document.querySelector('.text-row').getAttribute('aria-expanded')`)) === 'false', 'selecting an expression should collapse text controls' ); await evaluate( command, `(() => { const theme = document.querySelector('.theme-picker select'); theme.value = 'dark'; theme.dispatchEvent(new Event('change', { bubbles: true })); })()` ); await waitForEvaluation(command, `document.querySelector('main').classList.contains('dark')`, 'dark theme'); await evaluate(command, `document.querySelector('.text-row').click()`); const darkTextStyles = await evaluate( command, `(() => ({ section: getComputedStyle(document.querySelector('.text-section')).backgroundColor, row: getComputedStyle(document.querySelector('.text-row')).backgroundColor, rowText: getComputedStyle(document.querySelector('.text-row')).color, input: getComputedStyle(document.querySelector('.text-editor input')).backgroundColor }))()` ); assert(darkTextStyles.section === 'rgb(17, 24, 39)', `text section should use the dark panel color: ${JSON.stringify(darkTextStyles)}`); assert(darkTextStyles.row === 'rgb(15, 23, 42)', `text rows should remain legible in dark theme: ${JSON.stringify(darkTextStyles)}`); assert(darkTextStyles.rowText === 'rgb(226, 232, 240)', `text row labels should remain legible in dark theme: ${JSON.stringify(darkTextStyles)}`); assert(darkTextStyles.input === 'rgb(15, 23, 42)', `text editor fields should use the dark theme: ${JSON.stringify(darkTextStyles)}`); const selectedDarkBorder = await evaluate( command, `getComputedStyle(document.querySelector('.text-row.selected')).borderTopColor` ); assert( selectedDarkBorder === 'rgb(96, 165, 250)', `selected text rows should keep a visible highlight in dark theme: ${selectedDarkBorder}` ); await evaluate( command, `(() => { window.confirm = () => true; [...document.querySelectorAll('button')].find(button => button.textContent.trim() === 'New').click(); })()` ); await waitForEvaluation( command, `document.querySelectorAll('.text-row').length === 0 && !document.querySelector('.text-item')`, 'new graph clearing text selection' ); await evaluate(command, `document.querySelector('.add-text').click()`); await evaluate( command, `(() => { const preset = document.querySelector('[aria-label="Load 2d graph example"]'); preset.value = preset.options[1].value; preset.dispatchEvent(new Event('change', { bubbles: true })); })()` ); await waitForEvaluation( command, `document.querySelectorAll('.text-row').length === 0 && !document.querySelector('.text-item') && !document.querySelector('.text-controls')`, 'preset load clearing text selection' ); await evaluate( command, `(() => { const add = document.querySelector('.add-expression'); for (let index = 0; index < 20; index += 1) add.click(); })()` ); await waitForEvaluation(command, `document.querySelectorAll('article[data-expression-id]').length >= 20`, 'many expression rows'); await evaluate(command, `document.querySelector('.add-text').click()`); const scrollMetrics = await evaluate( command, `(() => { const rows = document.querySelector('.rows'); rows.scrollTop = rows.scrollHeight; const rowPreview = document.querySelector('.text-row-preview'); const rowRect = rowPreview.getBoundingClientRect(); const editorRect = document.querySelector('.text-editor textarea').getBoundingClientRect(); const rowsRect = rows.getBoundingClientRect(); const editorVisible = editorRect.bottom > rowsRect.top && editorRect.top < rowsRect.bottom; rows.scrollTop = Math.max(0, rows.scrollTop + rowRect.top - rowsRect.top); const reachablePreview = rowPreview.getBoundingClientRect(); const reachableRows = rows.getBoundingClientRect(); return { scrollHeight: rows.scrollHeight, clientHeight: rows.clientHeight, previewVisible: reachablePreview.bottom > reachableRows.top && reachablePreview.top < reachableRows.bottom, editorVisible }; })()` ); assert(scrollMetrics.scrollHeight > scrollMetrics.clientHeight, 'expression and text rows should share a scrollable container'); assert(scrollMetrics.previewVisible, 'text source preview should be reachable after scrolling past many expressions'); assert(scrollMetrics.editorVisible, 'text source editor should be reachable after scrolling past many expressions'); await setTextSource(command, 'woof, *woof*, **woof**, ***woof***\n$$\\frac{1}{2 \\pi} \\int e^{-x^2} dx$$'); await waitForEvaluation(command, `Boolean(document.querySelector('.text-item mjx-mfrac'))`, 'display math'); const mathLayout = await evaluate(command, `(() => { const item = document.querySelector('.text-item').getBoundingClientRect(); const numerator = document.querySelector('.text-item mjx-num').getBoundingClientRect(); const line = document.querySelector('.text-item mjx-line').getBoundingClientRect(); const denominator = document.querySelector('.text-item mjx-den').getBoundingClientRect(); const integral = [...document.querySelectorAll('.text-item mjx-mo')].find(node => node.getAttribute('data-latex')?.endsWith('int'))?.querySelector('mjx-c')?.getBoundingClientRect(); return { item: item.toJSON(), numerator: numerator.toJSON(), line: line.toJSON(), denominator: denominator.toJSON(), integral: integral.toJSON() }; })()`); assert(mathLayout.line.width > 10, 'the display fraction should have a visible bar'); assert(mathLayout.numerator.bottom < mathLayout.line.top, 'the fraction numerator should appear above its bar'); assert(mathLayout.denominator.top > mathLayout.line.bottom, 'the denominator should appear below its bar'); assert(mathLayout.integral.height > 30, 'the integral should use its full large-operator glyph'); assert(mathLayout.integral.top > mathLayout.item.top && mathLayout.integral.bottom < mathLayout.item.bottom, 'the integral should fit inside the text box'); await evaluate(command, `(() => { const input = document.querySelector('[data-text-color]'); Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set.call(input, '#d946ef'); input.dispatchEvent(new Event('input', { bubbles: true })); })()`); await waitForEvaluation( command, `(() => { const item = document.querySelector('.text-item'); const math = item?.querySelector('mjx-c'); return getComputedStyle(item).color === 'rgb(217, 70, 239)' && getComputedStyle(math).color === 'rgb(217, 70, 239)'; })()`, 'custom text and MathJax color' ); await clickExport(command); await waitForEvaluation(command, `window.__graphTextPngs.length === 7`, 'custom-color PNG export'); const customColorPixels = await evaluate(command, `(async () => { const bitmap = await createImageBitmap(await window.__graphTextPngBlobs[6]); const canvas = document.createElement('canvas'); canvas.width = bitmap.width; canvas.height = bitmap.height; const context = canvas.getContext('2d'); context.drawImage(bitmap, 0, 0); const pixels = context.getImageData(0, 0, canvas.width, canvas.height).data; const surface = document.querySelector('.graph-area .surface').getBoundingClientRect(); const box = document.querySelector('.text-item mjx-container').getBoundingClientRect(); const left = Math.max(0, Math.floor((box.left - surface.left) * canvas.width / surface.width)); const right = Math.min(canvas.width, Math.ceil((box.right - surface.left) * canvas.width / surface.width)); const top = Math.max(0, Math.floor((box.top - surface.top) * canvas.height / surface.height)); const bottom = Math.min(canvas.height, Math.ceil((box.bottom - surface.top) * canvas.height / surface.height)); let count = 0; for (let y = top; y < bottom; y += 1) for (let x = left; x < right; x += 1) { const index = (y * canvas.width + x) * 4; if (pixels[index] > 160 && pixels[index + 1] < 130 && pixels[index + 2] > 160) count += 1; } return count; })()`); assert(customColorPixels > 10, `custom-colored LaTeX should appear in PNG export: ${customColorPixels}`); await setTextNumber(command, 'width', '80'); await setTextNumber(command, 'height', '40'); await setTextNumber(command, 'font-size', '32'); for (const [name, source] of [ ['f', '$f(x) = x^2$'], ['g', '$g(x) := 2(x - 3)^2 + 4$'], ['h', '$h(x) := -(x + 2)^2 + 1$'] ]) { await setTextSource(command, `$${name}(x)$`); await waitForEvaluation(command, `document.querySelector('.text-item mjx-math')?.getAttribute('data-latex') === ${JSON.stringify(`${name}(x)`)}`, `${name}(x) baseline`); const baselineIndex = await evaluate(command, `window.__graphTextPngs.length`); await clickExport(command); await waitForEvaluation(command, `window.__graphTextPngs.length === ${baselineIndex + 1}`, `${name}(x) baseline PNG`); await setTextSource(command, source); await waitForEvaluation( command, `(() => { const item = document.querySelector('.text-item'); const math = item?.querySelector('mjx-math'); const last = math?.querySelector('[data-latex]:last-child') ?? math?.lastElementChild; if (math?.getAttribute('data-latex') !== ${JSON.stringify(source.slice(1, -1))} || !last) return false; const box = item.getBoundingClientRect(); const end = last.getBoundingClientRect(); return end.right <= box.right - 3 && end.bottom <= box.bottom - 3 && end.left >= box.left + 3; })()`, `complete preview formula ${source}` ); await clickExport(command); await waitForEvaluation(command, `window.__graphTextPngs.length === ${baselineIndex + 2}`, `complete PNG formula ${source}`); const changedPixels = await evaluate(command, `(async () => { const baseline = await createImageBitmap(await window.__graphTextPngBlobs[${baselineIndex}]); const bitmap = await createImageBitmap(await window.__graphTextPngBlobs[${baselineIndex + 1}]); const canvas = document.createElement('canvas'); canvas.width = bitmap.width; canvas.height = bitmap.height; const context = canvas.getContext('2d'); context.drawImage(bitmap, 0, 0); const pixels = context.getImageData(0, 0, canvas.width, canvas.height).data; context.clearRect(0, 0, canvas.width, canvas.height); context.drawImage(baseline, 0, 0); const prior = context.getImageData(0, 0, canvas.width, canvas.height).data; const surface = document.querySelector('.graph-area .surface').getBoundingClientRect(); const item = document.querySelector('.text-item').getBoundingClientRect(); const left = Math.max(0, Math.floor((item.left - surface.left) * canvas.width / surface.width)); const right = Math.min(canvas.width, Math.ceil((item.right - surface.left) * canvas.width / surface.width)); const top = Math.max(0, Math.floor((item.top - surface.top) * canvas.height / surface.height)); const bottom = Math.min(canvas.height, Math.ceil((item.bottom - surface.top) * canvas.height / surface.height)); let count = 0; for (let y = top; y < bottom; y += 1) for (let x = left; x < right; x += 1) { const offset = (y * canvas.width + x) * 4; if (Math.abs(pixels[offset] - prior[offset]) + Math.abs(pixels[offset + 1] - prior[offset + 1]) + Math.abs(pixels[offset + 2] - prior[offset + 2]) > 45) count += 1; } return count; })()`); assert(changedPixels > 20, `PNG should differ from the function-only label for ${source}: ${changedPixels}`); } await evaluate(command, `document.querySelector('.color-auto input').click()`); await waitForEvaluation( command, `document.querySelector('.color-auto input').checked && document.querySelector('.text-item').style.color === ''`, 'graph theme text color restored' ); const legacySnapshot = { version: 1, name: 'Legacy text browser fixture', viewport: { x_min: -10, x_max: 10, y_min: -10, y_max: 10 }, equalAxisScale: true, appearance: { theme: 'light', showGrid: true, showMinorGrid: false, showXAxis: true, showYAxis: true, showAxisNumbers: true, xAxisLabel: '', yAxisLabel: '' }, expressions: [{ source: 'x', enabled: true, color: null, appearance: { lineWidth: 2.5, lineOpacity: 1, fillOpacity: 0.4, pointRadius: 5, pointOpacity: 1, pointStyle: 'circle' } }], texts: [{ id: 'legacy-browser-note', source: 'Legacy center', center: { x: 0, y: 0 }, widthPx: 240, heightPx: 96, fontSizePx: 16, align: 'left' }] }; const legacyPayload = gzipSync(Buffer.from(JSON.stringify(legacySnapshot))).toString('base64url'); const { targetId: legacyTargetId } = await browser.command('Target.createTarget', { url: 'about:blank' }); const { sessionId: legacySessionId } = await browser.command('Target.attachToTarget', { targetId: legacyTargetId, flatten: true }); const legacyCommand = (method, params = {}) => browser.command(method, params, legacySessionId); await legacyCommand('Page.enable'); await legacyCommand('Runtime.enable'); await legacyCommand('Page.navigate', { url: `${origin}/calc/2d#doc=${legacyPayload}` }); await waitForEvaluation( legacyCommand, `document.querySelector('.text-row-preview')?.textContent ?? ''`, 'legacy graph text snapshot load', 30_000, (value) => value === 'Legacy center' ); await evaluate(legacyCommand, `document.querySelector('.text-row').click()`); const legacyLoad = await evaluate( legacyCommand, `(() => { const text = document.querySelector('.text-item').getBoundingClientRect(); const surface = document.querySelector('.graph-area .surface').getBoundingClientRect(); return { x: document.querySelector('[data-text-field="position-x"]').value, y: document.querySelector('[data-text-field="position-y"]').value, centerAnchor: document.querySelector('[data-text-anchor="middle-center"]').getAttribute('aria-pressed') === 'true', centerOffsetX: Math.abs((text.left + text.width / 2) - (surface.left + surface.width / 2)), centerOffsetY: Math.abs((text.top + text.height / 2) - (surface.top + surface.height / 2)) }; })()` ); assert(legacyLoad.x === '0' && legacyLoad.y === '0', `legacy center coordinates should migrate: ${JSON.stringify(legacyLoad)}`); assert(legacyLoad.centerAnchor, 'legacy text should load with the center-middle anchor'); assert(legacyLoad.centerOffsetX < 1 && legacyLoad.centerOffsetY < 1, `legacy text should keep its centered screen position: ${JSON.stringify(legacyLoad)}`); console.log(JSON.stringify({ ...safety, ...exportResult })); } finally { browser?.socket.close(); chromium?.kill(); vite?.kill(); await rm(browserProfile, { recursive: true, force: true }); } async function clickExport(command) { await evaluate( command, `[...document.querySelectorAll('button')].find(button => button.textContent.trim() === 'Export PNG').click()` ); } async function setTextSource(command, source) { await evaluate( command, `(() => { const editor = document.querySelector('.text-editor textarea'); Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value').set.call(editor, ${JSON.stringify(source)}); editor.dispatchEvent(new Event('input', { bubbles: true })); })()` ); } async function setTextNumber(command, field, value) { await evaluate( command, `(() => { const input = document.querySelector('[data-text-field="${field}"]'); Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set.call(input, ${JSON.stringify(value)}); input.dispatchEvent(new Event('change', { bubbles: true })); })()` ); } async function clickAt(command, point) { await command('Input.dispatchMouseEvent', { type: 'mouseMoved', x: point.x, y: point.y, button: 'none', buttons: 0 }); await command('Input.dispatchMouseEvent', { type: 'mousePressed', x: point.x, y: point.y, button: 'left', buttons: 1, clickCount: 1 }); await command('Input.dispatchMouseEvent', { type: 'mouseReleased', x: point.x, y: point.y, button: 'left', buttons: 0 }); } async function dragPointer(command, start, end) { await command('Input.dispatchMouseEvent', { type: 'mouseMoved', x: start.x, y: start.y, button: 'none', buttons: 0 }); await command('Input.dispatchMouseEvent', { type: 'mousePressed', x: start.x, y: start.y, button: 'left', buttons: 1, clickCount: 1 }); await command('Input.dispatchMouseEvent', { type: 'mouseMoved', x: end.x, y: end.y, button: 'left', buttons: 1 }); await command('Input.dispatchMouseEvent', { type: 'mouseReleased', x: end.x, y: end.y, button: 'left', buttons: 0 }); } async function waitForEvaluation(command, expression, label, timeoutMs = 30_000, ready = Boolean) { const deadline = Date.now() + timeoutMs; let last; while (Date.now() < deadline) { last = await evaluate(command, expression); if (ready(last)) return last; await delay(100); } throw new Error(`Timed out waiting for ${label}; last browser value: ${JSON.stringify(last)}`); } async function findFreePort() { const server = createServer(); await new Promise((resolveListen, reject) => { server.once('error', reject); server.listen(0, host, resolveListen); }); const address = server.address(); if (!address || typeof address === 'string') throw new Error('Could not find an available port.'); const { port } = address; await new Promise((resolveClose, reject) => server.close((error) => (error ? reject(error) : resolveClose())) ); return port; } async function waitForServer(url, child, output) { const deadline = Date.now() + 30_000; while (Date.now() < deadline) { if (child.exitCode !== null) throw new Error(`Vite exited: ${output.slice(-10).join('')}`); try { if ((await fetch(url)).ok) return; } catch { // Vite is still starting. } await delay(100); } throw new Error(`Timed out waiting for ${url}: ${output.slice(-10).join('')}`); } function collectOutput(child, output) { for (const stream of [child.stdout, child.stderr]) { stream?.on('data', (chunk) => output.push(chunk.toString())); } }