design-spine 0.1.0 → 0.2.0

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.0
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.0
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
 
@@ -100,18 +118,19 @@ Full reference: [docs/USAGE.md](docs/USAGE.md).
100
118
 
101
119
  | | Design Spine | Chromatic, Percy, Argos | Playwright `toHaveScreenshot` |
102
120
  |---|---|---|---|
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 |
121
+ | Screenshots stay on your machine | Yes | No, uploaded to a hosted service | Yes |
122
+ | Plans the route x state matrix from your app | Yes | No, captures the stories or tests you write | No |
123
+ | Human approval per screenshot | Yes, local review UI | Yes, in the vendor's web app | No, `--update-snapshots` accepts all at once |
124
+ | Needs an account | No | Yes | No |
125
+ | Pricing | Free, MIT | Free tier, then billed per screenshot | Free |
126
+
127
+ 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
128
 
110
129
  ## Limits
111
130
 
112
131
  - Next.js App Router only for `plan`. Other apps can write the manifest by hand.
113
132
  - 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.
133
+ - 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
134
 
116
135
  ## License
117
136
 
@@ -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' },
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' })
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.0",
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",
@@ -26,7 +26,6 @@
26
26
  "README.md",
27
27
  "LICENSE"
28
28
  ],
29
- "packageManager": "pnpm@12.6.0",
30
29
  "engines": {
31
30
  "node": ">=22.12",
32
31
  "bun": ">=1.3.13"
@@ -35,13 +34,6 @@
35
34
  "bin": {
36
35
  "spine": "bin/spine.js"
37
36
  },
38
- "scripts": {
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
- "spine": "bun src/cli.ts",
41
- "test": "vitest run",
42
- "lint": "biome check .",
43
- "format": "biome format --write ."
44
- },
45
37
  "dependencies": {
46
38
  "@axe-core/playwright": "^4.13.0",
47
39
  "commander": "^15.0.0",
@@ -49,11 +41,30 @@
49
41
  "playwright": "^1.63.0",
50
42
  "pngjs": "^7.0.0"
51
43
  },
44
+ "optionalDependencies": {
45
+ "@oven/bun-darwin-aarch64": "1.3.13",
46
+ "@oven/bun-darwin-x64": "1.3.13",
47
+ "@oven/bun-linux-aarch64": "1.3.13",
48
+ "@oven/bun-linux-aarch64-musl": "1.3.13",
49
+ "@oven/bun-linux-x64": "1.3.13",
50
+ "@oven/bun-linux-x64-musl": "1.3.13",
51
+ "@oven/bun-windows-aarch64": "1.3.13",
52
+ "@oven/bun-windows-x64": "1.3.13"
53
+ },
52
54
  "devDependencies": {
53
55
  "@biomejs/biome": "^2.5.15",
54
56
  "@types/bun": "^1.4.2",
55
57
  "@types/pngjs": "^6.0.5",
56
58
  "typescript": "^7.0.2",
57
59
  "vitest": "^5.0.3"
60
+ },
61
+ "scripts": {
62
+ "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",
63
+ "spine": "bun src/cli.ts",
64
+ "test": "vitest run",
65
+ "test:visual": "bun scripts/visual-tests.ts",
66
+ "test:visual:update": "bun scripts/visual-tests.ts --update",
67
+ "lint": "biome check .",
68
+ "format": "biome format --write ."
58
69
  }
59
- }
70
+ }
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,13 @@ 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'
54
57
 
55
58
  const root = resolve(process.env.SPINE_CWD ?? process.cwd())
56
59
  const program = new Command('spine')
57
60
  .description('Design gate for Next.js repos')
58
- .version('0.1.0')
61
+ .version('0.2.0')
59
62
  // Usage errors exit 2 so they never look like doctor's exit 1 (unfinished coverage).
60
63
  .exitOverride((err) => process.exit(err.exitCode === 0 ? 0 : 2))
61
64
 
@@ -199,35 +202,66 @@ program
199
202
  .option('--manifest <file>', 'manifest path', 'design-spine.json')
200
203
  .option('--routes <list>', 'comma-separated route paths to limit to')
201
204
  .option('--concurrency <n>', 'parallel tiles', '3')
205
+ .option(
206
+ '--docker',
207
+ 'capture inside a pinned Linux x64 Playwright image so baselines match ubuntu-latest CI',
208
+ )
202
209
  .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
