268 lines
13 KiB
JavaScript
268 lines
13 KiB
JavaScript
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;
|
|
}
|