Four browser-capability specs never reached the code they were named for. The `PRESIGNED_TRANSFER_V1` envelope gained a top-level `protocol` field, and the fixtures kept answering without it, so every capability was refused before any object request was made: the download and part-upload success paths were asserting against an empty transcript rather than exercising a real GET or PUT. The fixtures now speak the protocol they claim to, and the part-deletion expectation carries the physical effect the adapter reports. A refused capability document also answered `recovery: NONE`, telling the caller there was nothing to be done. The design record fixes this class of refusal as re-issuable and the vault already answers `REISSUE_CAPABILITY` for it, so the HTTP decoder disagreed with both. It now agrees. Lab performance produced no evidence at all. Playwright matches accessible names by substring, so the navigation entry "플랫폼 구성" also matched the home page's "플랫폼 구성 보기" call to action; the locator resolved to two links and the run died on a strict-mode violation before the first measurement. With an exact match the metrics are collected, and they show the named-interaction budget is missed on this machine — a real signal that was previously invisible. The platform overview baseline was captured before the reference routes moved from `integration-defined` to `session-required` and was never regenerated, so the only visual gate that could catch a regression on that page was failing for its own staleness. Regenerated after confirming the diff is exactly that label. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
178 lines
6.0 KiB
TypeScript
178 lines
6.0 KiB
TypeScript
import { spawn } from "node:child_process";
|
|
import { mkdir, readFile } from "node:fs/promises";
|
|
import { performance } from "node:perf_hooks";
|
|
import process from "node:process";
|
|
|
|
import { chromium } from "@playwright/test";
|
|
|
|
import { evaluateLabBudget } from "../src/application/policies/performance-budgets.ts";
|
|
import { ROUTE_REGISTRY } from "../src/features/installed-feature-contracts.ts";
|
|
import { labPerformanceArtifactSchema } from "./contracts/release-artifacts.ts";
|
|
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
|
|
|
|
type ContractPerformanceEvidence = {
|
|
lcpMs: number;
|
|
cls: number;
|
|
};
|
|
|
|
type ContractPerformanceWindow = Window & {
|
|
__contractPerformance?: ContractPerformanceEvidence;
|
|
};
|
|
|
|
type LayoutShiftEntry = PerformanceEntry & {
|
|
hadRecentInput: boolean;
|
|
value: number;
|
|
};
|
|
|
|
const server = spawn(
|
|
"corepack",
|
|
["pnpm", "preview", "--host", "127.0.0.1", "--port", "4173"],
|
|
{ stdio: "ignore" },
|
|
);
|
|
const baseUrl = "http://127.0.0.1:4173";
|
|
|
|
async function waitForServer() {
|
|
for (let attempt = 0; attempt < 50; attempt += 1) {
|
|
try {
|
|
const response = await fetch(baseUrl);
|
|
if (response.ok) return;
|
|
} catch {
|
|
// The bounded retry loop handles startup races.
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
}
|
|
throw new Error("Preview server did not become ready.");
|
|
}
|
|
|
|
try {
|
|
await waitForServer();
|
|
const release = JSON.parse(
|
|
await readFile("dist/release-manifest.json", "utf8"),
|
|
);
|
|
const thresholds = JSON.parse(
|
|
await readFile("config/performance/budgets.json", "utf8"),
|
|
).lab;
|
|
const browser = await chromium.launch();
|
|
try {
|
|
const context = await browser.newContext({
|
|
viewport: { width: 1280, height: 720 },
|
|
});
|
|
const page = await context.newPage();
|
|
const cdp = await context.newCDPSession(page);
|
|
await cdp.send("Network.enable");
|
|
await cdp.send("Network.emulateNetworkConditions", {
|
|
offline: false,
|
|
latency: 40,
|
|
downloadThroughput: 200_000,
|
|
uploadThroughput: 93_750,
|
|
connectionType: "cellular4g",
|
|
});
|
|
await cdp.send("Emulation.setCPUThrottlingRate", { rate: 4 });
|
|
await page.addInitScript(() => {
|
|
const evidence = { lcpMs: 0, cls: 0 };
|
|
(window as ContractPerformanceWindow).__contractPerformance = evidence;
|
|
new PerformanceObserver((list) => {
|
|
for (const entry of list.getEntries()) evidence.lcpMs = entry.startTime;
|
|
}).observe({ type: "largest-contentful-paint", buffered: true });
|
|
new PerformanceObserver((list) => {
|
|
for (const entry of list.getEntries()) {
|
|
const layoutShift = entry as LayoutShiftEntry;
|
|
if (!layoutShift.hadRecentInput) {
|
|
evidence.cls += layoutShift.value;
|
|
}
|
|
}
|
|
}).observe({ type: "layout-shift", buffered: true });
|
|
});
|
|
await page.goto(baseUrl, { waitUntil: "networkidle" });
|
|
const target = ROUTE_REGISTRY.EXAMPLES_PLATFORM;
|
|
const targetLabel = target.navigationLabel;
|
|
if (!targetLabel) {
|
|
throw new Error("Performance route must be present in navigation.");
|
|
}
|
|
const interactionStarted = performance.now();
|
|
// Playwright matches accessible names by substring, so the navigation entry
|
|
// "플랫폼 구성" also matched the home page's "플랫폼 구성 보기" call to
|
|
// action and the locator resolved to two links. That is a strict-mode
|
|
// violation before the first measurement is taken, so no lab performance
|
|
// evidence could be produced at all — the run failed for an ambiguous
|
|
// selector rather than for anything about performance.
|
|
await page.getByRole("link", { name: targetLabel, exact: true }).click();
|
|
await page.getByRole("heading", { name: target.title }).waitFor();
|
|
const namedInteractionMs = performance.now() - interactionStarted;
|
|
const paint = await page.evaluate(
|
|
() => (window as ContractPerformanceWindow).__contractPerformance,
|
|
);
|
|
if (!paint) {
|
|
throw new Error("Browser performance evidence was not initialized.");
|
|
}
|
|
const contextMetadata = {
|
|
runner: {
|
|
platform: process.platform,
|
|
architecture: process.arch,
|
|
nodeVersion: process.version,
|
|
},
|
|
browser: { name: "chromium", version: await browser.version() },
|
|
viewport: { width: 1280, height: 720 },
|
|
network: {
|
|
profile: "contract-fast-4g",
|
|
latencyMs: 40,
|
|
downloadBytesPerSecond: 200_000,
|
|
uploadBytesPerSecond: 93_750,
|
|
},
|
|
cpu: { throttlingRate: 4 },
|
|
cache: { state: "cold", isolation: "new-browser-context" },
|
|
build: { buildId: release.buildId, releaseId: release.releaseId },
|
|
};
|
|
const metrics = {
|
|
lcpMs: Math.round(paint.lcpMs),
|
|
cls: Number(paint.cls.toFixed(4)),
|
|
namedInteractionMs: Math.round(namedInteractionMs),
|
|
};
|
|
const result = evaluateLabBudget(
|
|
{ context: contextMetadata, metrics },
|
|
thresholds,
|
|
);
|
|
const fixtures = [
|
|
{
|
|
name: "missing-context",
|
|
passed: !evaluateLabBudget({ metrics }, thresholds).passed,
|
|
},
|
|
{
|
|
name: "lcp-over-threshold",
|
|
passed: !evaluateLabBudget(
|
|
{
|
|
context: contextMetadata,
|
|
metrics: { ...metrics, lcpMs: thresholds.lcpMs + 1 },
|
|
},
|
|
thresholds,
|
|
).passed,
|
|
},
|
|
];
|
|
const passed = result.passed && fixtures.every((fixture) => fixture.passed);
|
|
await mkdir("artifacts/performance", { recursive: true });
|
|
await writeValidatedJsonArtifact({
|
|
path: "artifacts/performance/lab.json",
|
|
schema: labPerformanceArtifactSchema,
|
|
value: {
|
|
schemaVersion: 1,
|
|
generatedAt: new Date().toISOString(),
|
|
context: contextMetadata,
|
|
metrics,
|
|
thresholds,
|
|
fixtures,
|
|
passed,
|
|
},
|
|
});
|
|
if (!passed) {
|
|
throw new Error(`Lab performance failed: ${JSON.stringify(metrics)}`);
|
|
}
|
|
process.stdout.write(
|
|
`Lab performance: PASS (LCP ${metrics.lcpMs}ms, CLS ${metrics.cls}, interaction ${metrics.namedInteractionMs}ms)\n`,
|
|
);
|
|
} finally {
|
|
await browser.close();
|
|
}
|
|
} finally {
|
|
server.kill("SIGTERM");
|
|
}
|