Files
nexu-io__open-design/e2e/scripts/artifact-render-parity.ts
lefarcen 1c1ecae470 fix(web): preserve HTML preview state across edit handoffs (#7592)
* fix(web): preserve HTML preview state across edit handoffs

* test(web): align reload race coverage with URL handoff

* test(e2e): target canonical active preview frame

* fix(preview): complete URL edit handoff parity

* fix(e2e): classify preview round-trip parity

* fix(preview): close edit handoff readiness gaps

* fix(preview): close remaining handoff regressions

* refactor(preview): centralize runtime state contract

* test(preview): isolate parity audit runtime
2026-08-31 01:32:49 +00:00

779 lines
28 KiB
TypeScript

import { createHash, randomUUID } from 'node:crypto';
import { mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { chromium, type BrowserContext, type Frame, type Locator, type Page } from '@playwright/test';
import { settledActiveArtifactPreview as settledActivePreview } from '../lib/playwright/artifact-preview.ts';
import {
classifyPixelParity,
combinePixelParityClassifications,
comparePngBuffers,
type ParityClassification,
type PixelComparison,
} from '../lib/playwright/artifact-render-parity.ts';
import { createToolsDevSuite, e2eWorkspaceRoot } from '../lib/tools-dev/runtime.ts';
import type { ToolsDevSuite } from '../lib/tools-dev/types.ts';
type Options = {
corpusDir: string;
outputDir: string;
limit: number;
timeoutMs: number;
caseTimeoutMs: number;
settleMs: number;
maxBytes: number;
maxPerceptualDiffRatio: number;
maxSelfDriftRatio: number;
headed: boolean;
};
type RunOptions = Options & { webUrl: string };
type RuntimeSnapshot = {
readyState: string;
compatMode: string;
hrefKind: 'http' | 'blob' | 'about' | 'other';
titleHash: string;
textHash: string;
domHash: string;
textLength: number;
domLength: number;
elementCount: number;
canvasCount: number;
videoCount: number;
windowScrollX: number;
windowScrollY: number;
scrollStateHash: string;
scrollableCount: number;
};
type RawRuntimeSnapshot = Omit<RuntimeSnapshot, 'titleHash' | 'textHash' | 'domHash' | 'scrollStateHash'> & {
title: string;
text: string;
dom: string;
scrollState: string;
};
type StableCapture = {
first: Buffer;
second: Buffer;
selfDrift: PixelComparison;
snapshot: RuntimeSnapshot;
};
type ComparisonSummary = Omit<PixelComparison, 'diffPng'>;
type CaseResult = {
sampleId: string;
bytes: number;
status: ParityClassification | 'failed' | 'skipped-not-url-load' | 'skipped-too-large';
initialMode?: string | null;
editMode?: string | null;
editBootstrapHasDoctype?: boolean;
editBootstrapLength?: number;
scrolled?: boolean;
entryStatus?: ParityClassification;
roundTripStatus?: ParityClassification;
entry?: ComparisonSummary;
roundTrip?: ComparisonSummary;
urlSelfDriftRatio?: number;
srcDocSelfDriftRatio?: number;
roundTripSelfDriftRatio?: number;
urlState?: RuntimeSnapshot;
srcDocState?: RuntimeSnapshot;
roundTripState?: RuntimeSnapshot;
consoleErrorHashes: string[];
failedRequestHashes: string[];
httpErrorHashes: string[];
evidence?: string[];
error?: string;
};
type BrowserSignals = {
consoleErrorHashes: Set<string>;
failedRequestHashes: Set<string>;
httpErrorHashes: Set<string>;
};
const isDirectRun = process.argv[1] != null
&& fileURLToPath(import.meta.url) === path.resolve(process.argv[1]);
if (isDirectRun) {
await main(process.argv.slice(2));
}
async function main(argv: string[]): Promise<void> {
const options = parseOptions(argv);
const files = (await listHtmlFiles(options.corpusDir)).slice(0, options.limit);
if (files.length === 0) {
throw new Error(`No .html files found under ${options.corpusDir}`);
}
const toolsDev = await createParityToolsDevRuntime();
let runError: unknown = null;
try {
await toolsDev.startWeb({
AMR_HOME: path.join(toolsDev.root, 'scratch', 'amr-home'),
});
const runOptions: RunOptions = { ...options, webUrl: toolsDev.url.web() };
await mkdir(options.outputDir, { recursive: true });
const results: CaseResult[] = [];
const startedAt = Date.now();
console.log(`Comparing ${files.length} HTML artifacts. Evidence stays local at ${options.outputDir}`);
for (const [index, filePath] of files.entries()) {
const source = await readFile(filePath);
const sampleId = sha256(source).slice(0, 20);
process.stdout.write(`[${index + 1}/${files.length}] ${sampleId} (${source.byteLength} bytes) `);
if (source.byteLength > options.maxBytes) {
const skipped: CaseResult = emptyResult(sampleId, source.byteLength, 'skipped-too-large');
results.push(skipped);
console.log('skipped: too large');
continue;
}
const result = await runIsolatedCase({ options: runOptions, sampleId, source: source.toString('utf8') });
results.push(result);
console.log(result.status);
await writeReport(options, results, startedAt);
}
await writeReport(options, results, startedAt);
printSummary(results, options.outputDir);
} catch (error) {
runError = error;
throw error;
} finally {
let stopError: unknown = null;
try {
await toolsDev.stopWeb();
} catch (error) {
stopError = error;
}
await rm(toolsDev.root, { force: true, recursive: true });
if (runError == null && stopError != null) throw stopError;
}
}
async function createParityToolsDevRuntime(): Promise<ToolsDevSuite> {
const namespace = `artifact-parity-${process.pid}-${randomUUID().slice(0, 8)}`;
const root = path.join(e2eWorkspaceRoot(), '.tmp', 'e2e', namespace);
const scratchDir = path.join(root, 'scratch');
await mkdir(scratchDir, { recursive: true });
return createToolsDevSuite({
codexHomeDir: path.join(scratchDir, 'codex-home'),
dataDir: path.join(scratchDir, 'data'),
namespace,
ownerPid: process.pid,
root,
toolsDevRoot: path.join(scratchDir, 'tools-dev'),
});
}
async function runIsolatedCase(input: {
options: RunOptions;
sampleId: string;
source: string;
}): Promise<CaseResult> {
const { options, sampleId, source } = input;
const projectId = `parity-${sampleId.slice(0, 12)}-${randomUUID().slice(0, 8)}`;
const browser = await chromium.launch({ headless: !options.headed });
const context = await browser.newContext({
baseURL: options.webUrl,
viewport: { width: 1440, height: 1000 },
deviceScaleFactor: 1,
serviceWorkers: 'block',
});
let timeout: NodeJS.Timeout | undefined;
try {
return await Promise.race([
runCase({ context, options, projectId, sampleId, source }),
new Promise<CaseResult>((_resolve, reject) => {
timeout = setTimeout(
() => reject(new Error(`Case exceeded the ${options.caseTimeoutMs} ms hard timeout`)),
options.caseTimeoutMs,
);
}),
]);
} catch (error) {
return {
...emptyResult(sampleId, Buffer.byteLength(source), 'failed'),
error: formatError(error),
};
} finally {
if (timeout != null) clearTimeout(timeout);
await browser.close({ reason: 'artifact parity case complete' }).catch(() => {});
await fetch(new URL(`/api/projects/${encodeURIComponent(projectId)}`, options.webUrl), {
method: 'DELETE',
signal: AbortSignal.timeout(Math.min(options.timeoutMs, 5_000)),
}).catch(() => {});
}
}
async function runCase(input: {
context: BrowserContext;
options: RunOptions;
projectId: string;
sampleId: string;
source: string;
}): Promise<CaseResult> {
const { context, options, projectId, sampleId, source } = input;
const signals: BrowserSignals = {
consoleErrorHashes: new Set(),
failedRequestHashes: new Set(),
httpErrorHashes: new Set(),
};
const page = await context.newPage();
collectBrowserSignals(page, signals);
try {
await createProject(page, projectId, sampleId, source, options.timeoutMs);
await page.goto(`/projects/${encodeURIComponent(projectId)}/files/index.html`, {
waitUntil: 'domcontentloaded',
timeout: options.timeoutMs,
});
await page.getByTestId('file-workspace').waitFor({ state: 'visible', timeout: options.timeoutMs });
await page.mouse.move(0, 0);
const initialFrame = await settledActivePreview(page, options.timeoutMs);
const initialMode = await initialFrame.locator.getAttribute('data-od-render-mode');
if (initialMode !== 'url-load') {
return withSignals({
...emptyResult(sampleId, Buffer.byteLength(source), 'skipped-not-url-load'),
initialMode,
}, signals);
}
const scrolled = await scrollPrimarySurfaceSafely(page, options.timeoutMs);
const urlCapture = await captureStable(page, 'url-load', options.timeoutMs, options.settleMs);
const editToggle = page.getByTestId('manual-edit-mode-toggle').filter({ visible: true }).last();
await editToggle.click({ timeout: options.timeoutMs });
await waitForPressed(editToggle, 'true', options.timeoutMs);
await page.mouse.move(0, 0);
const editFrame = await settledActivePreview(page, options.timeoutMs);
const editMode = await editFrame.locator.getAttribute('data-od-render-mode');
if (editMode !== 'srcdoc') {
throw new Error(`Edit did not activate srcDoc transport (active mode: ${String(editMode)})`);
}
const editBootstrap = await editFrame.locator.getAttribute('srcdoc') ?? '';
const srcDocCapture = await captureStable(page, 'srcdoc', options.timeoutMs, options.settleMs);
const entry = comparePngBuffers(urlCapture.second, srcDocCapture.second);
const entryStatus = classifyPixelParity({
comparison: entry,
actualSelfDriftRatio: urlCapture.selfDrift.perceptualDiffRatio,
expectedSelfDriftRatio: srcDocCapture.selfDrift.perceptualDiffRatio,
maxPerceptualDiffRatio: options.maxPerceptualDiffRatio,
maxSelfDriftRatio: options.maxSelfDriftRatio,
});
await editToggle.click({ timeout: options.timeoutMs });
await waitForPressed(editToggle, 'false', options.timeoutMs);
await page.mouse.move(0, 0);
const roundTripFrame = await settledActivePreview(page, options.timeoutMs);
await waitForRenderMode(roundTripFrame.locator, 'url-load', options.timeoutMs);
const roundTripCapture = await captureStable(page, 'url-load', options.timeoutMs, options.settleMs);
const roundTrip = comparePngBuffers(urlCapture.second, roundTripCapture.second);
const roundTripStatus = classifyPixelParity({
comparison: roundTrip,
actualSelfDriftRatio: urlCapture.selfDrift.perceptualDiffRatio,
expectedSelfDriftRatio: roundTripCapture.selfDrift.perceptualDiffRatio,
maxPerceptualDiffRatio: options.maxPerceptualDiffRatio,
maxSelfDriftRatio: options.maxSelfDriftRatio,
});
const status = combinePixelParityClassifications(entryStatus, roundTripStatus);
const evidence = status === 'exact' && roundTrip.exactDiffPixels === 0
? []
: await saveEvidence(options.outputDir, sampleId, {
url: urlCapture.second,
srcdoc: srcDocCapture.second,
entryDiff: entry.diffPng,
roundTrip: roundTripCapture.second,
roundTripDiff: roundTrip.diffPng,
});
return withSignals({
sampleId,
bytes: Buffer.byteLength(source),
status,
initialMode,
editMode,
editBootstrapHasDoctype: /^\s*<!doctype\b/i.test(editBootstrap),
editBootstrapLength: editBootstrap.length,
scrolled,
entryStatus,
roundTripStatus,
entry: summarizeComparison(entry),
roundTrip: summarizeComparison(roundTrip),
urlSelfDriftRatio: urlCapture.selfDrift.perceptualDiffRatio,
srcDocSelfDriftRatio: srcDocCapture.selfDrift.perceptualDiffRatio,
roundTripSelfDriftRatio: roundTripCapture.selfDrift.perceptualDiffRatio,
urlState: urlCapture.snapshot,
srcDocState: srcDocCapture.snapshot,
roundTripState: roundTripCapture.snapshot,
consoleErrorHashes: [],
failedRequestHashes: [],
httpErrorHashes: [],
...(evidence.length === 0 ? {} : { evidence }),
}, signals);
} catch (error) {
return withSignals({
...emptyResult(sampleId, Buffer.byteLength(source), 'failed'),
error: formatError(error),
}, signals);
}
}
async function createProject(
page: Page,
projectId: string,
sampleId: string,
source: string,
timeoutMs: number,
): Promise<void> {
const created = await page.request.post('/api/projects', {
data: {
id: projectId,
name: `Render parity ${sampleId}`,
skillId: null,
designSystemId: null,
pendingPrompt: null,
metadata: { kind: 'prototype' },
},
timeout: timeoutMs,
});
if (!created.ok()) throw new Error(`Project create failed (${created.status()}): ${await created.text()}`);
const uploaded = await page.request.post(`/api/projects/${encodeURIComponent(projectId)}/files`, {
data: {
name: 'index.html',
content: source,
artifactManifest: {
version: 1,
kind: 'html',
title: sampleId,
entry: 'index.html',
renderer: 'html',
exports: ['html'],
},
},
timeout: timeoutMs,
});
if (!uploaded.ok()) throw new Error(`HTML upload failed (${uploaded.status()}): ${await uploaded.text()}`);
}
async function captureStable(
page: Page,
expectedMode: string,
timeoutMs: number,
settleMs: number,
): Promise<StableCapture> {
const startedAt = Date.now();
let lastError: unknown;
while (Date.now() - startedAt < timeoutMs) {
try {
const firstActive = await settledActivePreview(page, remainingTimeout(startedAt, timeoutMs));
await waitForRenderMode(firstActive.locator, expectedMode, remainingTimeout(startedAt, timeoutMs));
const first = await firstActive.locator.screenshot({
animations: 'disabled',
type: 'png',
timeout: remainingTimeout(startedAt, timeoutMs),
});
await new Promise((resolve) => setTimeout(resolve, settleMs));
const secondActive = await settledActivePreview(page, remainingTimeout(startedAt, timeoutMs));
await waitForRenderMode(secondActive.locator, expectedMode, remainingTimeout(startedAt, timeoutMs));
const second = await secondActive.locator.screenshot({
animations: 'disabled',
type: 'png',
timeout: remainingTimeout(startedAt, timeoutMs),
});
return {
first,
second,
selfDrift: comparePngBuffers(first, second),
snapshot: await readRuntimeSnapshot(secondActive.frame),
};
} catch (error) {
lastError = error;
if (!/detached|not attached|not visible|browsing context|Execution context was destroyed/i.test(formatError(error))) {
throw error;
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
}
throw new Error(`Could not capture a stable ${expectedMode} iframe: ${formatError(lastError)}`);
}
async function readRuntimeSnapshot(frame: Frame): Promise<RuntimeSnapshot> {
const raw = await frame.evaluate(() => {
const all = [...document.querySelectorAll<HTMLElement>('*')];
const scrollingElement = document.scrollingElement as HTMLElement | null;
const scrollEntries = all
.map((element, index) => ({
index,
left: Math.round(element.scrollLeft),
top: Math.round(element.scrollTop),
maxLeft: Math.max(0, element.scrollWidth - element.clientWidth),
maxTop: Math.max(0, element.scrollHeight - element.clientHeight),
}))
.filter((entry) => entry.maxLeft > 1 || entry.maxTop > 1 || entry.left !== 0 || entry.top !== 0);
if (scrollingElement != null) {
scrollEntries.unshift({
index: -1,
left: Math.round(window.scrollX),
top: Math.round(window.scrollY),
maxLeft: Math.max(0, scrollingElement.scrollWidth - window.innerWidth),
maxTop: Math.max(0, scrollingElement.scrollHeight - window.innerHeight),
});
}
const href = location.href;
const hrefKind = href.startsWith('http')
? 'http'
: href.startsWith('blob:')
? 'blob'
: href.startsWith('about:')
? 'about'
: 'other';
const text = document.body?.innerText ?? '';
const dom = document.documentElement?.outerHTML ?? '';
return {
readyState: document.readyState,
compatMode: document.compatMode,
hrefKind,
title: document.title,
text,
dom,
textLength: text.length,
domLength: dom.length,
elementCount: all.length,
canvasCount: document.querySelectorAll('canvas').length,
videoCount: document.querySelectorAll('video').length,
windowScrollX: Math.round(window.scrollX),
windowScrollY: Math.round(window.scrollY),
scrollState: JSON.stringify(scrollEntries),
scrollableCount: scrollEntries.length,
} satisfies RawRuntimeSnapshot;
});
return {
readyState: raw.readyState,
compatMode: raw.compatMode,
hrefKind: raw.hrefKind,
titleHash: sha256(raw.title).slice(0, 16),
textHash: sha256(raw.text).slice(0, 16),
domHash: sha256(raw.dom).slice(0, 16),
textLength: raw.textLength,
domLength: raw.domLength,
elementCount: raw.elementCount,
canvasCount: raw.canvasCount,
videoCount: raw.videoCount,
windowScrollX: raw.windowScrollX,
windowScrollY: raw.windowScrollY,
scrollStateHash: sha256(raw.scrollState).slice(0, 16),
scrollableCount: raw.scrollableCount,
};
}
async function scrollPrimarySurface(frame: Frame): Promise<boolean> {
return frame.evaluate(() => {
const root = document.scrollingElement as HTMLElement | null;
const elements = [...document.querySelectorAll<HTMLElement>('*')];
const candidates = [
...(root == null ? [] : [{ element: root, root: true, ordinal: -1 }]),
...elements.map((element, ordinal) => ({ element, root: false, ordinal })),
].map((candidate) => {
const viewportHeight = candidate.root ? window.innerHeight : candidate.element.clientHeight;
const viewportWidth = candidate.root ? window.innerWidth : candidate.element.clientWidth;
const maxTop = Math.max(0, candidate.element.scrollHeight - viewportHeight);
const maxLeft = Math.max(0, candidate.element.scrollWidth - viewportWidth);
return { ...candidate, maxTop, maxLeft, score: maxTop * viewportWidth + maxLeft * viewportHeight };
}).filter((candidate) => candidate.maxTop > 8 || candidate.maxLeft > 8)
.sort((a, b) => b.score - a.score || a.ordinal - b.ordinal);
const target = candidates[0];
if (target == null) return false;
const top = Math.round(target.maxTop * 0.57);
const left = Math.round(target.maxLeft * 0.43);
if (target.root) {
window.scrollTo({ top, left, behavior: 'instant' });
window.dispatchEvent(new Event('scroll'));
return Math.abs(window.scrollY - top) <= 2 && Math.abs(window.scrollX - left) <= 2;
}
target.element.scrollTo({ top, left, behavior: 'instant' });
target.element.dispatchEvent(new Event('scroll', { bubbles: false }));
return Math.abs(target.element.scrollTop - top) <= 2 && Math.abs(target.element.scrollLeft - left) <= 2;
});
}
async function scrollPrimarySurfaceSafely(page: Page, timeoutMs: number): Promise<boolean> {
const startedAt = Date.now();
let lastError: unknown;
while (Date.now() - startedAt < timeoutMs) {
try {
const active = await settledActivePreview(page, remainingTimeout(startedAt, timeoutMs));
return await scrollPrimarySurface(active.frame);
} catch (error) {
lastError = error;
if (!/detached|not attached|browsing context|Execution context was destroyed/i.test(formatError(error))) {
throw error;
}
}
}
throw new Error(`Could not scroll a stable active iframe: ${formatError(lastError)}`);
}
async function waitForPressed(locator: Locator, expected: string, timeoutMs: number): Promise<void> {
await locator.page().waitForFunction(
({ selector, expectedValue }) => document.querySelector(selector)?.getAttribute('aria-pressed') === expectedValue,
{ selector: '[data-testid="manual-edit-mode-toggle"]', expectedValue: expected },
{ timeout: timeoutMs },
);
}
async function waitForRenderMode(locator: Locator, expected: string, timeoutMs: number): Promise<void> {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
if (await locator.getAttribute('data-od-render-mode') === expected) return;
await new Promise((resolve) => setTimeout(resolve, 50));
}
throw new Error(`Timed out waiting for active render mode ${expected}`);
}
function collectBrowserSignals(page: Page, signals: BrowserSignals): void {
page.on('console', (message) => {
if (message.type() !== 'error') return;
signals.consoleErrorHashes.add(sha256(message.text()).slice(0, 16));
});
page.on('requestfailed', (request) => {
signals.failedRequestHashes.add(sha256(`${request.method()} ${request.url()}`).slice(0, 16));
});
page.on('response', (response) => {
if (response.status() < 400) return;
signals.httpErrorHashes.add(sha256(`${response.status()} ${response.url()}`).slice(0, 16));
});
}
async function saveEvidence(
outputDir: string,
sampleId: string,
images: Record<string, Buffer>,
): Promise<string[]> {
const evidenceDir = path.join(outputDir, 'evidence', sampleId);
await mkdir(evidenceDir, { recursive: true });
const saved: string[] = [];
for (const [name, bytes] of Object.entries(images)) {
const relative = path.join('evidence', sampleId, `${name}.png`);
await writeFile(path.join(outputDir, relative), bytes);
saved.push(relative);
}
return saved;
}
function summarizeComparison(comparison: PixelComparison): ComparisonSummary {
const { diffPng: _diffPng, ...summary } = comparison;
return summary;
}
function withSignals(result: CaseResult, signals: BrowserSignals): CaseResult {
return {
...result,
consoleErrorHashes: [...signals.consoleErrorHashes].sort(),
failedRequestHashes: [...signals.failedRequestHashes].sort(),
httpErrorHashes: [...signals.httpErrorHashes].sort(),
};
}
function emptyResult(sampleId: string, bytes: number, status: CaseResult['status']): CaseResult {
return {
sampleId,
bytes,
status,
consoleErrorHashes: [],
failedRequestHashes: [],
httpErrorHashes: [],
};
}
async function writeReport(options: Options, results: CaseResult[], startedAt: number): Promise<void> {
const counts = countStatuses(results);
const report = {
schemaVersion: 1,
generatedAt: new Date().toISOString(),
durationMs: Date.now() - startedAt,
corpus: {
htmlFilesProcessed: results.length,
sourcePathsIncluded: false,
sourceContentIncluded: false,
},
browser: { name: 'chromium', viewport: { width: 1440, height: 1000 }, deviceScaleFactor: 1 },
thresholds: {
perceptualPixelThreshold: 0.1,
maxPerceptualDiffRatio: options.maxPerceptualDiffRatio,
maxSelfDriftRatio: options.maxSelfDriftRatio,
settleMs: options.settleMs,
caseTimeoutMs: options.caseTimeoutMs,
},
counts,
results,
};
await writeFile(path.join(options.outputDir, 'report.json'), `${JSON.stringify(report, null, 2)}\n`);
await writeFile(path.join(options.outputDir, 'summary.md'), renderMarkdownSummary(results, counts));
}
function renderMarkdownSummary(results: CaseResult[], counts: Record<string, number>): string {
const lines = [
'# Artifact URL/srcDoc render parity',
'',
'Local-only black-box comparison. Sample IDs are SHA-256 prefixes; source paths and HTML are omitted.',
'',
'| Status | Count |',
'| --- | ---: |',
...Object.entries(counts).sort(([a], [b]) => a.localeCompare(b)).map(([status, count]) => `| ${status} | ${count} |`),
'',
'| Sample | Status | Bytes | Entry perceptual diff | Round-trip perceptual diff |',
'| --- | --- | ---: | ---: | ---: |',
...results.map((result) => [
result.sampleId,
result.status,
String(result.bytes),
formatRatio(result.entry?.perceptualDiffRatio),
formatRatio(result.roundTrip?.perceptualDiffRatio),
].join(' | ')).map((row) => `| ${row} |`),
'',
];
return `${lines.join('\n')}\n`;
}
function countStatuses(results: CaseResult[]): Record<string, number> {
return results.reduce<Record<string, number>>((counts, result) => {
counts[result.status] = (counts[result.status] ?? 0) + 1;
return counts;
}, {});
}
function printSummary(results: CaseResult[], outputDir: string): void {
const counts = countStatuses(results);
console.log(`Done: ${Object.entries(counts).map(([status, count]) => `${status}=${count}`).join(', ')}`);
console.log(`Report: ${path.join(outputDir, 'summary.md')}`);
}
async function listHtmlFiles(root: string): Promise<string[]> {
const found: string[] = [];
async function visit(directory: string): Promise<void> {
const entries = await readdir(directory, { withFileTypes: true });
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
const absolute = path.join(directory, entry.name);
if (entry.isDirectory()) await visit(absolute);
else if ((entry.isFile() || entry.isSymbolicLink()) && entry.name.toLowerCase().endsWith('.html')) {
found.push(absolute);
}
}
}
await visit(path.resolve(root));
return found;
}
function parseOptions(argv: string[]): Options {
const values = new Map<string, string | true>();
for (let index = 0; index < argv.length; index += 1) {
const token = argv[index];
if (token === '--') continue;
if (token == null || !token.startsWith('--')) throw new Error(usage());
const key = token.slice(2);
if (key === 'headed' || key === 'help') {
values.set(key, true);
continue;
}
const value = argv[index + 1];
if (value == null || value.startsWith('--')) throw new Error(`Missing value for --${key}\n\n${usage()}`);
values.set(key, value);
index += 1;
}
if (values.has('help')) {
console.log(usage());
process.exit(0);
}
if (values.has('web-url') || values.has('keep-projects')) {
throw new Error('The parity auditor owns an isolated tools-dev runtime; --web-url and --keep-projects are no longer supported.');
}
const corpusDir = path.resolve(required(values, 'corpus-dir'));
const outputDir = path.resolve(
optional(values, 'output-dir')
?? path.join(tmpdir(), `od-artifact-render-parity-${Date.now()}`),
);
return {
corpusDir,
outputDir,
limit: positiveInteger(optional(values, 'limit') ?? '50', '--limit'),
timeoutMs: positiveInteger(optional(values, 'timeout-ms') ?? '30000', '--timeout-ms'),
caseTimeoutMs: positiveInteger(optional(values, 'case-timeout-ms') ?? '60000', '--case-timeout-ms'),
settleMs: nonNegativeInteger(optional(values, 'settle-ms') ?? '500', '--settle-ms'),
maxBytes: positiveInteger(optional(values, 'max-bytes') ?? '5000000', '--max-bytes'),
maxPerceptualDiffRatio: ratioOption(optional(values, 'max-diff-ratio') ?? '0.001', '--max-diff-ratio'),
maxSelfDriftRatio: ratioOption(optional(values, 'max-self-drift-ratio') ?? '0.001', '--max-self-drift-ratio'),
headed: values.get('headed') === true,
};
}
function required(values: Map<string, string | true>, key: string): string {
const value = optional(values, key);
if (value == null) throw new Error(`Missing --${key}\n\n${usage()}`);
return value;
}
function optional(values: Map<string, string | true>, key: string): string | undefined {
const value = values.get(key);
return typeof value === 'string' ? value : undefined;
}
function positiveInteger(value: string, option: string): number {
const parsed = Number(value);
if (!Number.isSafeInteger(parsed) || parsed <= 0) throw new Error(`${option} must be a positive integer`);
return parsed;
}
function nonNegativeInteger(value: string, option: string): number {
const parsed = Number(value);
if (!Number.isSafeInteger(parsed) || parsed < 0) throw new Error(`${option} must be a non-negative integer`);
return parsed;
}
function ratioOption(value: string, option: string): number {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed < 0 || parsed > 1) throw new Error(`${option} must be between 0 and 1`);
return parsed;
}
function sha256(value: string | Buffer): string {
return createHash('sha256').update(value).digest('hex');
}
function formatRatio(value: number | undefined): string {
return value == null ? '—' : `${(value * 100).toFixed(4)}%`;
}
function formatError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function remainingTimeout(startedAt: number, timeoutMs: number): number {
return Math.max(250, timeoutMs - (Date.now() - startedAt));
}
function usage(): string {
return `Usage:
pnpm --filter @open-design/e2e exec tsx scripts/artifact-render-parity.ts \\
--corpus-dir /path/to/local/html-corpus \\
[--output-dir /private/tmp/render-parity] [--limit 50] [--headed]
The script starts a namespace- and data-root-isolated tools-dev runtime, creates
temporary managed projects through its product API, captures the
active URL-load iframe, enters Manual Edit to capture the real srcDoc transport,
then exits Edit and captures URL-load again. The runtime and its projects are always
stopped and removed when the audit completes. Every artifact runs in an isolated
browser process with a 60-second hard timeout. Reports omit source paths and HTML content.`;
}