From 61f6ce7276cd86c04773489bce274f2b01ec433a Mon Sep 17 00:00:00 2001 From: Carson Seese Date: Sun, 25 Oct 2026 15:35:11 -0400 Subject: [PATCH 1/3] Restore Lighthouse cache hits and confirm timing failures selectively --- .github/workflows/lighthouse.yml | 13 +- docs/tooling.md | 30 +++-- hk.pkl | 4 + knip.jsonc | 2 +- package.json | 4 +- src/components/ui/primitives/ChalkOval.astro | 3 +- src/lib/svg-id.ts | 10 ++ tools/checks/verify-lighthouse.ts | 124 +++++++++++++++++++ tools/ci/lighthouse-runner.ts | 70 +++++++++++ tools/ci/lighthouse-summary.ts | 21 ++++ tools/ci/lighthouse.ts | 9 ++ tools/ci/lighthouserc.json | 11 +- 12 files changed, 282 insertions(+), 19 deletions(-) create mode 100644 src/lib/svg-id.ts create mode 100644 tools/checks/verify-lighthouse.ts create mode 100644 tools/ci/lighthouse-runner.ts create mode 100644 tools/ci/lighthouse.ts diff --git a/.github/workflows/lighthouse.yml b/.github/workflows/lighthouse.yml index fe5233b..bdc2bc4 100644 --- a/.github/workflows/lighthouse.yml +++ b/.github/workflows/lighthouse.yml @@ -21,7 +21,7 @@ jobs: if: >- ${{ github.event_name != 'pull_request' || (!github.event.pull_request.draft && contains(github.event.pull_request.labels.*.name, 'lighthouse')) }} - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 env: LHCI_VERSION: 0.15.1 steps: @@ -31,6 +31,13 @@ jobs: - run: pnpm build - run: pnpm check:events + - name: Identify audit browser + id: browser + run: | + chrome_path="$(command -v google-chrome)" + echo "CHROME_PATH=$chrome_path" >>"$GITHUB_ENV" + echo "version=$("$chrome_path" --version | sha256sum | cut -d ' ' -f1)" >>"$GITHUB_OUTPUT" + # The build is deterministic, so an identical `dist/` measured with the same server, config, # and lhci version has already passed; only the marker file is cached. - name: Look up a previous Lighthouse pass @@ -38,14 +45,14 @@ jobs: uses: actions/cache/restore@v6 with: path: .lighthouseci/passed - key: lighthouse-${{ env.LHCI_VERSION }}-${{ hashFiles('dist/**', '.lighthouseci/events/**', 'tools/ci/**') }} + key: lighthouse-${{ runner.os }}-${{ runner.arch }}-${{ steps.browser.outputs.version }}-${{ env.LHCI_VERSION }}-${{ hashFiles('dist/**', '.lighthouseci/events/**', 'tools/ci/**', '.github/workflows/lighthouse.yml') }} lookup-only: true - name: Lighthouse CI id: lighthouse if: ${{ steps.lighthouse-pass.outputs.cache-hit != 'true' }} run: | - pnpm dlx @lhci/cli@${{ env.LHCI_VERSION }} autorun --config=tools/ci/lighthouserc.json + pnpm --package=@lhci/cli@${{ env.LHCI_VERSION }} dlx -c 'pnpm lighthouse' touch .lighthouseci/passed # Also written to the log so the waterfall behind a failed LCP assertion is readable without diff --git a/docs/tooling.md b/docs/tooling.md index 533bd33..dc87aae 100644 --- a/docs/tooling.md +++ b/docs/tooling.md @@ -37,13 +37,15 @@ does not expose yet; when it does and `@astrojs/check` widens its peer range, bu TypeScript scripts run directly by Node (type stripping, no build step), each behind a `package.json` script: -| Script | Does | Runs in | -| ------------------- | -------------------------------------------------------------- | --------------- | -| `check:meta` | Every built page's head: unique title/description, og:image | CI, after build | -| `check:calendar` | Recurrence, daylight saving, exceptions, and feed merging | hk / CI | -| `check:events` | Isolated future-event build, metadata, and Lighthouse fixtures | CI, after build | -| `assets:og` | Render the OG cards in `src/assets/og/` | by hand | -| `assets:underlines` | Write the hand underline strokes into `src/styles/tokens.css` | by hand | +| Script | Does | Runs in | +| ------------------- | ------------------------------------------------------------------ | ---------------------- | +| `check:meta` | Every built page's head: unique title/description, og:image | CI, after build | +| `check:calendar` | Recurrence, daylight saving, exceptions, and feed merging | hk / CI | +| `lighthouse` | Collect, confirm timing failures, assert budgets, and save reports | CI, via the pinned CLI | +| `check:lighthouse` | Lighthouse retry orchestration and failure handling | `pnpm check`, CI | +| `check:events` | Isolated future-event build, metadata, and Lighthouse fixtures | CI, after build | +| `assets:og` | Render the OG cards in `src/assets/og/` | by hand | +| `assets:underlines` | Write the hand underline strokes into `src/styles/tokens.css` | by hand | Two more under `tools/ci/` have no script because Lighthouse CI runs them: `serve.ts` serves `dist/` over HTTP/2 and TLS for the audit, and `lighthouse-summary.ts` writes the job summary. @@ -186,7 +188,17 @@ and stores it in `.lighthouseci/events/`. The audit server exposes those pages u `/__event-fixture/`; production `dist/` and source dates stay unchanged. The job summary and the log carry the scores per URL, every failed assertion, and for each URL the LCP element, its phases, and the request waterfall; full reports upload as an artifact. A `dist/` byte-identical -to one that already passed skips Lighthouse. +to one that already passed skips Lighthouse. SVG definition IDs use a counter scoped to each +page render so repeated builds preserve the same output bytes; random IDs invalidate this cache. +The cache also includes Chrome’s version, the runner OS/architecture, and the workflow. The +audit uses the installed Chrome on Ubuntu 24.04, with that exact binary passed to Lighthouse. + +`tools/ci/lighthouse.ts` collects one sample per URL. If the only failures are performance score, +Largest Contentful Paint, or Total Blocking Time, it adds exactly two samples for the affected +URLs and checks the median of all three. Other assertions use the worst sample, so accessibility, +SEO, best practices, layout shift, and transfer-size failures stay failures. Collection errors +and mixed timing/non-timing failures do not trigger extra samples. Reports include the initial +assertions, final assertions, and URLs receiving confirmation runs. ### Performance budgets @@ -216,6 +228,6 @@ direct-link fallback during the staging soak. The FRC hero video (about 3 MB) st ```sh pnpm build pnpm check:events -pnpm dlx @lhci/cli@0.15.1 autorun --config=tools/ci/lighthouserc.json +pnpm --package=@lhci/cli@0.15.1 dlx -c 'pnpm lighthouse' node tools/ci/lighthouse-summary.ts ``` diff --git a/hk.pkl b/hk.pkl index 7070335..5ab4d5d 100644 --- a/hk.pkl +++ b/hk.pkl @@ -11,6 +11,10 @@ steps { glob = List("functions/**/*.ts", "tools/checks/verify-calendar.ts") check = "node tools/checks/verify-calendar.ts" } + ["lighthouse"] { + glob = List("tools/ci/**", "tools/checks/verify-lighthouse.ts") + check = "node tools/checks/verify-lighthouse.ts" + } ["prettier"] = (Builtins.prettier) { batch = false } // `--no-warn-ignored` keeps an explicitly passed ignored file from counting against // `--max-warnings 0`. diff --git a/knip.jsonc b/knip.jsonc index cb07cf1..dd214fa 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -7,7 +7,7 @@ "typescript": { "config": ["tsconfig.json", "functions/tsconfig.json", "tools/tsconfig.json"] }, // The CI server shells out to the system openssl, and package scripts run hk, which mise // installs; neither is an npm binary. - "ignoreBinaries": ["hk", "openssl"], + "ignoreBinaries": ["hk", "openssl", "lhci"], // Loaded by name from hk steps, which knip does not read. "ignoreDependencies": ["@astrojs/check", "eslint-formatter-gha"], "ignore": ["tools/lint/anti-slop/shared/**"], diff --git a/package.json b/package.json index c85f991..5bfab85 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,9 @@ "check:calendar": "node tools/checks/verify-calendar.ts", "check:events": "node tools/checks/verify-events.ts", "assets:og": "node tools/assets/og-cards.ts", - "assets:underlines": "node tools/assets/underlines.ts" + "assets:underlines": "node tools/assets/underlines.ts", + "check:lighthouse": "node tools/checks/verify-lighthouse.ts", + "lighthouse": "node tools/ci/lighthouse.ts" }, "dependencies": { "@astrojs/markdown-satteri": "0.4.3", diff --git a/src/components/ui/primitives/ChalkOval.astro b/src/components/ui/primitives/ChalkOval.astro index 0c0e592..fe5bc2b 100644 --- a/src/components/ui/primitives/ChalkOval.astro +++ b/src/components/ui/primitives/ChalkOval.astro @@ -1,11 +1,12 @@ --- import { cn } from "@/lib/cn"; import { type ChalkOvalProps, handLinkShadeClass, handMarkClass, handToneClass } from "@/lib/hand"; +import { nextSvgId } from "@/lib/svg-id"; type Props = ChalkOvalProps; const { variant = 1, tone = "marker", around = "word", class: className } = Astro.props; -const maskId = `oval-${crypto.randomUUID()}`; +const maskId = nextSvgId(Astro.locals); /** The inline margin matches the ring's overhang so neighbours clear it. */ const overhang = { diff --git a/src/lib/svg-id.ts b/src/lib/svg-id.ts new file mode 100644 index 0000000..5866ad5 --- /dev/null +++ b/src/lib/svg-id.ts @@ -0,0 +1,10 @@ +import type { AstroGlobal } from "astro"; + +const sequences = new WeakMap(); + +/** Astro.locals scopes the sequence to a page render, including its nested components. */ +export const nextSvgId = (locals: AstroGlobal["locals"]): string => { + const next = (sequences.get(locals) ?? 0) + 1; + sequences.set(locals, next); + return `svg-${String(next)}`; +}; diff --git a/tools/checks/verify-lighthouse.ts b/tools/checks/verify-lighthouse.ts new file mode 100644 index 0000000..3833d38 --- /dev/null +++ b/tools/checks/verify-lighthouse.ts @@ -0,0 +1,124 @@ +import assert from "node:assert/strict"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { runLighthouse } from "../ci/lighthouse-runner.ts"; + +interface Failure { + auditId: string; + auditProperty?: string; + passed: false; + url: string; +} + +interface Scenario { + failures?: Failure[]; + collectStatus?: number; + confirmationStatus?: number; + finalStatus?: number; + uploadStatus?: number; + omitAssertions?: boolean; +} + +const home = "https://localhost:4321/"; +const timing: Failure = { auditId: "total-blocking-time", passed: false, url: home }; +const directory = mkdtempSync(join(tmpdir(), "scstem-lighthouse-")); + +const scenario = (options: Scenario) => { + const calls: string[][] = []; + let assertions = 0; + let collections = 0; + const status = runLighthouse( + (args) => { + calls.push(args); + if (args[0] === "collect") { + collections += 1; + return collections === 1 ? (options.collectStatus ?? 0) : (options.confirmationStatus ?? 0); + } + if (args[0] === "assert") { + assertions += 1; + const failures = assertions === 1 ? (options.failures ?? []) : []; + if (!options.omitAssertions) { + writeFileSync(join(directory, "assertion-results.json"), JSON.stringify(failures)); + } + return assertions === 1 ? Number(failures.length > 0) : (options.finalStatus ?? 0); + } + assert.equal(args[0], "upload"); + return options.uploadStatus ?? 0; + }, + "tools/ci/lighthouserc.json", + directory, + ); + return { calls, status }; +}; + +try { + const passed = scenario({}); + assert.equal(passed.status, 0); + assert.deepEqual( + passed.calls.map((call) => call[0]), + ["collect", "assert", "upload"], + ); + + const confirmed = scenario({ + failures: [timing, { ...timing, auditId: "largest-contentful-paint" }], + }); + assert.equal(confirmed.status, 0); + assert.deepEqual(confirmed.calls[2], [ + "collect", + "--config=tools/ci/lighthouserc.json", + "--additive", + "--numberOfRuns=2", + `--url=${home}`, + ]); + assert.deepEqual( + confirmed.calls.map((call) => call[0]), + ["collect", "assert", "collect", "assert", "upload"], + ); + assert.equal( + readFileSync(join(directory, "reports/initial-assertion-results.json"), "utf8"), + JSON.stringify([timing, { ...timing, auditId: "largest-contentful-paint" }]), + ); + + const multiple = scenario({ failures: [timing, { ...timing, url: `${home}programs/frc` }] }); + assert.deepEqual( + multiple.calls[2]?.filter((arg) => arg.startsWith("--url=")), + [`--url=${home}`, `--url=${home}programs/frc`], + ); + + for (const failure of [ + { ...timing, auditId: "categories", auditProperty: "accessibility" }, + { ...timing, auditId: "categories", auditProperty: "seo" }, + { ...timing, auditId: "categories", auditProperty: "best-practices" }, + { ...timing, auditId: "resource-summary", auditProperty: "script:size" }, + { ...timing, auditId: "cumulative-layout-shift" }, + ]) { + const hardFailure = scenario({ failures: [timing, failure] }); + assert.equal(hardFailure.status, 1); + assert.equal(hardFailure.calls.filter((call) => call[0] === "collect").length, 1); + } + assert.equal( + scenario({ failures: [{ ...timing, auditId: "categories", auditProperty: "performance" }] }) + .calls.length, + 5, + ); + assert.equal(scenario({ failures: [timing], finalStatus: 1 }).status, 1); + assert.equal(scenario({ failures: [timing], confirmationStatus: 2 }).status, 2); + assert.equal(scenario({ uploadStatus: 3 }).status, 3); + assert.equal(scenario({ omitAssertions: true }).status, 1); + const collectionFailure = scenario({ collectStatus: 2 }); + assert.equal(collectionFailure.status, 2); + assert.deepEqual( + collectionFailure.calls.map((call) => call[0]), + ["collect", "upload"], + ); + assert.ok( + !existsSync(join(directory, "reports/initial-assertion-results.json")), + "stale assertions are removed", + ); + + console.log("Lighthouse fast path, bounded confirmation, hard failures, and artifacts verified"); +} finally { + rmSync(directory, { recursive: true, force: true }); +} diff --git a/tools/ci/lighthouse-runner.ts b/tools/ci/lighthouse-runner.ts new file mode 100644 index 0000000..1195c86 --- /dev/null +++ b/tools/ci/lighthouse-runner.ts @@ -0,0 +1,70 @@ +import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +interface Assertion { + auditId: string; + auditProperty?: string; + level?: string; + passed: boolean; + url: string; +} + +type LighthouseCommand = (args: string[]) => number; + +const timingFailure = (assertion: Assertion): boolean => + assertion.auditId === "largest-contentful-paint" || + assertion.auditId === "total-blocking-time" || + (assertion.auditId === "categories" && assertion.auditProperty === "performance"); + +export const runLighthouse = ( + execute: LighthouseCommand, + config = "tools/ci/lighthouserc.json", + directory = ".lighthouseci", +): number => { + const assertionsPath = join(directory, "assertion-results.json"); + const initialPath = join(directory, "initial-assertion-results.json"); + const reports = join(directory, "reports"); + mkdirSync(directory, { recursive: true }); + for (const path of [assertionsPath, initialPath, join(directory, "passed"), reports]) { + rmSync(path, { force: true, recursive: true }); + } + + const run = (command: string, ...args: string[]): number => + execute([command, `--config=${config}`, ...args]); + let retryUrls: string[] = []; + + const audit = (): number => { + const collected = run("collect"); + if (collected !== 0) return collected; + const initial = run("assert"); + if (!existsSync(assertionsPath)) return initial || 1; + copyFileSync(assertionsPath, initialPath); + if (initial === 0) return 0; + + // SAFETY: Lighthouse CI writes this assertion schema; malformed output fails the job. + const assertions = JSON.parse(readFileSync(assertionsPath, "utf8")) as Assertion[]; + const failures = assertions.filter( + (assertion) => !assertion.passed && assertion.level !== "warn", + ); + if (failures.length === 0 || !failures.every(timingFailure)) return initial; + retryUrls = [...new Set(failures.map((assertion) => assertion.url))]; + console.log(`Confirming timing failures with two additional runs: ${retryUrls.join(", ")}`); + const confirmed = run( + "collect", + "--additive", + "--numberOfRuns=2", + ...retryUrls.map((url) => `--url=${url}`), + ); + return confirmed === 0 ? run("assert") : confirmed; + }; + + const result = audit(); + const uploaded = run("upload"); + mkdirSync(reports, { recursive: true }); + for (const name of ["initial-assertion-results.json", "assertion-results.json"]) { + const path = join(directory, name); + if (existsSync(path)) copyFileSync(path, join(reports, name)); + } + writeFileSync(join(reports, "retry-urls.json"), JSON.stringify(retryUrls)); + return result || uploaded; +}; diff --git a/tools/ci/lighthouse-summary.ts b/tools/ci/lighthouse-summary.ts index 4644fee..25ae12e 100644 --- a/tools/ci/lighthouse-summary.ts +++ b/tools/ci/lighthouse-summary.ts @@ -62,6 +62,8 @@ const report = (path: string): Report => const runs = read(".lighthouseci/reports/manifest.json"); const assertions = read(".lighthouseci/assertion-results.json"); +const initialAssertions = read(".lighthouseci/initial-assertion-results.json"); +const retryUrls = read(".lighthouseci/reports/retry-urls.json"); const path = (url: string): string => new URL(url).pathname; const score = (value: number): string => { @@ -84,6 +86,25 @@ if (representative.length === 0) { } } +if (retryUrls.length > 0) { + lines.push( + "", + "### Timing confirmation", + "", + `Two additional samples collected for ${retryUrls.map((url) => `\`${path(url)}\``).join(", ")}.`, + "Timing budgets use all three samples' median; other budgets use the worst sample.", + "", + "| URL | Initial failure | Expected | Actual |", + "|---|---|---|---|", + ); + for (const a of initialAssertions.filter((assertion) => !assertion.passed)) { + const audit = a.auditProperty === undefined ? a.auditId : `${a.auditId}.${a.auditProperty}`; + lines.push( + `| \`${path(a.url)}\` | \`${audit}\` | ${a.operator} ${String(a.expected)} | ${String(a.actual)} |`, + ); + } +} + const failed = assertions.filter((a) => !a.passed); if (failed.length > 0) { lines.push("", `### ${String(failed.length)} failed assertion(s)`, ""); diff --git a/tools/ci/lighthouse.ts b/tools/ci/lighthouse.ts new file mode 100644 index 0000000..84b3fea --- /dev/null +++ b/tools/ci/lighthouse.ts @@ -0,0 +1,9 @@ +import { spawnSync } from "node:child_process"; + +import { runLighthouse } from "./lighthouse-runner.ts"; + +process.exitCode = runLighthouse((args) => { + const result = spawnSync("lhci", args, { stdio: "inherit" }); + if (result.error) console.error(result.error.message); + return result.status ?? 1; +}); diff --git a/tools/ci/lighthouserc.json b/tools/ci/lighthouserc.json index 8926a94..66cf6d7 100644 --- a/tools/ci/lighthouserc.json +++ b/tools/ci/lighthouserc.json @@ -17,15 +17,18 @@ ] }, "assert": { - "aggregationMethod": "median", + "aggregationMethod": "pessimistic", "assertions": { - "categories:performance": ["error", { "minScore": 0.95 }], + "categories:performance": ["error", { "minScore": 0.95, "aggregationMethod": "median" }], "categories:accessibility": ["error", { "minScore": 1 }], "categories:seo": ["error", { "minScore": 1 }], "categories:best-practices": ["error", { "minScore": 0.95 }], - "largest-contentful-paint": ["error", { "maxNumericValue": 2000 }], + "largest-contentful-paint": [ + "error", + { "maxNumericValue": 2000, "aggregationMethod": "median" } + ], "cumulative-layout-shift": ["error", { "maxNumericValue": 0.05 }], - "total-blocking-time": ["error", { "maxNumericValue": 100 }], + "total-blocking-time": ["error", { "maxNumericValue": 100, "aggregationMethod": "median" }], "resource-summary:script:size": ["error", { "maxNumericValue": 35840 }], "resource-summary:total:size": ["error", { "maxNumericValue": 1048576 }] } From 12c091797e88d644b521bc178096a21279873396 Mon Sep 17 00:00:00 2001 From: Carson Seese Date: Sun, 25 Oct 2026 16:50:02 -0400 Subject: [PATCH 2/3] Keep Lighthouse on ubuntu-latest --- .github/workflows/lighthouse.yml | 2 +- docs/tooling.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/lighthouse.yml b/.github/workflows/lighthouse.yml index bdc2bc4..d0e6d2e 100644 --- a/.github/workflows/lighthouse.yml +++ b/.github/workflows/lighthouse.yml @@ -21,7 +21,7 @@ jobs: if: >- ${{ github.event_name != 'pull_request' || (!github.event.pull_request.draft && contains(github.event.pull_request.labels.*.name, 'lighthouse')) }} - runs-on: ubuntu-24.04 + runs-on: ubuntu-latest env: LHCI_VERSION: 0.15.1 steps: diff --git a/docs/tooling.md b/docs/tooling.md index dc87aae..8e2f080 100644 --- a/docs/tooling.md +++ b/docs/tooling.md @@ -191,7 +191,7 @@ phases, and the request waterfall; full reports upload as an artifact. A `dist/` to one that already passed skips Lighthouse. SVG definition IDs use a counter scoped to each page render so repeated builds preserve the same output bytes; random IDs invalidate this cache. The cache also includes Chrome’s version, the runner OS/architecture, and the workflow. The -audit uses the installed Chrome on Ubuntu 24.04, with that exact binary passed to Lighthouse. +audit uses the installed Chrome on `ubuntu-latest`, with that exact binary passed to Lighthouse. `tools/ci/lighthouse.ts` collects one sample per URL. If the only failures are performance score, Largest Contentful Paint, or Total Blocking Time, it adds exactly two samples for the affected From aad090148e88a13375d47675f8d6425da49d019a Mon Sep 17 00:00:00 2001 From: Carson Seese Date: Sun, 25 Oct 2026 16:53:56 -0400 Subject: [PATCH 3/3] Focus CI fixes on deterministic Lighthouse caching --- .github/workflows/lighthouse.yml | 2 +- docs/tooling.md | 25 ++---- hk.pkl | 4 - knip.jsonc | 2 +- package.json | 4 +- tools/checks/verify-lighthouse.ts | 124 ------------------------------ tools/ci/lighthouse-runner.ts | 70 ----------------- tools/ci/lighthouse-summary.ts | 21 ----- tools/ci/lighthouse.ts | 9 --- tools/ci/lighthouserc.json | 11 +-- 10 files changed, 15 insertions(+), 257 deletions(-) delete mode 100644 tools/checks/verify-lighthouse.ts delete mode 100644 tools/ci/lighthouse-runner.ts delete mode 100644 tools/ci/lighthouse.ts diff --git a/.github/workflows/lighthouse.yml b/.github/workflows/lighthouse.yml index d0e6d2e..6b40e82 100644 --- a/.github/workflows/lighthouse.yml +++ b/.github/workflows/lighthouse.yml @@ -52,7 +52,7 @@ jobs: id: lighthouse if: ${{ steps.lighthouse-pass.outputs.cache-hit != 'true' }} run: | - pnpm --package=@lhci/cli@${{ env.LHCI_VERSION }} dlx -c 'pnpm lighthouse' + pnpm dlx @lhci/cli@${{ env.LHCI_VERSION }} autorun --config=tools/ci/lighthouserc.json touch .lighthouseci/passed # Also written to the log so the waterfall behind a failed LCP assertion is readable without diff --git a/docs/tooling.md b/docs/tooling.md index 8e2f080..295f5f9 100644 --- a/docs/tooling.md +++ b/docs/tooling.md @@ -37,15 +37,13 @@ does not expose yet; when it does and `@astrojs/check` widens its peer range, bu TypeScript scripts run directly by Node (type stripping, no build step), each behind a `package.json` script: -| Script | Does | Runs in | -| ------------------- | ------------------------------------------------------------------ | ---------------------- | -| `check:meta` | Every built page's head: unique title/description, og:image | CI, after build | -| `check:calendar` | Recurrence, daylight saving, exceptions, and feed merging | hk / CI | -| `lighthouse` | Collect, confirm timing failures, assert budgets, and save reports | CI, via the pinned CLI | -| `check:lighthouse` | Lighthouse retry orchestration and failure handling | `pnpm check`, CI | -| `check:events` | Isolated future-event build, metadata, and Lighthouse fixtures | CI, after build | -| `assets:og` | Render the OG cards in `src/assets/og/` | by hand | -| `assets:underlines` | Write the hand underline strokes into `src/styles/tokens.css` | by hand | +| Script | Does | Runs in | +| ------------------- | -------------------------------------------------------------- | --------------- | +| `check:meta` | Every built page's head: unique title/description, og:image | CI, after build | +| `check:calendar` | Recurrence, daylight saving, exceptions, and feed merging | hk / CI | +| `check:events` | Isolated future-event build, metadata, and Lighthouse fixtures | CI, after build | +| `assets:og` | Render the OG cards in `src/assets/og/` | by hand | +| `assets:underlines` | Write the hand underline strokes into `src/styles/tokens.css` | by hand | Two more under `tools/ci/` have no script because Lighthouse CI runs them: `serve.ts` serves `dist/` over HTTP/2 and TLS for the audit, and `lighthouse-summary.ts` writes the job summary. @@ -193,13 +191,6 @@ page render so repeated builds preserve the same output bytes; random IDs invali The cache also includes Chrome’s version, the runner OS/architecture, and the workflow. The audit uses the installed Chrome on `ubuntu-latest`, with that exact binary passed to Lighthouse. -`tools/ci/lighthouse.ts` collects one sample per URL. If the only failures are performance score, -Largest Contentful Paint, or Total Blocking Time, it adds exactly two samples for the affected -URLs and checks the median of all three. Other assertions use the worst sample, so accessibility, -SEO, best practices, layout shift, and transfer-size failures stay failures. Collection errors -and mixed timing/non-timing failures do not trigger extra samples. Reports include the initial -assertions, final assertions, and URLs receiving confirmation runs. - ### Performance budgets `lighthouserc.json`, every assertion an error: @@ -228,6 +219,6 @@ direct-link fallback during the staging soak. The FRC hero video (about 3 MB) st ```sh pnpm build pnpm check:events -pnpm --package=@lhci/cli@0.15.1 dlx -c 'pnpm lighthouse' +pnpm dlx @lhci/cli@0.15.1 autorun --config=tools/ci/lighthouserc.json node tools/ci/lighthouse-summary.ts ``` diff --git a/hk.pkl b/hk.pkl index 5ab4d5d..7070335 100644 --- a/hk.pkl +++ b/hk.pkl @@ -11,10 +11,6 @@ steps { glob = List("functions/**/*.ts", "tools/checks/verify-calendar.ts") check = "node tools/checks/verify-calendar.ts" } - ["lighthouse"] { - glob = List("tools/ci/**", "tools/checks/verify-lighthouse.ts") - check = "node tools/checks/verify-lighthouse.ts" - } ["prettier"] = (Builtins.prettier) { batch = false } // `--no-warn-ignored` keeps an explicitly passed ignored file from counting against // `--max-warnings 0`. diff --git a/knip.jsonc b/knip.jsonc index dd214fa..cb07cf1 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -7,7 +7,7 @@ "typescript": { "config": ["tsconfig.json", "functions/tsconfig.json", "tools/tsconfig.json"] }, // The CI server shells out to the system openssl, and package scripts run hk, which mise // installs; neither is an npm binary. - "ignoreBinaries": ["hk", "openssl", "lhci"], + "ignoreBinaries": ["hk", "openssl"], // Loaded by name from hk steps, which knip does not read. "ignoreDependencies": ["@astrojs/check", "eslint-formatter-gha"], "ignore": ["tools/lint/anti-slop/shared/**"], diff --git a/package.json b/package.json index 5bfab85..c85f991 100644 --- a/package.json +++ b/package.json @@ -13,9 +13,7 @@ "check:calendar": "node tools/checks/verify-calendar.ts", "check:events": "node tools/checks/verify-events.ts", "assets:og": "node tools/assets/og-cards.ts", - "assets:underlines": "node tools/assets/underlines.ts", - "check:lighthouse": "node tools/checks/verify-lighthouse.ts", - "lighthouse": "node tools/ci/lighthouse.ts" + "assets:underlines": "node tools/assets/underlines.ts" }, "dependencies": { "@astrojs/markdown-satteri": "0.4.3", diff --git a/tools/checks/verify-lighthouse.ts b/tools/checks/verify-lighthouse.ts deleted file mode 100644 index 3833d38..0000000 --- a/tools/checks/verify-lighthouse.ts +++ /dev/null @@ -1,124 +0,0 @@ -import assert from "node:assert/strict"; -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -import { runLighthouse } from "../ci/lighthouse-runner.ts"; - -interface Failure { - auditId: string; - auditProperty?: string; - passed: false; - url: string; -} - -interface Scenario { - failures?: Failure[]; - collectStatus?: number; - confirmationStatus?: number; - finalStatus?: number; - uploadStatus?: number; - omitAssertions?: boolean; -} - -const home = "https://localhost:4321/"; -const timing: Failure = { auditId: "total-blocking-time", passed: false, url: home }; -const directory = mkdtempSync(join(tmpdir(), "scstem-lighthouse-")); - -const scenario = (options: Scenario) => { - const calls: string[][] = []; - let assertions = 0; - let collections = 0; - const status = runLighthouse( - (args) => { - calls.push(args); - if (args[0] === "collect") { - collections += 1; - return collections === 1 ? (options.collectStatus ?? 0) : (options.confirmationStatus ?? 0); - } - if (args[0] === "assert") { - assertions += 1; - const failures = assertions === 1 ? (options.failures ?? []) : []; - if (!options.omitAssertions) { - writeFileSync(join(directory, "assertion-results.json"), JSON.stringify(failures)); - } - return assertions === 1 ? Number(failures.length > 0) : (options.finalStatus ?? 0); - } - assert.equal(args[0], "upload"); - return options.uploadStatus ?? 0; - }, - "tools/ci/lighthouserc.json", - directory, - ); - return { calls, status }; -}; - -try { - const passed = scenario({}); - assert.equal(passed.status, 0); - assert.deepEqual( - passed.calls.map((call) => call[0]), - ["collect", "assert", "upload"], - ); - - const confirmed = scenario({ - failures: [timing, { ...timing, auditId: "largest-contentful-paint" }], - }); - assert.equal(confirmed.status, 0); - assert.deepEqual(confirmed.calls[2], [ - "collect", - "--config=tools/ci/lighthouserc.json", - "--additive", - "--numberOfRuns=2", - `--url=${home}`, - ]); - assert.deepEqual( - confirmed.calls.map((call) => call[0]), - ["collect", "assert", "collect", "assert", "upload"], - ); - assert.equal( - readFileSync(join(directory, "reports/initial-assertion-results.json"), "utf8"), - JSON.stringify([timing, { ...timing, auditId: "largest-contentful-paint" }]), - ); - - const multiple = scenario({ failures: [timing, { ...timing, url: `${home}programs/frc` }] }); - assert.deepEqual( - multiple.calls[2]?.filter((arg) => arg.startsWith("--url=")), - [`--url=${home}`, `--url=${home}programs/frc`], - ); - - for (const failure of [ - { ...timing, auditId: "categories", auditProperty: "accessibility" }, - { ...timing, auditId: "categories", auditProperty: "seo" }, - { ...timing, auditId: "categories", auditProperty: "best-practices" }, - { ...timing, auditId: "resource-summary", auditProperty: "script:size" }, - { ...timing, auditId: "cumulative-layout-shift" }, - ]) { - const hardFailure = scenario({ failures: [timing, failure] }); - assert.equal(hardFailure.status, 1); - assert.equal(hardFailure.calls.filter((call) => call[0] === "collect").length, 1); - } - assert.equal( - scenario({ failures: [{ ...timing, auditId: "categories", auditProperty: "performance" }] }) - .calls.length, - 5, - ); - assert.equal(scenario({ failures: [timing], finalStatus: 1 }).status, 1); - assert.equal(scenario({ failures: [timing], confirmationStatus: 2 }).status, 2); - assert.equal(scenario({ uploadStatus: 3 }).status, 3); - assert.equal(scenario({ omitAssertions: true }).status, 1); - const collectionFailure = scenario({ collectStatus: 2 }); - assert.equal(collectionFailure.status, 2); - assert.deepEqual( - collectionFailure.calls.map((call) => call[0]), - ["collect", "upload"], - ); - assert.ok( - !existsSync(join(directory, "reports/initial-assertion-results.json")), - "stale assertions are removed", - ); - - console.log("Lighthouse fast path, bounded confirmation, hard failures, and artifacts verified"); -} finally { - rmSync(directory, { recursive: true, force: true }); -} diff --git a/tools/ci/lighthouse-runner.ts b/tools/ci/lighthouse-runner.ts deleted file mode 100644 index 1195c86..0000000 --- a/tools/ci/lighthouse-runner.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; - -interface Assertion { - auditId: string; - auditProperty?: string; - level?: string; - passed: boolean; - url: string; -} - -type LighthouseCommand = (args: string[]) => number; - -const timingFailure = (assertion: Assertion): boolean => - assertion.auditId === "largest-contentful-paint" || - assertion.auditId === "total-blocking-time" || - (assertion.auditId === "categories" && assertion.auditProperty === "performance"); - -export const runLighthouse = ( - execute: LighthouseCommand, - config = "tools/ci/lighthouserc.json", - directory = ".lighthouseci", -): number => { - const assertionsPath = join(directory, "assertion-results.json"); - const initialPath = join(directory, "initial-assertion-results.json"); - const reports = join(directory, "reports"); - mkdirSync(directory, { recursive: true }); - for (const path of [assertionsPath, initialPath, join(directory, "passed"), reports]) { - rmSync(path, { force: true, recursive: true }); - } - - const run = (command: string, ...args: string[]): number => - execute([command, `--config=${config}`, ...args]); - let retryUrls: string[] = []; - - const audit = (): number => { - const collected = run("collect"); - if (collected !== 0) return collected; - const initial = run("assert"); - if (!existsSync(assertionsPath)) return initial || 1; - copyFileSync(assertionsPath, initialPath); - if (initial === 0) return 0; - - // SAFETY: Lighthouse CI writes this assertion schema; malformed output fails the job. - const assertions = JSON.parse(readFileSync(assertionsPath, "utf8")) as Assertion[]; - const failures = assertions.filter( - (assertion) => !assertion.passed && assertion.level !== "warn", - ); - if (failures.length === 0 || !failures.every(timingFailure)) return initial; - retryUrls = [...new Set(failures.map((assertion) => assertion.url))]; - console.log(`Confirming timing failures with two additional runs: ${retryUrls.join(", ")}`); - const confirmed = run( - "collect", - "--additive", - "--numberOfRuns=2", - ...retryUrls.map((url) => `--url=${url}`), - ); - return confirmed === 0 ? run("assert") : confirmed; - }; - - const result = audit(); - const uploaded = run("upload"); - mkdirSync(reports, { recursive: true }); - for (const name of ["initial-assertion-results.json", "assertion-results.json"]) { - const path = join(directory, name); - if (existsSync(path)) copyFileSync(path, join(reports, name)); - } - writeFileSync(join(reports, "retry-urls.json"), JSON.stringify(retryUrls)); - return result || uploaded; -}; diff --git a/tools/ci/lighthouse-summary.ts b/tools/ci/lighthouse-summary.ts index 25ae12e..4644fee 100644 --- a/tools/ci/lighthouse-summary.ts +++ b/tools/ci/lighthouse-summary.ts @@ -62,8 +62,6 @@ const report = (path: string): Report => const runs = read(".lighthouseci/reports/manifest.json"); const assertions = read(".lighthouseci/assertion-results.json"); -const initialAssertions = read(".lighthouseci/initial-assertion-results.json"); -const retryUrls = read(".lighthouseci/reports/retry-urls.json"); const path = (url: string): string => new URL(url).pathname; const score = (value: number): string => { @@ -86,25 +84,6 @@ if (representative.length === 0) { } } -if (retryUrls.length > 0) { - lines.push( - "", - "### Timing confirmation", - "", - `Two additional samples collected for ${retryUrls.map((url) => `\`${path(url)}\``).join(", ")}.`, - "Timing budgets use all three samples' median; other budgets use the worst sample.", - "", - "| URL | Initial failure | Expected | Actual |", - "|---|---|---|---|", - ); - for (const a of initialAssertions.filter((assertion) => !assertion.passed)) { - const audit = a.auditProperty === undefined ? a.auditId : `${a.auditId}.${a.auditProperty}`; - lines.push( - `| \`${path(a.url)}\` | \`${audit}\` | ${a.operator} ${String(a.expected)} | ${String(a.actual)} |`, - ); - } -} - const failed = assertions.filter((a) => !a.passed); if (failed.length > 0) { lines.push("", `### ${String(failed.length)} failed assertion(s)`, ""); diff --git a/tools/ci/lighthouse.ts b/tools/ci/lighthouse.ts deleted file mode 100644 index 84b3fea..0000000 --- a/tools/ci/lighthouse.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { spawnSync } from "node:child_process"; - -import { runLighthouse } from "./lighthouse-runner.ts"; - -process.exitCode = runLighthouse((args) => { - const result = spawnSync("lhci", args, { stdio: "inherit" }); - if (result.error) console.error(result.error.message); - return result.status ?? 1; -}); diff --git a/tools/ci/lighthouserc.json b/tools/ci/lighthouserc.json index 66cf6d7..8926a94 100644 --- a/tools/ci/lighthouserc.json +++ b/tools/ci/lighthouserc.json @@ -17,18 +17,15 @@ ] }, "assert": { - "aggregationMethod": "pessimistic", + "aggregationMethod": "median", "assertions": { - "categories:performance": ["error", { "minScore": 0.95, "aggregationMethod": "median" }], + "categories:performance": ["error", { "minScore": 0.95 }], "categories:accessibility": ["error", { "minScore": 1 }], "categories:seo": ["error", { "minScore": 1 }], "categories:best-practices": ["error", { "minScore": 0.95 }], - "largest-contentful-paint": [ - "error", - { "maxNumericValue": 2000, "aggregationMethod": "median" } - ], + "largest-contentful-paint": ["error", { "maxNumericValue": 2000 }], "cumulative-layout-shift": ["error", { "maxNumericValue": 0.05 }], - "total-blocking-time": ["error", { "maxNumericValue": 100, "aggregationMethod": "median" }], + "total-blocking-time": ["error", { "maxNumericValue": 100 }], "resource-summary:script:size": ["error", { "maxNumericValue": 35840 }], "resource-summary:total:size": ["error", { "maxNumericValue": 1048576 }] }