- }),
210
+ guard(
211
+ async (o: {
212
+ base?: string
213
+ manifest: string
214
+ routes?: string
215
+ concurrency: string
216
+ docker?: boolean
217
+ }) => {
218
+ const concurrency = numberOption(o.concurrency, 'concurrency', 1, 32)
219
+ const mpath = resolve(root, o.manifest)
220
+ if (!existsSync(mpath))
221
+ throw new Error(`Manifest ${o.manifest} not found. Run \`spine plan\`.`)
222
+ const raw = JSON.parse(readFileSync(mpath, 'utf8')) as { baseUrl?: unknown }
223
+ const baseUrl = o.base ?? (typeof raw.baseUrl === 'string' ? raw.baseUrl : '')
224
+ if (!baseUrl)
225
+ throw new Error(`No base URL. Pass --base <url> or set baseUrl in ${o.manifest}.`)
226
+ if (o.docker) {
227
+ validateManifest({ ...raw, baseUrl }, root)
228
+ const code = await captureInDocker({
229
+ root,
230
+ manifest: manifestInRoot(root, o.manifest),
231
+ base: baseUrl,
232
+ hostBase: baseUrl,
233
+ routes: o.routes,
234
+ concurrency: String(concurrency),
235
+ log: console.log,
236
+ })
237
+ process.exit(code)
238
+ }
239
+ const { run, dir, skipped } = await runCapture({
240
+ root,
241
+ manifest: validateManifest({ ...raw, baseUrl }, root),
242
+ baseUrl,
243
+ // Set by --docker, where the container reaches the host through another name.
244
+ recordedBaseUrl: process.env.SPINE_RECORDED_BASE_URL || undefined,
245
+ routes: o.routes?.split(',').map((s) => s.trim()),
246
+ concurrency,
247
+ log: console.log,
248
+ })
249
+ for (const s of skipped) console.warn(`skipped ${s}`)
250
+ const flagged = run.tiles.filter(isFlagged).length
251
+ // Inside --docker the run lives under /work; print where the host will find it.
252
+ const shown = process.env.SPINE_HOST_ROOT
253
+ ? dir.replace(/^\/work(?=\/|$)/, process.env.SPINE_HOST_ROOT)
254
+ : dir
255
+ console.log(`Run ${run.id}: ${run.tiles.length} tiles, ${flagged} flagged -> ${shown}`)
256
+ const errored = run.tiles.filter((t) => t.error)
257
+ if (errored.length) {
258
+ console.error(`${errored.length} of ${run.tiles.length} tiles failed to capture:`)
259
+ for (const t of errored.slice(0, 5)) console.error(` ${t.id}: ${t.error}`)
260
+ if (errored.length > 5) console.error(` ${errored.length - 5} more, see run.json`)
261
+ process.exit(1)
262
+ }
263
+ },
264
+ ),
231
265
  )
232
266
 
233
267
  program
@@ -352,17 +386,30 @@ program
352
386
  }),
353
387
  )
354
388
 
389
+ interface DiffOptions {
390
+ run: string
391
+ threshold: string
392
+ strict?: boolean
393
+ requireBaselines?: boolean
394
+ summary?: string
395
+ }
396
+
355
397
  program
356
398
  .command('diff')
357
399
  .description('Compare a run to baselines; exit 1 when any tile exceeds the threshold')
358
400
  .option('--run <id>', 'run id or path', 'latest')
359
401
  .option('--threshold <ratio>', 'changed-pixel ratio', '0.002')
402
+ .option(
403
+ '--require-baselines',
404
+ 'fail when a captured tile has no baseline, without requiring a new review',
405
+ )
360
406
  .option(
361
407
  '--strict',
362
408
  'also require complete coverage, human approval and compatible baseline provenance',
363
409
  )
410
+ .option('--summary <file>', 'append a GitHub-flavoured Markdown summary to this file')
364
411
  .action(
365
- guard(async (o: { run: string; threshold: string; strict?: boolean }) => {
412
+ guard(async (o: DiffOptions) => {
366
413
  const threshold = numberOption(o.threshold, 'threshold', 0, 1, false)
367
414
  const dir = resolveRunDir(root, o.run)
368
415
  const run = loadRun(dir)
@@ -401,6 +448,7 @@ program
401
448
  }
402
449
  if (!existsSync(bp)) {
403
450
  rows.push({ id: t.id, result: 'new', ratio: '-' })
451
+ if (o.requireBaselines) failed++
404
452
  continue
405
453
  }
406
454
  const pinned = baselineManifest?.tiles[t.id]?.imageHash
@@ -444,11 +492,11 @@ program
444
492
  const w = Math.max(4, ...rows.map((r) => r.id.length))
445
493
  console.log(`${'tile'.padEnd(w)} ${'result'.padEnd(12)} ratio`)
446
494
  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}`)
495
+ console.log(`\n${rows.length} tiles, ${failed} failed comparisons; threshold ${threshold}`)
448
496
  const fresh = rows.filter((r) => r.result === 'new').length
449
497
  if (fresh && !o.strict)
450
498
  console.warn(
451
- `${fresh} tiles have no baseline. Run \`spine approve\` after review, or use --strict.`,
499
+ `${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
500
  )
453
501
  const reasons = o.strict
454
502
  ? evaluateGate(
@@ -465,9 +513,20 @@ program
465
513
  writeJson(join(dir, 'diff.json'), {
466
514
  threshold,
467
515
  rows,
468
- policy: o.strict ? 'strict' : 'pixels',
516
+ policy: o.strict ? 'strict' : o.requireBaselines ? 'require-baselines' : 'pixels',
469
517
  gateReasons: reasons,
470
518
  })
519
+ if (o.summary)
520
+ appendFileSync(
521
+ resolve(root, o.summary),
522
+ buildSummary({
523
+ rows,
524
+ threshold,
525
+ policy: o.strict ? 'strict' : o.requireBaselines ? 'require-baselines' : 'pixels',
526
+ gateReasons: reasons,
527
+ failed,
528
+ }),
529
+ )
471
530
  process.exit(failed ? 1 : 0)
472
531
  }),
473
532
  )
@@ -517,6 +517,8 @@ export interface CaptureOptions {
517
517
  root: string
518
518
  manifest: Manifest
519
519
  baseUrl: string
520
+ /** Written to run.json instead of baseUrl when the capture reaches the app by another name. */
521
+ recordedBaseUrl?: string
520
522
  routes?: string[]
521
523
  concurrency: number
522
524
  log?: (s: string) => void
@@ -557,7 +559,7 @@ export async function runCapture(
557
559
  id,
558
560
  startedAt: startedAt.toISOString(),
559
561
  finishedAt: new Date().toISOString(),
560
- baseUrl: manifest.baseUrl,
562
+ baseUrl: opts.recordedBaseUrl ?? manifest.baseUrl,
561
563
  tiles,
562
564
  coverage: coverage.map((entry) => {
563
565
  if (entry.outcome !== 'planned') return entry