1
This commit is contained in:
parent
1a3ba56d9a
commit
810ad6f5cb
33 changed files with 10710 additions and 1087 deletions
268
tests/run-additional-generation-browser.mjs
Normal file
268
tests/run-additional-generation-browser.mjs
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
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 === "/" ? "/tests/additional-generation-e2e.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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue