map/tests/run-additional-generation-browser.mjs

268 lines
13 KiB
JavaScript
Raw Normal View History

2026-08-10 13:59:33 +09:00
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");
2026-08-11 21:51:07 +09:00
const relative = decodeURIComponent(url.pathname === "/" ? "/index.html" : url.pathname);
2026-08-10 13:59:33 +09:00
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;
}