Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# ADR-0011: Enforce performance budgets with Playwright

- Status: Accepted
- Status: Superseded by ADR-0012
- Date: 2026-08-14

In the context of guarding the static Astro site's performance as published pages increase,
Expand Down
15 changes: 15 additions & 0 deletions docs/adr/0012-stabilize-performance-measurements.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# ADR-0012: Stabilize performance measurements

- Status: Accepted
- Date: 2026-08-15
- Supersedes: ADR-0011

In the context of enforcing browser performance budgets on variable GitHub-hosted runners,
facing a paint-time outlier consistent with runtime network variability while resource and
execution metrics remained stable, we decided for self-hosted rendering assets and adaptive
Playwright sampling against fixed three-run sampling, generic test retries, relaxed budgets, and
Lighthouse CI: measure three fresh browser contexts, add two confirmation measurements only when
the initial median exceeds a timing or layout budget, and evaluate the median of all samples. We
retain every sample, mark recovered results as unstable, and report ranges and sample counts,
accepting two additional measurements for suspicious pages and larger report artifacts in exchange
for fewer false positives without hiding the original failure evidence.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"@astrojs/rss": "^4.0.19",
"@astrojs/sitemap": "^3.7.2",
"@fontsource-variable/inter": "^5.2.8",
"@fontsource-variable/noto-sans-jp": "^5.2.8",
"astro": "^7.1.0",
"mermaid": "^11.16.1",
"sharp": "^0.35.3",
Expand Down
2 changes: 1 addition & 1 deletion playwright_performance.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export default defineConfig({
fullyParallel: false,
workers: 1,
retries: 0,
timeout: 120_000,
timeout: 180_000,
outputDir: "test-results/performance",
reporter: [["list"], ["./tests/performance/performance_reporter.ts"]],
webServer: {
Expand Down
8 changes: 8 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion public/_headers
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
Content-Security-Policy: default-src 'self'; base-uri 'self'; connect-src 'self'; font-src 'self' https://fonts.gstatic.com; form-action 'self'; frame-ancestors 'none'; img-src 'self' data:; object-src 'none'; script-src 'self' 'unsafe-inline' https://static.cloudflareinsights.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; upgrade-insecure-requests
Content-Security-Policy: default-src 'self'; base-uri 'self'; connect-src 'self'; font-src 'self'; form-action 'self'; frame-ancestors 'none'; img-src 'self' data:; object-src 'none'; script-src 'self' 'unsafe-inline' https://static.cloudflareinsights.com; style-src 'self' 'unsafe-inline'; upgrade-insecure-requests
Permissions-Policy: camera=(), geolocation=(), microphone=(), payment=(), usb=()
Referrer-Policy: strict-origin-when-cross-origin
Strict-Transport-Security: max-age=31536000
Expand Down
7 changes: 0 additions & 7 deletions src/components/seo_head.astro
Original file line number Diff line number Diff line change
Expand Up @@ -65,13 +65,6 @@ const socialPreviewUrl = `${origin}${socialPreview.path}`;
href={`${BASE_PATH}/rss.xml`}
/>

<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>

<meta property="og:type" content={ogType} />
<meta property="og:site_name" content={site.ownerName.en} />
<meta property="og:title" content={title} />
Expand Down
1 change: 1 addition & 0 deletions src/layouts/base_layout.astro
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
---
import "@fontsource-variable/inter/wght.css";
import "@fontsource-variable/noto-sans-jp/wght.css";
import "@styles/global.css";
import type { Lang } from "@i18n/ui";
import { useTranslations } from "@i18n/utils";
Expand Down
2 changes: 1 addition & 1 deletion src/styles/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
--color-text-secondary: #a1a1aa;
--color-accent: #4af2c8;

--font-sans: "Inter Variable", "Noto Sans JP", system-ui, sans-serif;
--font-sans: "Inter Variable", "Noto Sans JP Variable", system-ui, sans-serif;
--font-mono: ui-monospace, "SFMono-Regular", "Menlo", monospace;

--width-content: 56rem;
Expand Down
19 changes: 19 additions & 0 deletions tests/e2e/site.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,25 @@ test("publishes generated social preview metadata on an article page", async ({
);
});

test("loads critical article resources only from the site origin", async ({ page }) => {
const externalOrigins = new Set<string>();
page.on("request", (request) => {
const url = new URL(request.url());
const isCriticalResource = ["font", "script", "stylesheet"].includes(request.resourceType());
if (
isCriticalResource &&
url.protocol.startsWith("http") &&
url.origin !== "http://127.0.0.1:4321"
) {
externalOrigins.add(url.origin);
}
});

await page.goto("/en/blog/modern-terminal-environment/", { waitUntil: "networkidle" });

expect([...externalOrigins]).toEqual([]);
});

test("unknown paths return the custom not-found page", async ({ page }) => {
const response = await page.goto("/missing-page/");

Expand Down
71 changes: 23 additions & 48 deletions tests/performance/performance.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { readdirSync } from "node:fs";
import { join, relative, sep } from "node:path";

import { chromium, expect, test, type CDPSession, type Page } from "@playwright/test";
import { expect, test, type Browser, type CDPSession, type Page } from "@playwright/test";

import {
type PerformanceBudget as Budget,
Expand All @@ -10,10 +10,10 @@ import {
type ResourceKind,
} from "./performance_report";
import { PERFORMANCE_RESULT_ATTACHMENT } from "./performance_reporter";
import { collectAdaptiveMetrics } from "./performance_sampler";

const DIST_DIRECTORY = join(import.meta.dirname, "..", "..", "dist");
const BASE_URL = "http://127.0.0.1:4321";
const RUNS_PER_PAGE = 3;
const NETWORK_LATENCY_MS = 150;
const NETWORK_THROUGHPUT_BYTES_PER_SECOND = (1_600 * 1_024) / 8;
const CPU_SLOWDOWN_RATE = 4;
Expand Down Expand Up @@ -58,39 +58,36 @@ const ARTICLE_BUDGET: Budget = {
const routes = discoverRoutes(DIST_DIRECTORY);

for (const route of routes) {
test(`${route} remains within its performance budget`, async () => {
const samples = await collectSamples(route);
const metrics = medianMetrics(samples);
test(`${route} remains within its performance budget`, async ({ browser }) => {
const budget = budgetFor(route);
const { metrics, samples, wasExtended } = await collectAdaptiveMetrics(
() => measurePage(browser, route),
budget,
);

const measurement = { budget, metrics, route } satisfies PerformanceMeasurement;
const measurement = {
budget,
metrics,
route,
samples,
wasExtended,
} satisfies PerformanceMeasurement;
await test.info().attach(PERFORMANCE_RESULT_ATTACHMENT, {
body: Buffer.from(JSON.stringify(measurement)),
contentType: "application/json",
});

printMetrics(route, metrics);
printMetrics(route, metrics, samples.length);
expectMetricsWithinBudget(metrics, budget);
});
}

async function collectSamples(route: string): Promise<Metrics[]> {
const samples: Metrics[] = [];

for (let run = 0; run < RUNS_PER_PAGE; run += 1) {
samples.push(await measurePage(route));
}

return samples;
}

async function measurePage(route: string): Promise<Metrics> {
const browser = await chromium.launch();
async function measurePage(browser: Browser, route: string): Promise<Metrics> {
const context = await browser.newContext({
serviceWorkers: "block",
viewport: { width: 1_280, height: 720 },
});
try {
const context = await browser.newContext({
serviceWorkers: "block",
viewport: { width: 1_280, height: 720 },
});
const page = await context.newPage();
const session = await context.newCDPSession(page);
const resources = observeNetworkResources(session);
Expand All @@ -109,7 +106,7 @@ async function measurePage(route: string): Promise<Metrics> {

return await readMetrics(page, resources);
} finally {
await browser.close();
await context.close();
}
}

Expand Down Expand Up @@ -214,29 +211,6 @@ function resourceKind(resourceType: string | undefined): Exclude<ResourceKind, "
return null;
}

function medianMetrics(samples: Metrics[]): Metrics {
return {
firstContentfulPaint: median(samples.map((sample) => sample.firstContentfulPaint)),
largestContentfulPaint: median(samples.map((sample) => sample.largestContentfulPaint)),
cumulativeLayoutShift: median(samples.map((sample) => sample.cumulativeLayoutShift)),
blockingTime: median(samples.map((sample) => sample.blockingTime)),
resources: Object.fromEntries(
(["total", "script", "image", "font"] satisfies ResourceKind[]).map((kind) => [
kind,
{
size: median(samples.map((sample) => sample.resources[kind].size)),
count: median(samples.map((sample) => sample.resources[kind].count)),
},
]),
) as Metrics["resources"],
};
}

function median(values: number[]): number {
const sorted = values.toSorted((left, right) => left - right);
return sorted[Math.floor(sorted.length / 2)];
}

function expectMetricsWithinBudget(metrics: Metrics, budget: Budget): void {
expect(metrics.firstContentfulPaint, "first contentful paint (ms)").toBeGreaterThan(0);
expect(metrics.firstContentfulPaint, "first contentful paint (ms)").toBeLessThanOrEqual(
Expand Down Expand Up @@ -281,9 +255,10 @@ function listHtmlFiles(directory: string): string[] {
});
}

function printMetrics(route: string, metrics: Metrics): void {
function printMetrics(route: string, metrics: Metrics, sampleCount: number): void {
console.table({
route,
samples: sampleCount,
fcpMs: Math.round(metrics.firstContentfulPaint),
lcpMs: Math.round(metrics.largestContentfulPaint),
cls: metrics.cumulativeLayoutShift.toFixed(3),
Expand Down
36 changes: 31 additions & 5 deletions tests/performance/performance_report.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@ describe("performance report", () => {

expect(summary).toBe(`## Performance budgets

| Status | Page | FCP | LCP | CLS | Blocking | Transfer | Requests |
| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: |
| ✅ | \`/ja/\` | 1.23 s | 1.45 s | 0.012 | 88 ms | 512 KB | 20 |
| Status | Page | FCP | LCP | CLS | Blocking | Transfer | Requests | Samples |
| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
| ✅ | \`/ja/\` | 1.23 s | 1.45 s | 0.012 | 88 ms | 512 KB | 20 | 1 |
`);
});

Expand Down Expand Up @@ -55,10 +55,34 @@ describe("performance report", () => {
rmSync(directory, { recursive: true });
}
});

test("marks a recovered measurement as unstable", () => {
const entry = {
...reportEntry(),
samples: Array.from({ length: 5 }, () => reportEntry().metrics),
wasExtended: true,
};

expect(renderPerformanceSummary([entry])).toContain("| ⚠️ unstable | `/ja/` |");
});

test("shows the sample count and timing range", () => {
const entry = reportEntry();
entry.samples = [
{ ...entry.metrics, firstContentfulPaint: 1_000 },
entry.metrics,
{ ...entry.metrics, firstContentfulPaint: 1_500 },
];

const summary = renderPerformanceSummary([entry]);

expect(summary).toContain("1.23 s (1.00–1.50)");
expect(summary).toContain("| 3 |");
});
});

function reportEntry(): PerformanceReportEntry {
return {
const entry = {
route: "/ja/",
status: "passed",
metrics: {
Expand All @@ -85,5 +109,7 @@ function reportEntry(): PerformanceReportEntry {
font: { size: 700_000, count: 35 },
},
},
};
} satisfies Omit<PerformanceReportEntry, "samples" | "wasExtended">;

return { ...entry, samples: [entry.metrics], wasExtended: false };
}
Loading
Loading