Carries eight template commits: the provider sandbox actually running, release
admission to a named environment, the product feature manifest with its runtime
kill switch, architecture and documentation rules that match what is enforced,
the removability fixtures, and the browser, visual and performance evidence.
Product identity is unchanged. `package.json` keeps `tech-log-frontend` and the
catalog keeps the Tech Log naming; the home page was not in the delta. The
visual baselines are this product's own — the template's were excluded from the
transplant and these were regenerated here, where the only difference is the
platform overview's new product-feature section.
What this repository gains operationally: `config/runtime/{local,development,
staging,production}.json` with `FE-GATE-027` refusing an artifact whose runtime
document does not match the environment it is being admitted to, and
`FEATURE_OVERRIDES` for taking an installed feature out of service without a
rebuild.
Verified here: eight gates green, build green, visual 5/5, and 1,858 of 1,859
tests in the suites that do not need a sandbox — the one failure passes in
isolation and is a jsdom lazy-chunk timeout under parallel load. The provider
suites cannot run on this machine at all: `kernel.apparmor_restrict_unprivileged
_userns=1` makes `bwrap --unshare-net` fail, reproducible without any code from
either repository.
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");
|
|
}
|