178 lines
5.6 KiB
TypeScript
178 lines
5.6 KiB
TypeScript
import { spawn } from "node:child_process";
|
|
import { mkdir, readFile, writeFile } 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";
|
|
|
|
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 performanceRoutes: ReadonlyArray<{
|
|
access: string;
|
|
navigationLabel: string | null;
|
|
}> = Object.values(ROUTE_REGISTRY);
|
|
const targetLabel = performanceRoutes.find(
|
|
(definition) => definition.access === "integration-defined",
|
|
)?.navigationLabel;
|
|
if (!targetLabel) {
|
|
throw new Error("Performance route must be present in navigation.");
|
|
}
|
|
const interactionStarted = performance.now();
|
|
await page.getByRole("link", { name: targetLabel }).click();
|
|
await page.getByRole("heading", { name: "세션이 필요합니다." }).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 writeFile(
|
|
"artifacts/performance/lab.json",
|
|
`${JSON.stringify(
|
|
{
|
|
schemaVersion: 1,
|
|
generatedAt: new Date().toISOString(),
|
|
context: contextMetadata,
|
|
metrics,
|
|
thresholds,
|
|
fixtures,
|
|
passed,
|
|
},
|
|
null,
|
|
2,
|
|
)}\n`,
|
|
);
|
|
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");
|
|
}
|