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 +30 -10
- package/bin/resolve-bun.d.ts +23 -0
- package/bin/resolve-bun.js +66 -0
- package/bin/spine.js +18 -6
- package/package.json +19 -6
- package/src/assets.d.ts +10 -0
- package/src/cli.ts +98 -33
- package/src/lib/capture.ts +63 -10
- package/src/lib/docker.ts +196 -0
- package/src/lib/redact.ts +26 -0
- package/src/lib/report.ts +4 -0
- package/src/lib/routes.ts +2 -0
- package/src/lib/runs.ts +9 -3
- package/src/lib/server.ts +14 -0
- package/src/lib/summary.ts +70 -0
- package/src/lib/types.ts +9 -0
- package/src/mcp.ts +117 -0
- package/src/ui/index.html +3 -710
- package/src/ui/review.css +1313 -0
- package/src/ui/review.js +1592 -0
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+
|
|
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@
|
|
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
|
|
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
|
-
|
|
|
104
|
-
| Plans the route x state matrix from your app | Yes | No | No |
|
|
105
|
-
| Human approval
|
|
106
|
-
|
|
|
107
|
-
|
|
|
108
|
-
|
|
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
|
-
-
|
|
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 {
|
|
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
|
|
9
|
-
|
|
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
|
|
15
|
-
process.
|
|
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
|
|
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": "
|
|
54
|
-
"@types/bun": "
|
|
55
|
-
"@types/pngjs": "
|
|
56
|
-
"typescript": "
|
|
57
|
-
"vitest": "
|
|
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
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
|
|
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(
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
baseUrl
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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:
|
|
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}
|
|
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
|
|
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)
|
package/src/lib/capture.ts
CHANGED
|
@@ -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.
|
|
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]
|
|
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
|