diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b2abb7af..126cedc4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,5 +16,12 @@ jobs: cache: npm - run: npm ci - run: npm test + # Browser step for the render-blocking guarantee (dogfood finding + # b8f6046e942a): the static guards prove the blocking shape cannot + # return, this step proves the served pages actually render without it. + - name: Render-blocking regression check (real Chromium) + run: | + npx playwright install --with-deps chromium + npm run check:render-blocking - name: Wrangler config and bundle check run: npx wrangler@latest deploy --dry-run diff --git a/docs/evidence/render-blocking-fonts-2026-08-08.md b/docs/evidence/render-blocking-fonts-2026-08-08.md index 585a7770..c5622c0e 100644 --- a/docs/evidence/render-blocking-fonts-2026-08-08.md +++ b/docs/evidence/render-blocking-fonts-2026-08-08.md @@ -96,4 +96,21 @@ await browser.close(); ## Limitation -This is local static-server proof, not CI proof and not a hosted/live claim. The repo's CI (`npm test`) has no browser dependency, so it runs only the source-string guards in `scripts/check-site.mjs` plus the worker/UI contract tests; a render-blocking regression can therefore still ship if the served HTML drifts and CI stays green. The live tinystudio.io deployment was not measured here; a deployed page could differ (CDN cache, different asset versions). To be CI-proof this measurement would need a browser step added to CI (not done — out of scope). +This is local static-server proof, not CI proof and not a hosted/live claim. The repo's CI (`npm test`) has no browser dependency, so it runs only the source-string guards in `scripts/check-site.mjs` plus the worker/UI contract tests; a render-blocking regression can therefore still ship if the served HTML drifts and CI stays green. The live tinystudio.io deployment was not measured here; a deployed page could differ (CDN cache, different asset versions). + +### CI enforcement (added 2026-08-09) + +The browser gap above is now closed: `.github/workflows/ci.yml` runs +`npm run check:render-blocking` as a browser step (`scripts/check-render-blocking.mjs`), +so the render-blocking guarantee no longer depends on CI staying source-green. +The check serves the six public pages statically under the exact production CSP +header, intercepts the css2 request and delays it, then asserts in real +Chromium that (1) the css2 resource is non-blocking, (2) first-contentful-paint +does not wait for it, (3) the only render-blocking resources are the site's own +same-origin stylesheets, and (4) the preload link is still promoted to a real +stylesheet under the production CSP. The css2 response is stubbed so the check +has no external network dependency; the static guards above keep the real font +URL and the no-JS fallback honest. A blocking shape (a `` +or `@import` returning on any of the six pages) fails CI: measured locally, a +reintroduced blocking link holds first paint to the delayed css2 response +(fcp 2780ms vs css2 end 2566ms) and the check fails with exit code 1. diff --git a/package-lock.json b/package-lock.json index 861e1384..5112fee0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "tinystudio-io", "version": "0.1.0", "devDependencies": { + "playwright": "^1.62.1", "wrangler": "^4.93.0" }, "engines": { @@ -689,9 +690,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -709,9 +707,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -729,9 +724,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -749,9 +741,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -769,9 +758,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -789,9 +775,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -809,9 +792,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -829,9 +809,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -849,9 +826,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -875,9 +849,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -901,9 +872,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -927,9 +895,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -953,9 +918,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -979,9 +941,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1005,9 +964,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1031,9 +987,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1349,6 +1302,53 @@ "dev": true, "license": "MIT" }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/semver": { "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", diff --git a/package.json b/package.json index 2d8e3a46..7ab1eb6e 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "scripts": { "dev": "wrangler dev --remote --ip 127.0.0.1 --port 8788 --persist-to /tmp/tinystudio-agent-worker", "check": "node scripts/check-site.mjs", + "check:render-blocking": "node scripts/check-render-blocking.mjs", "test": "npm run check && npm run test:worker && npm run test:ui", "test:worker": "node --test scripts/test-agent-worker.mjs", "test:ui": "node --test scripts/test-agent-ui.mjs", @@ -20,6 +21,7 @@ "node": ">=20" }, "devDependencies": { + "playwright": "^1.62.1", "wrangler": "^4.93.0" } } diff --git a/scripts/check-render-blocking.mjs b/scripts/check-render-blocking.mjs new file mode 100644 index 00000000..e33dc974 --- /dev/null +++ b/scripts/check-render-blocking.mjs @@ -0,0 +1,214 @@ +// TinyStudio — browser render-blocking guard (dogfood finding b8f6046e942a). +// +// The static guards in check-site.mjs prove the served HTML cannot contain the +// blocking font shape, but they cannot see the behavior: a regression could +// still ship if the served HTML drifts and CI stays green. This check runs +// real Chromium against the six public pages, served statically under the +// exact production Content-Security-Policy the worker emits, and asserts: +// +// 1. the Google Fonts css2 stylesheet is fetched non-blocking +// (renderBlockingStatus !== "blocking"), +// 2. first-contentful-paint does not wait for it (the css2 response is +// intercepted and delayed, so a blocking shape deterministically delays +// first paint), +// 3. the only render-blocking resources are the site's own same-origin +// stylesheets, +// 4. the preload link is still promoted to a real stylesheet under the +// production CSP (the inline-onload shape the CSP forbids would leave +// link.sheet null). +// +// The css2 response is stubbed so the check has no external network +// dependency; the real font URL and the no-JS fallback are kept honest by the +// static guards in scripts/check-site.mjs. +// +// Run: node scripts/check-render-blocking.mjs (or `npm run check:render-blocking`) + +import http from "node:http"; +import { readFileSync, statSync } from "node:fs"; +import { extname, join, normalize } from "node:path"; +import { fileURLToPath } from "node:url"; +import { chromium } from "playwright"; + +// Mirrors SECURITY_HEADERS in src/worker.js. The promotion script is only +// required because this exact header forbids inline handlers; the check must +// run under it or it would not exercise the production shape. +const PRODUCTION_CSP = + "default-src 'self'; img-src 'self' data:; style-src 'self' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; script-src 'self' https://static.cloudflareinsights.com; connect-src 'self' https://cloudflareinsights.com; base-uri 'self'; frame-ancestors 'none'; form-action 'self'"; + +const FONT_CSS_PATTERN = "**fonts.googleapis.com/css2**"; +const CSS2_DELAY_MS = 2500; // generous: a slow CI runner still paints first before this +const STUB_CSS = "/* TinyStudio render-blocking check: stubbed font stylesheet */"; + +const PUBLIC_DIR = fileURLToPath(new URL("../public/", import.meta.url)); +const PAGES = [ + ["homepage", "index.html"], + ["audit page", "audit.html"], + ["desk page", "agents.html"], + ["pricing page", "pricing.html"], + ["specimen page", "specimen.html"], + ["brief-requested page", "brief-requested.html"] +]; + +const CONTENT_TYPES = { + ".html": "text/html; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".svg": "image/svg+xml", + ".png": "image/png", + ".txt": "text/plain; charset=utf-8", + ".md": "text/markdown; charset=utf-8", + ".xml": "application/xml; charset=utf-8", + ".ico": "image/x-icon" +}; + +function startServer() { + return new Promise((resolve) => { + const server = http.createServer((req, res) => { + const url = new URL(req.url, "http://127.0.0.1"); + let rel = decodeURIComponent(url.pathname); + if (rel === "/") rel = "/index.html"; + const file = normalize(join(PUBLIC_DIR, rel)); + if (!file.startsWith(PUBLIC_DIR)) { + res.writeHead(403).end("forbidden"); + return; + } + try { + statSync(file); + } catch { + res.writeHead(404).end("not found"); + return; + } + res.writeHead(200, { + "Content-Type": CONTENT_TYPES[extname(file)] ?? "application/octet-stream", + "Content-Security-Policy": PRODUCTION_CSP + }); + res.end(readFileSync(file)); + }); + server.listen(0, "127.0.0.1", () => resolve(server)); + }); +} + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +async function measurePage(page, pageUrl) { + // Delay the css2 response: if the stylesheet is ever fetched render-blocking + // again, first paint is held until it responds and the check fails. + await page.route(FONT_CSS_PATTERN, async (route) => { + await sleep(CSS2_DELAY_MS); + await route.fulfill({ + status: 200, + contentType: "text/css; charset=utf-8", + body: STUB_CSS + }); + }); + + await page.goto(pageUrl, { waitUntil: "domcontentloaded", timeout: 30000 }); + + let fcp = null; + let css2 = null; + for (let i = 0; i < 160; i++) { + const state = await page.evaluate(() => { + const paint = performance.getEntriesByType("paint").find((p) => p.name === "first-contentful-paint"); + const resources = performance.getEntriesByType("resource").map((r) => ({ + name: r.name, + responseEnd: r.responseEnd, + renderBlockingStatus: r.renderBlockingStatus + })); + return { + fcp: paint ? Math.round(paint.startTime) : null, + resources + }; + }); + fcp = state.fcp; + css2 = state.resources.find((r) => r.name.includes("css2")) ?? null; + if (fcp !== null && css2 !== null) break; + await sleep(50); + } + + // Give the (stubbed, delayed) stylesheet time to finish loading so the + // promotion result is observable. + await sleep(1500); + const promotion = await page.evaluate(() => { + // fonts.js creates a NEW stylesheet link for the same URL; the original + // preload link keeps rel="preload" and never gets a sheet. Match the + // promoted stylesheet link by the font URL (as the evidence receipt does). + const promoted = [...document.querySelectorAll("link[href*='fonts.googleapis.com/css2']")].find( + (l) => l.rel === "stylesheet" + ); + return { exists: !!promoted, sheetApplied: !!promoted && !!promoted.sheet }; + }); + + const blocking = await page.evaluate(() => + performance + .getEntriesByType("resource") + .filter((r) => r.renderBlockingStatus === "blocking") + .map((r) => r.name) + ); + + return { fcp, css2, promotion, blocking }; +} + +const failures = []; +const rows = []; + +const server = await startServer(); +const port = server.address().port; +const origin = `http://127.0.0.1:${port}`; + +const browser = await chromium.launch({ headless: true }); + +try { + for (const [pageName, file] of PAGES) { + const page = await browser.newPage(); + const result = await measurePage(page, `${origin}/${file}`); + await page.close(); + + const css2Status = result.css2?.renderBlockingStatus ?? null; + const waitedForCss2 = + result.fcp !== null && result.css2 !== null ? result.fcp >= Math.round(result.css2.responseEnd) : null; + + const problems = []; + if (css2Status !== "non-blocking") { + problems.push(`font stylesheet renderBlockingStatus is ${JSON.stringify(css2Status)}, expected "non-blocking"`); + } + if (waitedForCss2 !== false) { + problems.push(`first-contentful-paint waited for the font stylesheet (fcp=${result.fcp}ms, css2 end=${result.css2 ? Math.round(result.css2.responseEnd) : null}ms)`); + } + const disallowedBlocking = result.blocking.filter((name) => { + const u = new URL(name); + return u.origin !== origin || !u.pathname.endsWith(".css"); + }); + if (disallowedBlocking.length) { + problems.push(`render-blocking resources other than same-origin stylesheets: ${disallowedBlocking.join(", ")}`); + } + if (!result.promotion.exists || !result.promotion.sheetApplied) { + problems.push("font preload link was not promoted to a stylesheet under the production CSP (link.sheet is null)"); + } + + rows.push({ pageName, css2Status, fcp: result.fcp, waitedForCss2, disallowedBlocking, sheetApplied: result.promotion.sheetApplied, problems }); + if (problems.length) failures.push(`${pageName}: ${problems.join("; ")}`); + } +} finally { + await browser.close(); + server.close(); +} + +console.log("Render-blocking check (real Chromium, production CSP, css2 delayed " + CSS2_DELAY_MS + "ms):"); +console.table( + rows.map((r) => ({ + page: r.pageName, + "css2 status": r.css2Status, + "fcp (ms)": r.fcp, + "fcp waited for css2": r.waitedForCss2, + "other blocking": r.disallowedBlocking.length ? r.disallowedBlocking.join(",") : "-", + "sheet applied": r.sheetApplied, + result: r.problems.length ? "FAIL" : "PASS" + })) +); + +if (failures.length) { + console.error("\nRender-blocking failures:"); + for (const failure of failures) console.error(`- ${failure}`); + process.exit(1); +} +console.log("All six pages load the font stylesheet without render-blocking under the production CSP."); diff --git a/scripts/check-site.mjs b/scripts/check-site.mjs index a4c148e8..16b2ffb8 100644 --- a/scripts/check-site.mjs +++ b/scripts/check-site.mjs @@ -493,6 +493,31 @@ for (const anchor of [ } } +// The static guards cannot see behavior, so CI must run the browser check +// (scripts/check-render-blocking.mjs) alongside them; these guards keep that +// wiring from drifting: the script, the package.json entry, the CI step, and +// the production CSP the browser check asserts under (mirrored from the +// worker, which the check cannot import). +let renderBlockingScript = ""; +try { + renderBlockingScript = read("scripts/check-render-blocking.mjs"); +} catch { + failures.push("scripts/check-render-blocking.mjs must exist (browser render-blocking guard)."); +} +const ciWorkflow = read(".github/workflows/ci.yml"); +if (!packageJson.includes('"check:render-blocking": "node scripts/check-render-blocking.mjs"')) { + failures.push("package.json must expose the browser render-blocking check as check:render-blocking."); +} +if (!ciWorkflow.includes("npm run check:render-blocking")) { + failures.push("CI must run the browser render-blocking check (npm run check:render-blocking)."); +} +if (renderBlockingScript) { + const workerCsp = worker.match(/Content-Security-Policy":\s*"([^"]+)"/)?.[1] ?? ""; + if (!workerCsp || !renderBlockingScript.includes(workerCsp)) { + failures.push("scripts/check-render-blocking.mjs must mirror the worker's production CSP string."); + } +} + if (existsSync(new URL("../public/pipeline-sprint/index.html", import.meta.url))) { failures.push("Pipeline Sprint page should not remain as a separate stale public asset."); }