From 0479ea13167eabb21d277419db96b75cc824480a Mon Sep 17 00:00:00 2001 From: furedea <132188853+furedea@users.noreply.github.com> Date: Sat, 15 Aug 2026 00:56:25 +0900 Subject: [PATCH 1/2] fix(performance): stabilize browser budget measurements --- playwright_performance.config.ts | 2 +- tests/performance/performance.spec.ts | 71 +++++----------- tests/performance/performance_report.test.ts | 36 ++++++-- tests/performance/performance_report.ts | 83 ++++++++++++++++--- .../performance/performance_reporter.test.ts | 5 +- tests/performance/performance_sampler.test.ts | 70 ++++++++++++++++ tests/performance/performance_sampler.ts | 70 ++++++++++++++++ 7 files changed, 269 insertions(+), 68 deletions(-) create mode 100644 tests/performance/performance_sampler.test.ts create mode 100644 tests/performance/performance_sampler.ts diff --git a/playwright_performance.config.ts b/playwright_performance.config.ts index cc70e12..23ef23a 100644 --- a/playwright_performance.config.ts +++ b/playwright_performance.config.ts @@ -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: { diff --git a/tests/performance/performance.spec.ts b/tests/performance/performance.spec.ts index e97bbb3..653786c 100644 --- a/tests/performance/performance.spec.ts +++ b/tests/performance/performance.spec.ts @@ -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, @@ -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; @@ -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 { - 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 { - const browser = await chromium.launch(); +async function measurePage(browser: Browser, route: string): Promise { + 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); @@ -109,7 +106,7 @@ async function measurePage(route: string): Promise { return await readMetrics(page, resources); } finally { - await browser.close(); + await context.close(); } } @@ -214,29 +211,6 @@ function resourceKind(resourceType: string | undefined): Exclude 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( @@ -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), diff --git a/tests/performance/performance_report.test.ts b/tests/performance/performance_report.test.ts index a3088f3..0dc8498 100644 --- a/tests/performance/performance_report.test.ts +++ b/tests/performance/performance_report.test.ts @@ -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 | `); }); @@ -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: { @@ -85,5 +109,7 @@ function reportEntry(): PerformanceReportEntry { font: { size: 700_000, count: 35 }, }, }, - }; + } satisfies Omit; + + return { ...entry, samples: [entry.metrics], wasExtended: false }; } diff --git a/tests/performance/performance_report.ts b/tests/performance/performance_report.ts index 1db08e1..0bec966 100644 --- a/tests/performance/performance_report.ts +++ b/tests/performance/performance_report.ts @@ -17,7 +17,9 @@ export type PerformanceReportEntry = { budget: PerformanceBudget; metrics: PerformanceMetrics; route: string; + samples: PerformanceMetrics[]; status: "failed" | "interrupted" | "passed" | "skipped" | "timedOut"; + wasExtended: boolean; }; export type PerformanceMeasurement = Omit; @@ -27,8 +29,8 @@ export function renderPerformanceSummary(entries: PerformanceReportEntry[]): str return [ "## Performance budgets", "", - "| Status | Page | FCP | LCP | CLS | Blocking | Transfer | Requests |", - "| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: |", + "| Status | Page | FCP | LCP | CLS | Blocking | Transfer | Requests | Samples |", + "| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |", ...rows, "", ].join("\n"); @@ -52,21 +54,47 @@ export function writePerformanceReports( } function renderRow(entry: PerformanceReportEntry): string { - const { metrics } = entry; + const { metrics, samples } = entry; return [ - `| ${statusSymbol(entry.status)} | \`${entry.route}\``, - `${seconds(metrics.firstContentfulPaint)} s`, - `${seconds(metrics.largestContentfulPaint)} s`, - metrics.cumulativeLayoutShift.toFixed(3), - `${Math.round(metrics.blockingTime)} ms`, - `${Math.round(metrics.resources.total.size / 1_024)} KB`, - `${metrics.resources.total.count} |`, + `| ${statusSymbol(entry)} | \`${entry.route}\``, + secondsWithRange( + metrics.firstContentfulPaint, + samples.map(({ firstContentfulPaint }) => firstContentfulPaint), + ), + secondsWithRange( + metrics.largestContentfulPaint, + samples.map(({ largestContentfulPaint }) => largestContentfulPaint), + ), + valueWithRange( + metrics.cumulativeLayoutShift, + samples.map(({ cumulativeLayoutShift }) => cumulativeLayoutShift), + (value) => value.toFixed(3), + ), + unitWithRange( + metrics.blockingTime, + samples.map(({ blockingTime }) => blockingTime), + "ms", + Math.round, + ), + unitWithRange( + metrics.resources.total.size, + samples.map(({ resources }) => resources.total.size), + "KB", + (value) => Math.round(value / 1_024), + ), + valueWithRange( + metrics.resources.total.count, + samples.map(({ resources }) => resources.total.count), + String, + ), + `${samples.length} |`, ].join(" | "); } -function statusSymbol(status: PerformanceReportEntry["status"]): string { - if (status === "passed") return "✅"; - if (status === "skipped") return "➖"; +function statusSymbol(entry: PerformanceReportEntry): string { + if (entry.status === "passed" && entry.wasExtended) return "⚠️ unstable"; + if (entry.status === "passed") return "✅"; + if (entry.status === "skipped") return "➖"; return "❌"; } @@ -74,6 +102,35 @@ function seconds(milliseconds: number): string { return (milliseconds / 1_000).toFixed(2); } +function secondsWithRange(median: number, samples: number[]): string { + return unitWithRange(median, samples, "s", seconds); +} + +function unitWithRange( + median: number, + samples: number[], + unit: string, + format: (value: number) => string | number, +): string { + const minimum = Math.min(...samples); + const maximum = Math.max(...samples); + const formattedMedian = format(median); + if (minimum === maximum) return `${formattedMedian} ${unit}`; + return `${formattedMedian} ${unit} (${format(minimum)}–${format(maximum)})`; +} + +function valueWithRange( + median: number, + samples: number[], + format: (value: number) => string | number, +): string { + const minimum = Math.min(...samples); + const maximum = Math.max(...samples); + const formattedMedian = format(median); + if (minimum === maximum) return String(formattedMedian); + return `${formattedMedian} (${format(minimum)}–${format(maximum)})`; +} + function byRoute(left: PerformanceReportEntry, right: PerformanceReportEntry): number { return left.route.localeCompare(right.route); } diff --git a/tests/performance/performance_reporter.test.ts b/tests/performance/performance_reporter.test.ts index 125aad7..4d9303d 100644 --- a/tests/performance/performance_reporter.test.ts +++ b/tests/performance/performance_reporter.test.ts @@ -7,10 +7,13 @@ import { parsePerformanceResult } from "./performance_reporter"; describe("performance reporter", () => { test("combines an attached measurement with the final Playwright status", () => { + const measuredMetrics = metrics(1_200); const measurement: Omit = { budget: metrics(2_800), - metrics: metrics(1_200), + metrics: measuredMetrics, route: "/ja/", + samples: [measuredMetrics], + wasExtended: false, }; expect(parsePerformanceResult(Buffer.from(JSON.stringify(measurement)), "failed")).toEqual({ diff --git a/tests/performance/performance_sampler.test.ts b/tests/performance/performance_sampler.test.ts new file mode 100644 index 0000000..2aa8df1 --- /dev/null +++ b/tests/performance/performance_sampler.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, test } from "vitest"; + +import type { PerformanceBudget, PerformanceMetrics } from "./performance_report"; +import { collectAdaptiveMetrics } from "./performance_sampler"; + +describe("adaptive performance sampling", () => { + test("uses three samples when the initial median is within the variable budgets", async () => { + const samples = [metrics(1_000), metrics(1_200), metrics(1_100)]; + + const measurement = await collectAdaptiveMetrics(sequence(samples), budget()); + + expect(measurement).toMatchObject({ + metrics: { firstContentfulPaint: 1_100 }, + samples, + wasExtended: false, + }); + }); + + test("adds two samples when the initial median exceeds a variable budget", async () => { + const samples = [ + metrics(3_000), + metrics(6_000), + metrics(6_100), + metrics(1_000), + metrics(1_200), + ]; + + const measurement = await collectAdaptiveMetrics(sequence(samples), budget()); + + expect(measurement).toMatchObject({ + metrics: { firstContentfulPaint: 3_000 }, + samples, + wasExtended: true, + }); + }); + + test("does not add samples for a resource-only budget failure", async () => { + const samples = [metrics(1_000), metrics(1_100), metrics(1_200)]; + const resourceBudget = budget(); + resourceBudget.resources.total.size = 1; + + const measurement = await collectAdaptiveMetrics(sequence(samples), resourceBudget); + + expect(measurement).toMatchObject({ samples, wasExtended: false }); + }); +}); + +function sequence(samples: PerformanceMetrics[]): () => Promise { + let index = 0; + return async () => samples[index++]; +} + +function metrics(duration: number): PerformanceMetrics { + return { + blockingTime: duration / 10, + cumulativeLayoutShift: 0.01, + firstContentfulPaint: duration, + largestContentfulPaint: duration, + resources: { + font: { count: 1, size: 1_024 }, + image: { count: 1, size: 2_048 }, + script: { count: 1, size: 3_072 }, + total: { count: 3, size: 6_144 }, + }, + }; +} + +function budget(): PerformanceBudget { + return metrics(3_500); +} diff --git a/tests/performance/performance_sampler.ts b/tests/performance/performance_sampler.ts new file mode 100644 index 0000000..7b102d6 --- /dev/null +++ b/tests/performance/performance_sampler.ts @@ -0,0 +1,70 @@ +import type { PerformanceBudget, PerformanceMetrics, ResourceKind } from "./performance_report"; + +const INITIAL_SAMPLE_COUNT = 3; +const CONFIRMATION_SAMPLE_COUNT = 2; + +type AdaptiveMeasurement = { + metrics: PerformanceMetrics; + samples: PerformanceMetrics[]; + wasExtended: boolean; +}; + +export async function collectAdaptiveMetrics( + measure: () => Promise, + budget: PerformanceBudget, +): Promise { + const samples = await collectSamples(measure, INITIAL_SAMPLE_COUNT); + const initialMetrics = medianMetrics(samples); + if (variableMetricsWithinBudget(initialMetrics, budget)) { + return { metrics: initialMetrics, samples, wasExtended: false }; + } + + samples.push(...(await collectSamples(measure, CONFIRMATION_SAMPLE_COUNT))); + return { metrics: medianMetrics(samples), samples, wasExtended: true }; +} + +async function collectSamples( + measure: () => Promise, + count: number, +): Promise { + const samples: PerformanceMetrics[] = []; + for (let index = 0; index < count; index += 1) { + samples.push(await measure()); + } + return samples; +} + +function medianMetrics(samples: PerformanceMetrics[]): PerformanceMetrics { + 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 PerformanceMetrics["resources"], + }; +} + +function median(values: number[]): number { + const sorted = values.toSorted((left, right) => left - right); + return sorted[Math.floor(sorted.length / 2)]; +} + +function variableMetricsWithinBudget( + metrics: PerformanceMetrics, + budget: PerformanceBudget, +): boolean { + return ( + metrics.firstContentfulPaint <= budget.firstContentfulPaint && + metrics.largestContentfulPaint <= budget.largestContentfulPaint && + metrics.cumulativeLayoutShift <= budget.cumulativeLayoutShift && + metrics.blockingTime <= budget.blockingTime + ); +} From a4a03c253ae42a88e54fafe756d3e069fd024cb1 Mon Sep 17 00:00:00 2001 From: furedea <132188853+furedea@users.noreply.github.com> Date: Sat, 15 Aug 2026 01:07:04 +0900 Subject: [PATCH 2/2] fix(performance): self-host Japanese web font --- ...rce-performance-budgets-with-playwright.md | 2 +- ...0012-stabilize-performance-measurements.md | 15 +++++++++++++++ package.json | 1 + pnpm-lock.yaml | 8 ++++++++ public/_headers | 2 +- src/components/seo_head.astro | 7 ------- src/layouts/base_layout.astro | 1 + src/styles/global.css | 2 +- tests/e2e/site.spec.ts | 19 +++++++++++++++++++ 9 files changed, 47 insertions(+), 10 deletions(-) create mode 100644 docs/adr/0012-stabilize-performance-measurements.md diff --git a/docs/adr/0011-enforce-performance-budgets-with-playwright.md b/docs/adr/0011-enforce-performance-budgets-with-playwright.md index 528391f..eaae679 100644 --- a/docs/adr/0011-enforce-performance-budgets-with-playwright.md +++ b/docs/adr/0011-enforce-performance-budgets-with-playwright.md @@ -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, diff --git a/docs/adr/0012-stabilize-performance-measurements.md b/docs/adr/0012-stabilize-performance-measurements.md new file mode 100644 index 0000000..61c13d7 --- /dev/null +++ b/docs/adr/0012-stabilize-performance-measurements.md @@ -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. diff --git a/package.json b/package.json index 074baae..ae0fb26 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5345d93..5e219b0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: '@fontsource-variable/inter': specifier: ^5.2.8 version: 5.2.8 + '@fontsource-variable/noto-sans-jp': + specifier: ^5.2.8 + version: 5.3.0 astro: specifier: ^7.1.0 version: 7.1.6(@astrojs/markdown-remark@7.2.2)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.2)(@types/node@25.5.2)(jiti@2.6.1)(rollup@4.60.1)(yaml@2.8.3) @@ -466,6 +469,9 @@ packages: '@fontsource-variable/inter@5.2.8': resolution: {integrity: sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ==} + '@fontsource-variable/noto-sans-jp@5.3.0': + resolution: {integrity: sha512-XVL9GfGBRjj523v9w3Bctu4mNq7E5rUa+AMZm8DK1EM3LAGlKC3DZMvuqkLn4DB4TwwoAjcsJUCiRpUTDzzOkw==} + '@iconify/types@2.0.0': resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} @@ -3681,6 +3687,8 @@ snapshots: '@fontsource-variable/inter@5.2.8': {} + '@fontsource-variable/noto-sans-jp@5.3.0': {} + '@iconify/types@2.0.0': {} '@iconify/utils@3.1.4': diff --git a/public/_headers b/public/_headers index 7500084..4c33e40 100644 --- a/public/_headers +++ b/public/_headers @@ -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 diff --git a/src/components/seo_head.astro b/src/components/seo_head.astro index 1ac6c6e..c7420fc 100644 --- a/src/components/seo_head.astro +++ b/src/components/seo_head.astro @@ -65,13 +65,6 @@ const socialPreviewUrl = `${origin}${socialPreview.path}`; href={`${BASE_PATH}/rss.xml`} /> - - - - diff --git a/src/layouts/base_layout.astro b/src/layouts/base_layout.astro index 3fdea25..f0cf026 100644 --- a/src/layouts/base_layout.astro +++ b/src/layouts/base_layout.astro @@ -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"; diff --git a/src/styles/global.css b/src/styles/global.css index 9c32942..f594402 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -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; diff --git a/tests/e2e/site.spec.ts b/tests/e2e/site.spec.ts index c6f0798..35d127c 100644 --- a/tests/e2e/site.spec.ts +++ b/tests/e2e/site.spec.ts @@ -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(); + 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/");