import { createServer } from "node:http"; import { readFile } from "node:fs/promises"; import { extname, resolve, sep } from "node:path"; import { fileURLToPath } from "node:url"; const workspace = resolve(fileURLToPath(new URL("..", import.meta.url))); const profile = process.env.BROWSER_E2E_PROFILE === "release" ? "release" : "smoke"; const configuredRepetitions = Number(process.env.BROWSER_E2E_REPETITIONS); const repetitions = Number.isFinite(configuredRepetitions) && configuredRepetitions > 0 ? Math.max(1, Math.floor(configuredRepetitions)) : profile === "release" ? 20 : 1; const timeoutMs = profile === "release" ? 30 * 60_000 : 12 * 60_000; const allWorkloads = [ { name: "regeneration-rect", mode: "regeneration", shape: "rect", width: 60, height: 60 }, { name: "expansion-lasso", mode: "expansion", shape: "lasso", width: 96, height: 72 }, { name: "regeneration-cancel", mode: "regeneration", shape: "rect", width: 60, height: 60, kind: "cancel" }, { name: "regeneration-large", mode: "regeneration", shape: "rect", width: 259, height: 184 }, { name: "expansion-max-visible", mode: "expansion", shape: "lasso", width: 470, height: 333 }, ]; const requestedWorkloads = new Set(String(process.env.BROWSER_E2E_WORKLOADS || "") .split(",").map((value) => value.trim()).filter(Boolean)); const workloads = requestedWorkloads.size ? allWorkloads.filter((workload) => requestedWorkloads.has(workload.name)) : allWorkloads; if (!workloads.length) { throw new Error(`No browser E2E workloads matched BROWSER_E2E_WORKLOADS=${process.env.BROWSER_E2E_WORKLOADS || ""}.`); } const mime = { ".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".mjs": "text/javascript; charset=utf-8", ".css": "text/css; charset=utf-8", ".json": "application/json; charset=utf-8", }; function percentile95(values) { const sorted = [...values].sort((a, b) => a - b); return sorted[Math.max(0, Math.ceil(sorted.length * 0.95) - 1)] || 0; } async function browserHeap(page, label) { return page.evaluate((sampleLabel) => performance.memory ? { label: sampleLabel, usedJSHeapSize: performance.memory.usedJSHeapSize, totalJSHeapSize: performance.memory.totalJSHeapSize, jsHeapSizeLimit: performance.memory.jsHeapSizeLimit, } : null, label); } async function runAppWorkload(page, origin, workload) { await page.goto(`${origin}/index.html?additionalGenerationE2E=1`, { waitUntil: "domcontentloaded", timeout: 30_000 }); await page.waitForFunction(() => window.__additionalGenerationE2E?.snapshot().ready === true, null, { timeout: timeoutMs }); const ready = await page.evaluate(() => window.__additionalGenerationE2E.snapshot()); const x0 = workload.mode === "expansion" ? Math.max(0, Math.min(ready.worldWidth - workload.width, ready.originX + 238)) : Math.max(0, Math.min(ready.worldWidth - workload.width, ready.originX)); const y0 = Math.max(0, Math.min(ready.worldHeight - workload.height, ready.originY)); const rect = { x0, y0, x1: x0 + workload.width, y1: y0 + workload.height }; if (workload.shape === "lasso") { const insetX = Math.max(4, Math.floor(workload.width * 0.16)); const insetY = Math.max(4, Math.floor(workload.height * 0.16)); rect.kind = "lasso"; rect.polygon = [ { x: rect.x0 + insetX, y: rect.y0 }, { x: rect.x1 - 1, y: rect.y0 + insetY }, { x: rect.x1 - insetX, y: rect.y1 - 1 }, { x: rect.x0, y: rect.y1 - insetY }, ]; } const heap = [await browserHeap(page, "ready")].filter(Boolean); const before = await page.evaluate((config) => window.__additionalGenerationE2E.configure(config), { rect, patchMode: workload.mode, terrainType: "auto", variant: 0, seamDiagnostics: false, }); if (workload.kind === "cancel") { // Resolve the physical target before generation begins. A selector lookup // performed after dispatch can itself be delayed by a synchronous main- // thread slice and would therefore hide the very input-latency regression // this test is intended to detect. const cancelPoint = await page.evaluate(() => { const button = document.querySelector("#cancelPatchGeneration"); if (!button) throw new Error("Cancel button is missing."); button.scrollIntoView({ block: "center", inline: "center" }); const box = button.getBoundingClientRect(); if (box.width <= 0 || box.height <= 0) throw new Error("Cancel button is not visible."); return { x: box.left + box.width / 2, y: box.top + box.height / 2 }; }); await page.evaluate(() => { window.__additionalGenerationE2EPending = window.__additionalGenerationE2E.generate(); }); // Give the operation enough time to cross the first animation-frame yield // and enter Worker/mirror work, but do not probe the renderer with // Runtime.evaluate here: that probe can block behind a long task and cause // the test to click only after generation has already finished. await new Promise((resolveDelay) => setTimeout(resolveDelay, 100)); const cancelStartedAt = performance.now(); await page.mouse.click(cancelPoint.x, cancelPoint.y); const inputDispatchWallMs = performance.now() - cancelStartedAt; await page.evaluate(() => window.__additionalGenerationE2EPending); const inputToSettledWallMs = performance.now() - cancelStartedAt; const { settled, cancelTiming } = await page.evaluate(() => ({ settled: window.__additionalGenerationE2E.snapshot(), cancelTiming: window.__additionalGenerationE2ECancelTiming, })); const assertions = { trustedPhysicalInput: cancelTiming?.isTrusted === true, inputReachedHandlerWithinBudget: Number(cancelTiming?.inputToHandlerMs) < 500, handlerCancelledWithinBudget: Number(cancelTiming?.handlerToCancelledMs) < 500, inputCancelledWithinBudget: Number(cancelTiming?.inputToCancelledMs) < 500, inputDispatchReturnedWithinBudget: inputDispatchWallMs < 500, externalInputToSettledWithinBudget: inputToSettledWallMs < 500, generationStopped: !settled.patchBusy && cancelTiming?.patchBusyAfter === false, noPreviewPublished: !settled.pending, previousCanvasPreserved: settled.canvasDigest === before.canvasDigest, cancellationReported: /cancel/i.test(`${settled.patchStatus} ${settled.progressText}`), }; return { status: Object.values(assertions).every(Boolean) ? "pass" : "fail", workload: { ...workload, selection: rect }, timing: { patchWallMs: inputToSettledWallMs, inputDispatchWallMs, inputToSettledWallMs, patchBudgetMs: 500, patchMaxProgressGapMs: 0, ...cancelTiming, }, memory: null, assertions, before, cancelTiming, settled, }; } const patchStartedAt = performance.now(); const generated = await page.evaluate(() => window.__additionalGenerationE2E.generate()); const patchWallMs = performance.now() - patchStartedAt; const afterGenerateHeap = await browserHeap(page, "after-generate"); if (afterGenerateHeap) heap.push(afterGenerateHeap); const applyStartedAt = performance.now(); const applied = generated.pending ? await page.evaluate(() => window.__additionalGenerationE2E.apply()) : generated; const applyWallMs = performance.now() - applyStartedAt; const afterApplyHeap = await browserHeap(page, "after-apply"); if (afterApplyHeap) heap.push(afterApplyHeap); const boundedProgress = (generated.progressEvents || []).filter((event) => event.boundedWork); const progressTimes = (generated.progressEvents || []).map((event) => Number(event.at)).filter(Number.isFinite); let patchMaxProgressGapMs = 0; for (let index = 1; index < progressTimes.length; index++) { patchMaxProgressGapMs = Math.max(patchMaxProgressGapMs, progressTimes[index] - progressTimes[index - 1]); } const boundedProtocolByUnit = new Map(); let boundedProgressMonotonic = boundedProgress.length > 0; for (const event of boundedProgress) { const unitId = String(event.workUnitId || ""); const completed = Number(event.completed); const total = Number(event.total); if (!unitId || !Number.isFinite(completed) || !Number.isFinite(total) || completed < 0 || completed > total) { boundedProgressMonotonic = false; break; } const previous = boundedProtocolByUnit.get(unitId); if (previous && (previous.total !== total || completed < previous.completed)) { boundedProgressMonotonic = false; break; } boundedProtocolByUnit.set(unitId, { completed, total }); } const assertions = { acceptedPreviewPublished: generated.pending && generated.publicationStatus === "published" && generated.searchStatus === "succeeded", canvasChangedAtPublish: !!generated.canvasDigest && generated.canvasDigest !== before.canvasDigest, completeStatsPublished: !!generated.statsText && !!generated.diagnosticsText, applyCommittedOneRevision: generated.pending && applied.committedRevision === before.committedRevision + 1, applyClearedPending: generated.pending && !applied.pending, applyMirrorAcked: generated.pending && applied.workerMirrorRevision === applied.committedRevision, boundedProgressValid: boundedProgressMonotonic, patchBudgetMet: patchWallMs < 60_000, }; return { status: Object.values(assertions).every(Boolean) ? "pass" : "fail", workload: { ...workload, selection: rect }, timing: { patchWallMs, applyWallMs, patchBudgetMs: 60_000, patchMaxProgressGapMs }, memory: heap.length ? { snapshots: heap, peakUsedJSHeapSize: Math.max(...heap.map((item) => item.usedJSHeapSize)) } : null, assertions, before, generated, applied, }; } let launchBrowser; let browserBackend = "playwright"; try { const playwright = await import("playwright"); launchBrowser = () => playwright.chromium.launch({ headless: true }); } catch (playwrightError) { try { const { launchChromiumCdp } = await import("./chromium-cdp-page.mjs"); launchBrowser = () => launchChromiumCdp(); browserBackend = "chromium-cdp"; console.error(`[browser-e2e] Playwright unavailable; using direct Chromium CDP backend (${playwrightError?.message || playwrightError}).`); } catch (cdpError) { console.error("Browser E2E infrastructure error: neither Playwright nor direct Chromium CDP is available."); console.error(cdpError?.message || String(cdpError)); process.exitCode = 1; } } if (launchBrowser) { const server = createServer(async (request, response) => { try { const url = new URL(request.url || "/", "http://127.0.0.1"); const relative = decodeURIComponent(url.pathname === "/" ? "/index.html" : url.pathname); const file = resolve(workspace, `.${relative}`); if (file !== workspace && !file.startsWith(`${workspace}${sep}`)) throw new Error("Path outside workspace"); const bytes = await readFile(file); response.writeHead(200, { "content-type": mime[extname(file)] || "application/octet-stream", "cache-control": "no-store" }); response.end(bytes); } catch (error) { response.writeHead(404, { "content-type": "text/plain; charset=utf-8" }); response.end(error?.message || "Not found"); } }); await new Promise((resolveListen) => server.listen(0, "127.0.0.1", resolveListen)); const address = server.address(); // System Chromium policies in some CI images block loopback URL literals. // The direct-CDP launcher maps this reserved test host back to 127.0.0.1, // preserving a real HTTP origin for modules/workers without bypassing app code. const originHost = browserBackend === "chromium-cdp" ? "jmg.test" : "127.0.0.1"; const origin = `http://${originHost}:${address.port}`; const browser = await launchBrowser(); const reports = []; try { for (const workload of workloads) { for (let iteration = 0; iteration < repetitions; iteration++) { const page = await browser.newPage(); page.setDefaultTimeout(timeoutMs); const startedAt = performance.now(); const report = await runAppWorkload(page, origin, workload); reports.push({ workloadName: workload.name, iteration, runnerWallMs: performance.now() - startedAt, ...report }); await page.close(); console.error(`[browser-e2e:${browserBackend}] ${workload.name} ${iteration + 1}/${repetitions}: ${report.status} ${Math.round(report.timing?.patchWallMs || 0)}ms`); } } } finally { await browser.close(); await new Promise((resolveClose) => server.close(resolveClose)); } const strata = Object.fromEntries(workloads.map((workload) => { const rows = reports.filter((report) => report.workloadName === workload.name); const budgetMs = workload.kind === "cancel" ? 500 : 60_000; return [workload.name, { samples: rows.length, pass: rows.length > 0 && rows.every((row) => row.status === "pass"), patchBudgetMs: budgetMs, patchP95Ms: rows.length ? percentile95(rows.map((row) => Number(row.timing?.patchWallMs || Infinity))) : Infinity, maxProgressGapMs: rows.length ? Math.max(...rows.map((row) => Number(row.timing?.patchMaxProgressGapMs || 0))) : Infinity, peakHeapBytes: rows.length ? Math.max(...rows.map((row) => Number(row.memory?.peakUsedJSHeapSize || 0))) : 0, }]; })); const expectedSamples = repetitions; const releasePass = Object.values(strata).every( (row) => row.samples === expectedSamples && row.pass && row.patchP95Ms < row.patchBudgetMs, ); const summary = { profile, repetitions, browserBackend, releasePass, strata, reports }; console.log(JSON.stringify(summary, null, 2)); if (!releasePass) process.exitCode = 1; }