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 +29 -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 +21 -10
- package/src/assets.d.ts +10 -0
- package/src/cli.ts +92 -33
- package/src/lib/capture.ts +3 -1
- package/src/lib/docker.ts +196 -0
- package/src/lib/routes.ts +2 -0
- package/src/lib/server.ts +14 -0
- package/src/lib/summary.ts +70 -0
- package/src/ui/index.html +3 -710
- package/src/ui/review.css +1313 -0
- package/src/ui/review.js +1575 -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.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
|
|
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
|
-
|
|
|
104
|
-
| Plans the route x state matrix from your app | Yes | No | No |
|
|
105
|
-
| Human approval
|
|
106
|
-
|
|
|
107
|
-
|
|
|
108
|
-
|
|
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
|
-
-
|
|
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 {
|
|
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.
|
|
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
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.
|
|
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(
|
|
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
|
-
|
|
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:
|
|
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}
|
|
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
|
|
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
|
)
|
package/src/lib/capture.ts
CHANGED
|
@@ -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
|