Integrates the frontend template sync (a0fbafb → 5434760) into the TechLog UI migration. Merged in this direction so every conflict is resolved and proved in the worktree; main is only fast-forwarded afterwards and never holds a state that was not verified here. 15 conflicts. The rule throughout: keep the template's mechanism, keep the product's content, and never invent a third state neither branch would accept. The template's product manifest and its runtime feature kill switch are adopted. The route registry is deliberately not composed from contract.routes: the reference feature still declares screens this product deleted, and reducing over them would register paths with no component behind them. ROUTE_FEATURE_OWNER is narrowed to registered routes for the same reason. The first resolution did compose from contract.routes and was rejected by product-features.test.ts. Three files pinned counts and a digest describing the gate contract. Neither side's numbers describe the merged config/ci/gates.json, so they were recomputed from it rather than chosen: 27 gates, 82 commands, 94 command references, 107 evidence references, 128 artifacts, shape sha256 5063586d. README.md and docs/accessibility/manual-checklist.md now enumerate this product's 27 routes, which the template's own verify:documentation requires. product-feature-switch.test.tsx was rewritten around the invariant that still applies here — no registered route without a component — rather than deleted with the screens it used to exercise. docs/operations/template-merge-2026-08-17.md records every decision, the gate results, and the three follow-ups this merge deliberately did not decide. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
174 lines
5.9 KiB
TypeScript
174 lines
5.9 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.TECH_LOG_HOME;
|
|
const interactionStarted = performance.now();
|
|
// Template merge. The template clicked a navigation link here; this
|
|
// product measures its own landing route, which `goto` already reached, so
|
|
// there is no click and no `targetLabel`. The template's lesson is kept
|
|
// because it applies to whatever click lands here next: Playwright matches
|
|
// accessible names by substring, so a nav entry can also match a call to
|
|
// action and resolve to two links — a strict-mode violation before the
|
|
// first measurement, which produces no performance evidence at all.
|
|
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");
|
|
}
|