design-spine 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -14,7 +14,7 @@ Most UI gets built and checked in one state: the happy path, at desktop width, i
14
14
 
15
15
  ## Quick start
16
16
 
17
- Requires [Bun](https://bun.sh) 1.3+ and a running copy of your app.
17
+ Requires a running copy of your app. The CLI runs on [Bun](https://bun.sh), which the npm install bundles, so you do not install it yourself. A Bun 1.3+ already on your PATH is used first.
18
18
 
19
19
  ```bash
20
20
  npm install -D design-spine
@@ -72,12 +72,30 @@ In CI, add the action after the step that starts your app, and commit `design-sp
72
72
 
73
73
  ```yaml
74
74
  - run: npm run build && (npm start &) && npx wait-on http://localhost:3000
75
- - uses: forbiddenlink/design-spine@main
75
+ - uses: forbiddenlink/design-spine@v0.2.1
76
76
  with:
77
77
  base-url: http://localhost:3000
78
78
  ```
79
79
 
80
- The job fails when any screen changes from its approved baseline, and uploads the run as an artifact. To accept a change, download the artifact into `.design-spine/runs/`, run `spine review --run <id>`, approve, then `spine approve --run <id>` and commit the new baselines. Fonts render differently on macOS and Linux, so approve baselines from a CI (Linux) run.
80
+ The job fails when any screen changes from its approved baseline. The action writes a pass/fail summary with a table of changed tiles to the job summary, and uploads two artifacts: `design-spine-run` (the captures) and `design-spine-report` (a single offline HTML file with the screenshots). Reviewers open the report instead of unzipping the run.
81
+
82
+ To also post that summary on the pull request, set `comment: "true"` and grant the job write access to pull requests. The action keeps one comment, marked `<!-- design-spine -->`, and edits it on each push:
83
+
84
+ ```yaml
85
+ permissions:
86
+ contents: read
87
+ pull-requests: write
88
+ steps:
89
+ - uses: forbiddenlink/design-spine@v0.2.1
90
+ with:
91
+ comment: "true"
92
+ ```
93
+
94
+ Comments are skipped outside `pull_request` events. On pull requests from forks the token is read-only, so the comment step fails without failing the job. The job summary still appears.
95
+
96
+ To accept a change, download the artifact into `.design-spine/runs/`, run `spine review --run <id>`, approve, then `spine approve --run <id>` and commit the new baselines. Fonts render differently on macOS and Linux. Capture with `spine capture --docker` and approve those baselines; the action also captures in that image by default, so the two match pixel for pixel. The first run builds a local image, and amd64 emulation on Apple silicon is slower. See [Matching CI baselines](docs/USAGE.md#matching-ci-baselines).
97
+
98
+ Upgrading from v0.1.0: that version captured with the runner's own Chromium and fonts. Baselines approved from those runs differ from docker captures, so either approve new baselines from a `--docker` capture, or keep the old behavior with `docker: "false"`.
81
99
 
82
100
  ## Commands
83
101
 
@@ -93,6 +111,7 @@ The job fails when any screen changes from its approved baseline, and uploads th
93
111
  | `report` | Self-contained HTML record of a review |
94
112
  | `lint` | Ratchet on arbitrary Tailwind values such as `w-[37px]` |
95
113
  | `status` | Writes `.design-spine/status.json` for dashboards |
114
+ | `mcp` | Read-only MCP server over stdio for agents; see [docs/USAGE.md](docs/USAGE.md#use-from-an-mcp-client) |
96
115
 
97
116
  Full reference: [docs/USAGE.md](docs/USAGE.md).
98
117
 
@@ -100,18 +119,19 @@ Full reference: [docs/USAGE.md](docs/USAGE.md).
100
119
 
101
120
  | | Design Spine | Chromatic, Percy, Argos | Playwright `toHaveScreenshot` |
102
121
  |---|---|---|---|
103
- | Runs locally, no upload | Yes | No | Yes |
104
- | Plans the route x state matrix from your app | Yes | No | No |
105
- | Human approval ledger per screenshot | Yes | Yes, in the cloud | No |
106
- | Coverage gaps shown as unfinished | Yes | No | No |
107
- | Needs Storybook | No | Chromatic: mostly | No |
108
- | Pricing | Free | Per snapshot | Free |
122
+ | Screenshots stay on your machine | Yes | No, uploaded to a hosted service | Yes |
123
+ | Plans the route x state matrix from your app | Yes | No, captures the stories or tests you write | No |
124
+ | Human approval per screenshot | Yes, local review UI | Yes, in the vendor's web app | No, `--update-snapshots` accepts all at once |
125
+ | Needs an account | No | Yes | No |
126
+ | Pricing | Free, MIT | Free tier, then billed per screenshot | Free |
127
+
128
+ Checked 2026-10-18 against each vendor's docs and pricing pages. Argos is open source; Chromatic works with Storybook, Vitest, Playwright and Cypress.
109
129
 
110
130
  ## Limits
111
131
 
112
132
  - Next.js App Router only for `plan`. Other apps can write the manifest by hand.
113
133
  - Chromium only. Tested on macOS, and the test suite runs on Linux in CI. Windows is not verified.
114
- - Requires Bun to run the CLI.
134
+ - The CLI runs on Bun. The npm package bundles Bun 1.3.13 for macOS, Linux and Windows on x64 and arm64; other platforms need Bun 1.3+ installed. `--docker` also needs Docker and downloads a 2.6 GB image once.
115
135
 
116
136
  ## License
117
137
 
@@ -0,0 +1,23 @@
1
+ export const MIN_MAJOR: number
2
+ export const MIN_MINOR: number
3
+ export function satisfiesMinimum(versionText: string): boolean
4
+ export function platformPackage(target: {
5
+ platform: string
6
+ arch: string
7
+ musl?: boolean
8
+ }): string | undefined
9
+ export function bundledBunPath(options?: {
10
+ platform?: string
11
+ arch?: string
12
+ musl?: boolean
13
+ resolve?: (id: string) => string
14
+ exists?: (path: string) => boolean
15
+ }): string | undefined
16
+ export function resolveBun(options?: {
17
+ run?: (
18
+ command: string,
19
+ args: string[],
20
+ options: { encoding: 'utf8'; timeout: number },
21
+ ) => { error?: Error; status: number | null; stdout?: string | null }
22
+ bundled?: () => string | undefined
23
+ }): string | undefined
@@ -0,0 +1,66 @@
1
+ // Finds a Bun >= 1.3: one on PATH first, else the binary from the bundled @oven/bun-<platform> package.
2
+ import { spawnSync } from 'node:child_process'
3
+ import { existsSync } from 'node:fs'
4
+ import { createRequire } from 'node:module'
5
+ import { dirname, join } from 'node:path'
6
+
7
+ export const MIN_MAJOR = 1
8
+ export const MIN_MINOR = 3
9
+
10
+ export function satisfiesMinimum(versionText) {
11
+ const [major = 0, minor = 0] = String(versionText).trim().split('.').map(Number)
12
+ return major > MIN_MAJOR || (major === MIN_MAJOR && minor >= MIN_MINOR)
13
+ }
14
+
15
+ // Maps node's platform/arch to the @oven/bun-* package that holds the real binary.
16
+ export function platformPackage({ platform, arch, musl = false }) {
17
+ const cpu = { x64: 'x64', arm64: 'aarch64' }[arch]
18
+ const os = { darwin: 'darwin', linux: 'linux', win32: 'windows' }[platform]
19
+ if (!cpu || !os) return undefined
20
+ return `@oven/bun-${os}-${cpu}${os === 'linux' && musl ? '-musl' : ''}`
21
+ }
22
+
23
+ function isMusl() {
24
+ if (process.platform !== 'linux') return false
25
+ try {
26
+ return !process.report.getReport().header.glibcVersionRuntime
27
+ } catch {
28
+ return false
29
+ }
30
+ }
31
+
32
+ // Oven's `bun` package ships a stub bin until its postinstall runs, which pnpm 12 and
33
+ // --ignore-scripts skip, and pnpm 12 fails the whole install over that script. So we depend on
34
+ // the script-free platform packages directly; they carry the real binary.
35
+ export function bundledBunPath({
36
+ platform = process.platform,
37
+ arch = process.arch,
38
+ musl = isMusl(),
39
+ resolve = createRequire(import.meta.url).resolve,
40
+ exists = existsSync,
41
+ } = {}) {
42
+ const name = platformPackage({ platform, arch, musl })
43
+ if (!name) return undefined
44
+ try {
45
+ const pkgDir = dirname(resolve(`${name}/package.json`))
46
+ const file = join(pkgDir, 'bin', platform === 'win32' ? 'bun.exe' : 'bun')
47
+ return exists(file) ? file : undefined
48
+ } catch {
49
+ return undefined
50
+ }
51
+ }
52
+
53
+ function bunVersion(command, run) {
54
+ const result = run(command, ['--version'], { encoding: 'utf8', timeout: 10000 })
55
+ if (result.error || result.status !== 0) return undefined
56
+ return result.stdout ?? ''
57
+ }
58
+
59
+ // Returns the command to run Bun with, or undefined when no usable Bun exists.
60
+ export function resolveBun({ run = spawnSync, bundled = bundledBunPath } = {}) {
61
+ const onPath = bunVersion('bun', run)
62
+ if (onPath !== undefined && satisfiesMinimum(onPath)) return 'bun'
63
+ const file = bundled()
64
+ if (file && satisfiesMinimum(bunVersion(file, run) ?? '')) return file
65
+ return undefined
66
+ }
package/bin/spine.js CHANGED
@@ -1,15 +1,27 @@
1
1
  #!/usr/bin/env node
2
2
  // Thin launcher: Design Spine needs Bun (HTML imports, Bun.serve), so hand off to it.
3
- import { spawnSync } from 'node:child_process'
3
+ import { spawn } from 'node:child_process'
4
4
  import { fileURLToPath } from 'node:url'
5
+ import { resolveBun } from './resolve-bun.js'
5
6
 
6
7
  const MESSAGE = 'Design Spine runs on Bun 1.3+. Install: curl -fsSL https://bun.sh/install | bash'
7
8
  const cli = fileURLToPath(new URL('../src/cli.ts', import.meta.url))
8
- const version = spawnSync('bun', ['--version'], { encoding: 'utf8' })
9
- const [major = 0, minor = 0] = (version.stdout ?? '').trim().split('.').map(Number)
10
- if (version.error || version.status !== 0 || major < 1 || (major === 1 && minor < 3)) {
9
+ const bun = resolveBun()
10
+ if (!bun) {
11
11
  console.error(MESSAGE)
12
12
  process.exit(1)
13
13
  }
14
- const result = spawnSync('bun', [cli, ...process.argv.slice(2)], { stdio: 'inherit' })
15
- process.exit(result.status ?? 1)
14
+ const child = spawn(bun, [cli, ...process.argv.slice(2)], { stdio: 'inherit' })
15
+ for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.on(signal, () => child.kill(signal))
16
+ child.on('error', () => {
17
+ console.error(MESSAGE)
18
+ process.exit(1)
19
+ })
20
+ child.on('exit', (code, signal) => {
21
+ if (signal) {
22
+ process.removeAllListeners(signal)
23
+ process.kill(process.pid, signal)
24
+ } else {
25
+ process.exit(code ?? 1)
26
+ }
27
+ })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "design-spine",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Screenshot every route and state of your Next.js app, then approve each screen by eye. Local, no account.",
5
5
  "license": "MIT",
6
6
  "author": "Elizabeth Stein",
@@ -39,21 +39,34 @@
39
39
  "build": "bun build --compile src/cli.ts src/lib/comparison-worker.ts --external 'chromium-bidi/*' --external electron --define 'import.meta.spineWorker=\"./lib/comparison-worker.js\"' --outfile dist/spine",
40
40
  "spine": "bun src/cli.ts",
41
41
  "test": "vitest run",
42
+ "test:visual": "bun scripts/visual-tests.ts",
43
+ "test:visual:update": "bun scripts/visual-tests.ts --update",
42
44
  "lint": "biome check .",
43
45
  "format": "biome format --write ."
44
46
  },
45
47
  "dependencies": {
46
48
  "@axe-core/playwright": "^4.13.0",
49
+ "@modelcontextprotocol/sdk": "1.32.2",
47
50
  "commander": "^15.0.0",
48
51
  "pixelmatch": "^7.2.0",
49
52
  "playwright": "^1.63.0",
50
53
  "pngjs": "^7.0.0"
51
54
  },
55
+ "optionalDependencies": {
56
+ "@oven/bun-darwin-aarch64": "1.3.13",
57
+ "@oven/bun-darwin-x64": "1.3.13",
58
+ "@oven/bun-linux-aarch64": "1.3.13",
59
+ "@oven/bun-linux-aarch64-musl": "1.3.13",
60
+ "@oven/bun-linux-x64": "1.3.13",
61
+ "@oven/bun-linux-x64-musl": "1.3.13",
62
+ "@oven/bun-windows-aarch64": "1.3.13",
63
+ "@oven/bun-windows-x64": "1.3.13"
64
+ },
52
65
  "devDependencies": {
53
- "@biomejs/biome": "^2.5.15",
54
- "@types/bun": "^1.4.2",
55
- "@types/pngjs": "^6.0.5",
56
- "typescript": "^7.0.2",
57
- "vitest": "^5.0.3"
66
+ "@biomejs/biome": "2.6.4",
67
+ "@types/bun": "1.4.4",
68
+ "@types/pngjs": "6.0.5",
69
+ "typescript": "7.0.2",
70
+ "vitest": "5.0.5"
58
71
  }
59
72
  }
package/src/assets.d.ts CHANGED
@@ -1,3 +1,13 @@
1
1
  interface ImportMeta {
2
2
  readonly spineWorker?: string
3
3
  }
4
+
5
+ declare module '*.css' {
6
+ const path: string
7
+ export default path
8
+ }
9
+
10
+ declare module '*ui/review.js' {
11
+ const path: string
12
+ export default path
13
+ }
package/src/cli.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env bun
2
2
  import { createHash } from 'node:crypto'
3
3
  import {
4
+ appendFileSync,
4
5
  existsSync,
5
6
  mkdirSync,
6
7
  readdirSync,
@@ -15,6 +16,7 @@ import { loadManifest, planJobs, runCapture } from './lib/capture.ts'
15
16
  import { collectDesignRules } from './lib/critique.ts'
16
17
  import { runCritique } from './lib/critique-run.ts'
17
18
  import { compareImages } from './lib/diff.ts'
19
+ import { captureInDocker, manifestInRoot } from './lib/docker.ts'
18
20
  import { unfinishedLines } from './lib/doctor.ts'
19
21
  import { evaluateGate } from './lib/gate.ts'
20
22
  import {
@@ -50,12 +52,14 @@ import {
50
52
  writeJson,
51
53
  } from './lib/runs.ts'
52
54
  import { startReviewServer } from './lib/server.ts'
55
+ import { buildSummary } from './lib/summary.ts'
53
56
  import type { Provenance } from './lib/types.ts'
57
+ import { runMcp } from './mcp.ts'
54
58
 
55
59
  const root = resolve(process.env.SPINE_CWD ?? process.cwd())
56
60
  const program = new Command('spine')
57
61
  .description('Design gate for Next.js repos')
58
- .version('0.1.0')
62
+ .version('0.2.1')
59
63
  // Usage errors exit 2 so they never look like doctor's exit 1 (unfinished coverage).
60
64
  .exitOverride((err) => process.exit(err.exitCode === 0 ? 0 : 2))
61
65
 
@@ -199,35 +203,66 @@ program
199
203
  .option('--manifest <file>', 'manifest path', 'design-spine.json')
200
204
  .option('--routes <list>', 'comma-separated route paths to limit to')
201
205
  .option('--concurrency <n>', 'parallel tiles', '3')
206
+ .option(
207
+ '--docker',
208
+ 'capture inside a pinned Linux x64 Playwright image so baselines match ubuntu-latest CI',
209
+ )
202
210
  .action(
203
- guard(async (o: { base?: string; manifest: string; routes?: string; concurrency: string }) => {
204
- const concurrency = numberOption(o.concurrency, 'concurrency', 1, 32)
205
- const mpath = resolve(root, o.manifest)
206
- if (!existsSync(mpath))
207
- throw new Error(`Manifest ${o.manifest} not found. Run \`spine plan\`.`)
208
- const raw = JSON.parse(readFileSync(mpath, 'utf8')) as { baseUrl?: unknown }
209
- const baseUrl = o.base ?? (typeof raw.baseUrl === 'string' ? raw.baseUrl : '')
210
- if (!baseUrl)
211
- throw new Error(`No base URL. Pass --base <url> or set baseUrl in ${o.manifest}.`)
212
- const { run, dir, skipped } = await runCapture({
213
- root,
214
- manifest: validateManifest({ ...raw, baseUrl }, root),
215
- baseUrl,
216
- routes: o.routes?.split(',').map((s) => s.trim()),
217
- concurrency,
218
- log: console.log,
219
- })
220
- for (const s of skipped) console.warn(`skipped ${s}`)
221
- const flagged = run.tiles.filter(isFlagged).length
222
- console.log(`Run ${run.id}: ${run.tiles.length} tiles, ${flagged} flagged -> ${dir}`)
223
- const errored = run.tiles.filter((t) => t.error)
224
- if (errored.length) {
225
- console.error(`${errored.length} of ${run.tiles.length} tiles failed to capture:`)
226
- for (const t of errored.slice(0, 5)) console.error(` ${t.id}: ${t.error}`)
227
- if (errored.length > 5) console.error(` ${errored.length - 5} more, see run.json`)
228
- process.exit(1)
229
- }
230
- }),
211
+ guard(
212
+ async (o: {
213
+ base?: string
214
+ manifest: string
215
+ routes?: string
216
+ concurrency: string
217
+ docker?: boolean
218
+ }) => {
219
+ const concurrency = numberOption(o.concurrency, 'concurrency', 1, 32)
220
+ const mpath = resolve(root, o.manifest)
221
+ if (!existsSync(mpath))
222
+ throw new Error(`Manifest ${o.manifest} not found. Run \`spine plan\`.`)
223
+ const raw = JSON.parse(readFileSync(mpath, 'utf8')) as { baseUrl?: unknown }
224
+ const baseUrl = o.base ?? (typeof raw.baseUrl === 'string' ? raw.baseUrl : '')
225
+ if (!baseUrl)
226
+ throw new Error(`No base URL. Pass --base <url> or set baseUrl in ${o.manifest}.`)
227
+ if (o.docker) {
228
+ validateManifest({ ...raw, baseUrl }, root)
229
+ const code = await captureInDocker({
230
+ root,
231
+ manifest: manifestInRoot(root, o.manifest),
232
+ base: baseUrl,
233
+ hostBase: baseUrl,
234
+ routes: o.routes,
235
+ concurrency: String(concurrency),
236
+ log: console.log,
237
+ })
238
+ process.exit(code)
239
+ }
240
+ const { run, dir, skipped } = await runCapture({
241
+ root,
242
+ manifest: validateManifest({ ...raw, baseUrl }, root),
243
+ baseUrl,
244
+ // Set by --docker, where the container reaches the host through another name.
245
+ recordedBaseUrl: process.env.SPINE_RECORDED_BASE_URL || undefined,
246
+ routes: o.routes?.split(',').map((s) => s.trim()),
247
+ concurrency,
248
+ log: console.log,
249
+ })
250
+ for (const s of skipped) console.warn(`skipped ${s}`)
251
+ const flagged = run.tiles.filter(isFlagged).length
252
+ // Inside --docker the run lives under /work; print where the host will find it.
253
+ const shown = process.env.SPINE_HOST_ROOT
254
+ ? dir.replace(/^\/work(?=\/|$)/, process.env.SPINE_HOST_ROOT)
255
+ : dir
256
+ console.log(`Run ${run.id}: ${run.tiles.length} tiles, ${flagged} flagged -> ${shown}`)
257
+ const errored = run.tiles.filter((t) => t.error)
258
+ if (errored.length) {
259
+ console.error(`${errored.length} of ${run.tiles.length} tiles failed to capture:`)
260
+ for (const t of errored.slice(0, 5)) console.error(` ${t.id}: ${t.error}`)
261
+ if (errored.length > 5) console.error(` ${errored.length - 5} more, see run.json`)
262
+ process.exit(1)
263
+ }
264
+ },
265
+ ),
231
266
  )
232
267
 
233
268
  program
@@ -352,17 +387,30 @@ program
352
387
  }),
353
388
  )
354
389
 
390
+ interface DiffOptions {
391
+ run: string
392
+ threshold: string
393
+ strict?: boolean
394
+ requireBaselines?: boolean
395
+ summary?: string
396
+ }
397
+
355
398
  program
356
399
  .command('diff')
357
400
  .description('Compare a run to baselines; exit 1 when any tile exceeds the threshold')
358
401
  .option('--run <id>', 'run id or path', 'latest')
359
402
  .option('--threshold <ratio>', 'changed-pixel ratio', '0.002')
403
+ .option(
404
+ '--require-baselines',
405
+ 'fail when a captured tile has no baseline, without requiring a new review',
406
+ )
360
407
  .option(
361
408
  '--strict',
362
409
  'also require complete coverage, human approval and compatible baseline provenance',
363
410
  )
411
+ .option('--summary <file>', 'append a GitHub-flavoured Markdown summary to this file')
364
412
  .action(
365
- guard(async (o: { run: string; threshold: string; strict?: boolean }) => {
413
+ guard(async (o: DiffOptions) => {
366
414
  const threshold = numberOption(o.threshold, 'threshold', 0, 1, false)
367
415
  const dir = resolveRunDir(root, o.run)
368
416
  const run = loadRun(dir)
@@ -401,6 +449,7 @@ program
401
449
  }
402
450
  if (!existsSync(bp)) {
403
451
  rows.push({ id: t.id, result: 'new', ratio: '-' })
452
+ if (o.requireBaselines) failed++
404
453
  continue
405
454
  }
406
455
  const pinned = baselineManifest?.tiles[t.id]?.imageHash
@@ -444,11 +493,11 @@ program
444
493
  const w = Math.max(4, ...rows.map((r) => r.id.length))
445
494
  console.log(`${'tile'.padEnd(w)} ${'result'.padEnd(12)} ratio`)
446
495
  for (const r of rows) console.log(`${r.id.padEnd(w)} ${r.result.padEnd(12)} ${r.ratio}`)
447
- console.log(`\n${rows.length} tiles, ${failed} over threshold ${threshold}`)
496
+ console.log(`\n${rows.length} tiles, ${failed} failed comparisons; threshold ${threshold}`)
448
497
  const fresh = rows.filter((r) => r.result === 'new').length
449
498
  if (fresh && !o.strict)
450
499
  console.warn(
451
- `${fresh} tiles have no baseline. Run \`spine approve\` after review, or use --strict.`,
500
+ `${fresh} tiles have no baseline. Run \`spine approve\` after review. Use --require-baselines to fail on missing baselines, or --strict for complete sign-off.`,
452
501
  )
453
502
  const reasons = o.strict
454
503
  ? evaluateGate(
@@ -465,9 +514,20 @@ program
465
514
  writeJson(join(dir, 'diff.json'), {
466
515
  threshold,
467
516
  rows,
468
- policy: o.strict ? 'strict' : 'pixels',
517
+ policy: o.strict ? 'strict' : o.requireBaselines ? 'require-baselines' : 'pixels',
469
518
  gateReasons: reasons,
470
519
  })
520
+ if (o.summary)
521
+ appendFileSync(
522
+ resolve(root, o.summary),
523
+ buildSummary({
524
+ rows,
525
+ threshold,
526
+ policy: o.strict ? 'strict' : o.requireBaselines ? 'require-baselines' : 'pixels',
527
+ gateReasons: reasons,
528
+ failed,
529
+ }),
530
+ )
471
531
  process.exit(failed ? 1 : 0)
472
532
  }),
473
533
  )
@@ -576,4 +636,9 @@ program
576
636
  }),
577
637
  )
578
638
 
639
+ program
640
+ .command('mcp')
641
+ .description('Serve a read-only MCP server over stdio (spine_status, spine_list_tiles)')
642
+ .action(guard(() => runMcp(root)))
643
+
579
644
  await program.parseAsync(process.argv)
@@ -3,11 +3,12 @@ import { createHash } from 'node:crypto'
3
3
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
4
4
  import { join } from 'node:path'
5
5
  import AxeBuilder from '@axe-core/playwright'
6
- import { type Browser, type BrowserContext, chromium } from 'playwright'
6
+ import { type Browser, type BrowserContext, chromium, type Request } from 'playwright'
7
7
  import { PNG } from 'pngjs'
8
8
  import { findBlankBoxes } from './blank.ts'
9
9
  import { fingerprint, validateManifest } from './manifest.ts'
10
10
  import { routeSlug, tileFilename, tileId } from './names.ts'
11
+ import { redactUrls, safeUrl } from './redact.ts'
11
12
  import { fillParams } from './routes.ts'
12
13
  import { setLatest, spineDir, writeJson } from './runs.ts'
13
14
  import type {
@@ -265,6 +266,10 @@ function fixedOverlays(): string[] {
265
266
  )
266
267
  }
267
268
 
269
+ export function requestFailureCode(errorText?: string): string {
270
+ return errorText?.match(/^net::ERR_[A-Z0-9_]+\b/)?.[0] ?? 'Network request failed'
271
+ }
272
+
268
273
  /** Wait before retrying a 429: Retry-After seconds (max 10), else 2s. */
269
274
  export function retryDelayMs(retryAfter?: string): number {
270
275
  const seconds = retryAfter?.trim() ? Number(retryAfter) : Number.NaN
@@ -340,6 +345,7 @@ async function captureTile(
340
345
  textClipped: [],
341
346
  }
342
347
  let ctx: BrowserContext | undefined
348
+ let collectingRequests = true
343
349
  let stage = 'context initialization'
344
350
  try {
345
351
  ctx = await browser.newContext({
@@ -354,22 +360,52 @@ async function captureTile(
354
360
  const page = await ctx.newPage()
355
361
  page.setDefaultTimeout(manifest.capture?.timeoutMs ?? 5000)
356
362
  if (manifest.capture?.clock) await page.clock.setFixedTime(new Date(manifest.capture.clock))
363
+ // Requests answered by a state mock carry the status the author chose, so they are not
364
+ // request failures even when that status is 500.
365
+ const mocked = new WeakSet<Request>()
357
366
  for (const response of job.state.responses ?? []) {
358
- await page.route(response.url, (route) =>
359
- route.fulfill({
367
+ await page.route(response.url, (route) => {
368
+ mocked.add(route.request())
369
+ return route.fulfill({
360
370
  status: response.status,
361
371
  body: response.body ?? '',
362
372
  contentType: response.contentType ?? 'text/plain',
363
- }),
364
- )
373
+ })
374
+ })
365
375
  }
366
376
  page.on('console', (m) => {
367
377
  if (m.type() === 'error' && tile.consoleErrors.length < 20)
368
- tile.consoleErrors.push(m.text().slice(0, 200))
378
+ tile.consoleErrors.push(redactUrls(m.text()).slice(0, 200))
369
379
  })
370
380
  page.on('pageerror', (e) => {
371
- if (tile.pageErrors.length < 20) tile.pageErrors.push(e.message.slice(0, 200))
381
+ if (tile.pageErrors.length < 20) tile.pageErrors.push(redactUrls(e.message).slice(0, 200))
372
382
  })
383
+ const navigationStarted = performance.now()
384
+ const recordedRequests = new WeakSet<Request>()
385
+ const requestErrors: NonNullable<Tile['requestErrors']> = []
386
+ tile.requestErrors = requestErrors
387
+ const recordRequestError = (request: Request, status?: number): void => {
388
+ if (
389
+ !collectingRequests ||
390
+ mocked.has(request) ||
391
+ recordedRequests.has(request) ||
392
+ requestErrors.length >= 20
393
+ )
394
+ return
395
+ recordedRequests.add(request)
396
+ requestErrors.push({
397
+ url: safeUrl(request.url()).slice(0, 500),
398
+ resourceType: request.resourceType(),
399
+ ...(status !== undefined
400
+ ? { status }
401
+ : { failure: requestFailureCode(request.failure()?.errorText) }),
402
+ elapsedMs: Math.round(performance.now() - navigationStarted),
403
+ })
404
+ }
405
+ page.on('response', (response) => {
406
+ if (response.status() >= 400) recordRequestError(response.request(), response.status())
407
+ })
408
+ page.on('requestfailed', (request) => recordRequestError(request))
373
409
  // `load` first, then a bounded wait for network idle. Pages that poll (live
374
410
  // embeds) never go idle; waiting on networkidle alone timed out and lost the tile.
375
411
  const url = manifest.baseUrl.replace(/\/$/, '') + job.path
@@ -377,8 +413,22 @@ async function captureTile(
377
413
  page.goto(url, { waitUntil: 'load', timeout: 30000 })
378
414
  let resp = await go()
379
415
  if (resp?.status() === 429) {
416
+ const limited = resp.request()
380
417
  await page.waitForTimeout(retryDelayMs(resp.headers()['retry-after']))
418
+ const before = requestErrors.length
381
419
  resp = await go()
420
+ // A recovered retry means the first 429 says nothing about this screen; drop its entry.
421
+ if (resp && resp.status() < 400) {
422
+ const first = requestErrors
423
+ .slice(0, before)
424
+ .findIndex(
425
+ (e) =>
426
+ e.status === 429 &&
427
+ e.resourceType === 'document' &&
428
+ e.url === safeUrl(limited.url()).slice(0, 500),
429
+ )
430
+ if (first !== -1) requestErrors.splice(first, 1)
431
+ }
382
432
  }
383
433
  tile.status = resp?.status() ?? 0
384
434
  await page.waitForLoadState('networkidle', { timeout: 8000 }).catch(() => {
@@ -490,7 +540,7 @@ async function captureTile(
490
540
  }
491
541
  }
492
542
  tile.overflowX = m.overflowX
493
- tile.brokenImages = m.brokenImages
543
+ tile.brokenImages = m.brokenImages.map(redactUrls)
494
544
  tile.textClipped = m.textClipped
495
545
  const boxes = findBlankBoxes(PNG.sync.read(buf), m.candidates).map((b) => ({
496
546
  x: Math.round(b.x),
@@ -502,12 +552,13 @@ async function captureTile(
502
552
  tile.consoleErrors = [...new Set(tile.consoleErrors)].slice(0, 5)
503
553
  tile.pageErrors = [...new Set(tile.pageErrors)].slice(0, 5)
504
554
  } catch (e) {
505
- tile.error = (e as Error).message.split('\n')[0]?.slice(0, 200)
555
+ tile.error = redactUrls((e as Error).message.split('\n')[0] ?? '').slice(0, 200)
506
556
  tile.diagnostic = {
507
557
  stage,
508
558
  message: `Capture failed during ${stage}. Inspect the configured condition and retry.`,
509
559
  }
510
560
  } finally {
561
+ collectingRequests = false
511
562
  await ctx?.close().catch(() => {})
512
563
  }
513
564
  return tile
@@ -517,6 +568,8 @@ export interface CaptureOptions {
517
568
  root: string
518
569
  manifest: Manifest
519
570
  baseUrl: string
571
+ /** Written to run.json instead of baseUrl when the capture reaches the app by another name. */
572
+ recordedBaseUrl?: string
520
573
  routes?: string[]
521
574
  concurrency: number
522
575
  log?: (s: string) => void
@@ -557,7 +610,7 @@ export async function runCapture(
557
610
  id,
558
611
  startedAt: startedAt.toISOString(),
559
612
  finishedAt: new Date().toISOString(),
560
- baseUrl: manifest.baseUrl,
613
+ baseUrl: redactUrls(opts.recordedBaseUrl ?? manifest.baseUrl),
561
614
  tiles,
562
615
  coverage: coverage.map((entry) => {
563
616
  if (entry.outcome !== 'planned') return entry