Files
clean-architecture-frontend…/scripts/test-performance.ts
T
DongHyeonkaandClaude Opus 5 7485cd86e4 fix: let the browser, visual and performance evidence describe the product again
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>
2026-08-15 16:49:48 +09:00

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");
}