similarbuild 0.1.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/CHANGELOG.md +110 -0
- package/LICENSE +21 -0
- package/README.md +301 -0
- package/bin/install.js +256 -0
- package/lib/copy-templates.mjs +52 -0
- package/lib/install-deps.mjs +62 -0
- package/lib/prompt-config.mjs +83 -0
- package/lib/verify-env.mjs +19 -0
- package/package.json +63 -0
- package/scripts/sync-templates.mjs +71 -0
- package/templates/commands/build-page.md +490 -0
- package/templates/commands/build-site.md +548 -0
- package/templates/commands/clip-section.md +519 -0
- package/templates/memory/anti-patterns.md +212 -0
- package/templates/memory/design-knowledge.md +225 -0
- package/templates/memory/fixes.md +163 -0
- package/templates/memory/patterns.md +681 -0
- package/templates/presets/shopify-section.yaml +51 -0
- package/templates/presets/wp-elementor.yaml +49 -0
- package/templates/reports/fixtures/mock-run-1.json +115 -0
- package/templates/reports/fixtures/mock-run-2.json +72 -0
- package/templates/reports/report-renderer.mjs +218 -0
- package/templates/reports/report-template.html +571 -0
- package/templates/skills/sb-build-shopify/SKILL.md +104 -0
- package/templates/skills/sb-build-shopify/references/shopify-build-rules.md +563 -0
- package/templates/skills/sb-build-shopify/scripts/build-shopify.mjs +637 -0
- package/templates/skills/sb-build-shopify/scripts/tests/test-build-shopify.mjs +424 -0
- package/templates/skills/sb-build-wp/SKILL.md +83 -0
- package/templates/skills/sb-build-wp/references/wp-build-rules.md +376 -0
- package/templates/skills/sb-build-wp/scripts/build-wp.mjs +327 -0
- package/templates/skills/sb-build-wp/scripts/tests/test-build-wp.mjs +224 -0
- package/templates/skills/sb-compare-visual/SKILL.md +121 -0
- package/templates/skills/sb-compare-visual/scripts/compare-visual.mjs +387 -0
- package/templates/skills/sb-compare-visual/scripts/lib/compare-tokens.mjs +273 -0
- package/templates/skills/sb-compare-visual/scripts/tests/test-compare-tokens.mjs +350 -0
- package/templates/skills/sb-compare-visual/scripts/tests/test-compare-visual.mjs +626 -0
- package/templates/skills/sb-crawl-and-list/SKILL.md +99 -0
- package/templates/skills/sb-crawl-and-list/scripts/crawl-and-list.mjs +437 -0
- package/templates/skills/sb-crawl-and-list/scripts/lib/blocklist-filter.mjs +176 -0
- package/templates/skills/sb-crawl-and-list/scripts/lib/fallback-crawler.mjs +107 -0
- package/templates/skills/sb-crawl-and-list/scripts/lib/page-classifier.mjs +89 -0
- package/templates/skills/sb-crawl-and-list/scripts/lib/sitemap-parser.mjs +118 -0
- package/templates/skills/sb-crawl-and-list/scripts/tests/test-blocklist-filter.mjs +204 -0
- package/templates/skills/sb-crawl-and-list/scripts/tests/test-crawl-and-list.mjs +276 -0
- package/templates/skills/sb-crawl-and-list/scripts/tests/test-fallback-crawler.mjs +243 -0
- package/templates/skills/sb-crawl-and-list/scripts/tests/test-page-classifier.mjs +120 -0
- package/templates/skills/sb-crawl-and-list/scripts/tests/test-sitemap-parser.mjs +157 -0
- package/templates/skills/sb-extract-assets/SKILL.md +112 -0
- package/templates/skills/sb-extract-assets/scripts/extract-assets.mjs +484 -0
- package/templates/skills/sb-extract-assets/scripts/tests/test-extract-assets.mjs +112 -0
- package/templates/skills/sb-inspect-live/SKILL.md +105 -0
- package/templates/skills/sb-inspect-live/scripts/inspect-live.mjs +693 -0
- package/templates/skills/sb-inspect-live/scripts/tests/test-inspect-live.mjs +181 -0
- package/templates/skills/sb-review-checks/SKILL.md +113 -0
- package/templates/skills/sb-review-checks/references/review-rules.md +195 -0
- package/templates/skills/sb-review-checks/scripts/lib/anti-patterns.mjs +379 -0
- package/templates/skills/sb-review-checks/scripts/lib/cross-reference.mjs +115 -0
- package/templates/skills/sb-review-checks/scripts/lib/design-quality.mjs +541 -0
- package/templates/skills/sb-review-checks/scripts/review-checks.mjs +250 -0
- package/templates/skills/sb-review-checks/scripts/tests/test-anti-patterns.mjs +343 -0
- package/templates/skills/sb-review-checks/scripts/tests/test-cross-reference.mjs +170 -0
- package/templates/skills/sb-review-checks/scripts/tests/test-design-quality.mjs +493 -0
- package/templates/skills/sb-review-checks/scripts/tests/test-review-checks.mjs +267 -0
- package/templates/skills/sb-tweak/SKILL.md +130 -0
- package/templates/skills/sb-tweak/references/tweak-patterns.md +157 -0
- package/templates/skills/sb-tweak/scripts/lib/diff-summarizer.mjs +140 -0
- package/templates/skills/sb-tweak/scripts/lib/element-locator.mjs +507 -0
- package/templates/skills/sb-tweak/scripts/lib/intent-parser.mjs +324 -0
- package/templates/skills/sb-tweak/scripts/tests/test-diff-summarizer.mjs +248 -0
- package/templates/skills/sb-tweak/scripts/tests/test-element-locator.mjs +418 -0
- package/templates/skills/sb-tweak/scripts/tests/test-intent-parser.mjs +496 -0
- package/templates/skills/sb-tweak/scripts/tests/test-tweak.mjs +407 -0
- package/templates/skills/sb-tweak/scripts/tweak.mjs +656 -0
- package/templates/skills/sb-validate-render/SKILL.md +120 -0
- package/templates/skills/sb-validate-render/scripts/tests/test-validate-render.mjs +304 -0
- package/templates/skills/sb-validate-render/scripts/validate-render.mjs +645 -0
|
@@ -0,0 +1,626 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// test-compare-visual.mjs — Smoke tests for compare-visual.mjs.
|
|
3
|
+
// Covers: --help, arg validation, missing files. Plus optional integration
|
|
4
|
+
// tests with on-the-fly generated PNG fixtures (skip gracefully if sharp /
|
|
5
|
+
// pixelmatch / pngjs aren't installed).
|
|
6
|
+
//
|
|
7
|
+
// No chromium required → coverage can be deeper than the playwright-gated skills.
|
|
8
|
+
|
|
9
|
+
import { spawnSync } from 'node:child_process'
|
|
10
|
+
import { fileURLToPath } from 'node:url'
|
|
11
|
+
import { dirname, resolve, join } from 'node:path'
|
|
12
|
+
import { mkdir, writeFile, readFile, rm, stat } from 'node:fs/promises'
|
|
13
|
+
import { strict as assert } from 'node:assert'
|
|
14
|
+
import { tmpdir } from 'node:os'
|
|
15
|
+
|
|
16
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
17
|
+
const SCRIPT = resolve(here, '..', 'compare-visual.mjs')
|
|
18
|
+
|
|
19
|
+
let passed = 0
|
|
20
|
+
let failed = 0
|
|
21
|
+
|
|
22
|
+
function test(name, fn) {
|
|
23
|
+
try {
|
|
24
|
+
const result = fn()
|
|
25
|
+
if (result && typeof result.then === 'function') {
|
|
26
|
+
return result.then(
|
|
27
|
+
() => {
|
|
28
|
+
process.stdout.write(`ok - ${name}\n`)
|
|
29
|
+
passed++
|
|
30
|
+
},
|
|
31
|
+
(err) => {
|
|
32
|
+
process.stdout.write(`not ok - ${name}\n ${err.message}\n`)
|
|
33
|
+
failed++
|
|
34
|
+
},
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
process.stdout.write(`ok - ${name}\n`)
|
|
38
|
+
passed++
|
|
39
|
+
} catch (err) {
|
|
40
|
+
process.stdout.write(`not ok - ${name}\n ${err.message}\n`)
|
|
41
|
+
failed++
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function skip(name, reason) {
|
|
46
|
+
process.stdout.write(`ok - ${name} # SKIP ${reason}\n`)
|
|
47
|
+
passed++
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function run(args) {
|
|
51
|
+
return spawnSync('node', [SCRIPT, ...args], { encoding: 'utf8' })
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ─── help + arg validation (no deps required) ──────────────────────────────
|
|
55
|
+
|
|
56
|
+
test('--help exits 0 and prints usage', () => {
|
|
57
|
+
const r = run(['--help'])
|
|
58
|
+
assert.equal(r.status, 0, `exit code was ${r.status}\n${r.stderr}`)
|
|
59
|
+
assert.match(r.stdout, /compare-visual\.mjs/)
|
|
60
|
+
assert.match(r.stdout, /--live-screenshot/)
|
|
61
|
+
assert.match(r.stdout, /--build-screenshot/)
|
|
62
|
+
assert.match(r.stdout, /--output-dir/)
|
|
63
|
+
assert.match(r.stdout, /--threshold/)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test('missing --live-screenshot exits 2', () => {
|
|
67
|
+
const r = run(['--build-screenshot', '/tmp/x.png', '--output-dir', '/tmp/x'])
|
|
68
|
+
assert.equal(r.status, 2)
|
|
69
|
+
assert.match(r.stderr, /missing --live-screenshot/)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('missing --build-screenshot exits 2', () => {
|
|
73
|
+
const r = run(['--live-screenshot', '/tmp/x.png', '--output-dir', '/tmp/x'])
|
|
74
|
+
assert.equal(r.status, 2)
|
|
75
|
+
assert.match(r.stderr, /missing --build-screenshot/)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test('missing --output-dir exits 2', () => {
|
|
79
|
+
const r = run(['--live-screenshot', '/tmp/x.png', '--build-screenshot', '/tmp/y.png'])
|
|
80
|
+
assert.equal(r.status, 2)
|
|
81
|
+
assert.match(r.stderr, /missing --output-dir/)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test('non-numeric --threshold exits 2', () => {
|
|
85
|
+
const r = run([
|
|
86
|
+
'--live-screenshot', '/tmp/x.png',
|
|
87
|
+
'--build-screenshot', '/tmp/y.png',
|
|
88
|
+
'--output-dir', '/tmp/x',
|
|
89
|
+
'--threshold', 'abc',
|
|
90
|
+
])
|
|
91
|
+
assert.equal(r.status, 2)
|
|
92
|
+
assert.match(r.stderr, /--threshold must be a number/)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test('--threshold > 100 exits 2', () => {
|
|
96
|
+
const r = run([
|
|
97
|
+
'--live-screenshot', '/tmp/x.png',
|
|
98
|
+
'--build-screenshot', '/tmp/y.png',
|
|
99
|
+
'--output-dir', '/tmp/x',
|
|
100
|
+
'--threshold', '150',
|
|
101
|
+
])
|
|
102
|
+
assert.equal(r.status, 2)
|
|
103
|
+
assert.match(r.stderr, /--threshold must be a number/)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
test('--pixel-threshold > 1 exits 2', () => {
|
|
107
|
+
const r = run([
|
|
108
|
+
'--live-screenshot', '/tmp/x.png',
|
|
109
|
+
'--build-screenshot', '/tmp/y.png',
|
|
110
|
+
'--output-dir', '/tmp/x',
|
|
111
|
+
'--pixel-threshold', '5',
|
|
112
|
+
])
|
|
113
|
+
assert.equal(r.status, 2)
|
|
114
|
+
assert.match(r.stderr, /--pixel-threshold must be a number/)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
test('--help mentions --crop-live-bbox', () => {
|
|
118
|
+
const r = run(['--help'])
|
|
119
|
+
assert.equal(r.status, 0)
|
|
120
|
+
assert.match(r.stdout, /--crop-live-bbox/)
|
|
121
|
+
assert.match(r.stdout, /--crop-live-dpr/)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
test('--crop-live-bbox malformed exits 2', () => {
|
|
125
|
+
const r = run([
|
|
126
|
+
'--live-screenshot', '/tmp/x.png',
|
|
127
|
+
'--build-screenshot', '/tmp/y.png',
|
|
128
|
+
'--output-dir', '/tmp/x',
|
|
129
|
+
'--crop-live-bbox', '0,0,abc,10',
|
|
130
|
+
])
|
|
131
|
+
assert.equal(r.status, 2)
|
|
132
|
+
assert.match(r.stderr, /--crop-live-bbox/)
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
test('--crop-live-bbox wrong arity exits 2', () => {
|
|
136
|
+
const r = run([
|
|
137
|
+
'--live-screenshot', '/tmp/x.png',
|
|
138
|
+
'--build-screenshot', '/tmp/y.png',
|
|
139
|
+
'--output-dir', '/tmp/x',
|
|
140
|
+
'--crop-live-bbox', '0,0,10',
|
|
141
|
+
])
|
|
142
|
+
assert.equal(r.status, 2)
|
|
143
|
+
assert.match(r.stderr, /--crop-live-bbox/)
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
test('--crop-live-bbox negative dims exit 2', () => {
|
|
147
|
+
const r = run([
|
|
148
|
+
'--live-screenshot', '/tmp/x.png',
|
|
149
|
+
'--build-screenshot', '/tmp/y.png',
|
|
150
|
+
'--output-dir', '/tmp/x',
|
|
151
|
+
'--crop-live-bbox', '0,0,-5,10',
|
|
152
|
+
])
|
|
153
|
+
assert.equal(r.status, 2)
|
|
154
|
+
assert.match(r.stderr, /--crop-live-bbox/)
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
test('--help mentions --crop-build-bbox', () => {
|
|
158
|
+
const r = run(['--help'])
|
|
159
|
+
assert.equal(r.status, 0)
|
|
160
|
+
assert.match(r.stdout, /--crop-build-bbox/)
|
|
161
|
+
assert.match(r.stdout, /--crop-build-dpr/)
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
test('--crop-build-bbox malformed exits 2', () => {
|
|
165
|
+
const r = run([
|
|
166
|
+
'--live-screenshot', '/tmp/x.png',
|
|
167
|
+
'--build-screenshot', '/tmp/y.png',
|
|
168
|
+
'--output-dir', '/tmp/x',
|
|
169
|
+
'--crop-build-bbox', '0,0,abc,10',
|
|
170
|
+
])
|
|
171
|
+
assert.equal(r.status, 2)
|
|
172
|
+
assert.match(r.stderr, /--crop-build-bbox/)
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
test('--crop-build-bbox wrong arity exits 2', () => {
|
|
176
|
+
const r = run([
|
|
177
|
+
'--live-screenshot', '/tmp/x.png',
|
|
178
|
+
'--build-screenshot', '/tmp/y.png',
|
|
179
|
+
'--output-dir', '/tmp/x',
|
|
180
|
+
'--crop-build-bbox', '0,0,10',
|
|
181
|
+
])
|
|
182
|
+
assert.equal(r.status, 2)
|
|
183
|
+
assert.match(r.stderr, /--crop-build-bbox/)
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
test('missing live-screenshot file exits 2', () => {
|
|
187
|
+
const r = run([
|
|
188
|
+
'--live-screenshot', '/tmp/sb-compare-does-not-exist-abc.png',
|
|
189
|
+
'--build-screenshot', '/tmp/sb-compare-does-not-exist-xyz.png',
|
|
190
|
+
'--output-dir', '/tmp/sb-compare-test-out',
|
|
191
|
+
])
|
|
192
|
+
assert.equal(r.status, 2)
|
|
193
|
+
assert.match(r.stderr, /not found/)
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
// ─── integration: generate PNG fixtures + run end-to-end ────────────────────
|
|
197
|
+
// These tests are valuable BECAUSE the script has no chromium dep — we can
|
|
198
|
+
// cover the full pixel + token + exit-code path without mocking the world.
|
|
199
|
+
|
|
200
|
+
let PNG = null
|
|
201
|
+
let pixelmatchAvailable = false
|
|
202
|
+
let sharpAvailable = false
|
|
203
|
+
try {
|
|
204
|
+
;({ PNG } = await import('pngjs'))
|
|
205
|
+
} catch {}
|
|
206
|
+
try {
|
|
207
|
+
await import('pixelmatch')
|
|
208
|
+
pixelmatchAvailable = true
|
|
209
|
+
} catch {}
|
|
210
|
+
try {
|
|
211
|
+
await import('sharp')
|
|
212
|
+
sharpAvailable = true
|
|
213
|
+
} catch {}
|
|
214
|
+
|
|
215
|
+
const integrationReady = PNG && pixelmatchAvailable && sharpAvailable
|
|
216
|
+
|
|
217
|
+
function makePng(width, height, rgba) {
|
|
218
|
+
const png = new PNG({ width, height })
|
|
219
|
+
for (let i = 0; i < width * height; i++) {
|
|
220
|
+
png.data[i * 4 + 0] = rgba[0]
|
|
221
|
+
png.data[i * 4 + 1] = rgba[1]
|
|
222
|
+
png.data[i * 4 + 2] = rgba[2]
|
|
223
|
+
png.data[i * 4 + 3] = rgba[3] ?? 255
|
|
224
|
+
}
|
|
225
|
+
return PNG.sync.write(png)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const TMPROOT = join(tmpdir(), `sb-compare-visual-test-${Date.now()}`)
|
|
229
|
+
|
|
230
|
+
if (!integrationReady) {
|
|
231
|
+
skip('integration: identical PNGs → diffPercent 0, exit 0', 'sharp/pixelmatch/pngjs not installed')
|
|
232
|
+
skip('integration: opposite-color PNGs → diffPercent 100, exit 3', 'sharp/pixelmatch/pngjs not installed')
|
|
233
|
+
skip('integration: differing dimensions are padded, not resized', 'sharp/pixelmatch/pngjs not installed')
|
|
234
|
+
skip('integration: token cross-check populates structuredDiffs', 'sharp/pixelmatch/pngjs not installed')
|
|
235
|
+
skip('integration: passes when below custom threshold', 'sharp/pixelmatch/pngjs not installed')
|
|
236
|
+
} else {
|
|
237
|
+
await mkdir(TMPROOT, { recursive: true })
|
|
238
|
+
|
|
239
|
+
const RED = [255, 0, 0, 255]
|
|
240
|
+
const BLUE = [0, 0, 255, 255]
|
|
241
|
+
|
|
242
|
+
await test('integration: identical PNGs → diffPercent 0, exit 0', async () => {
|
|
243
|
+
const dir = join(TMPROOT, 'identical')
|
|
244
|
+
await mkdir(dir, { recursive: true })
|
|
245
|
+
const live = join(dir, 'live.png')
|
|
246
|
+
const build = join(dir, 'build.png')
|
|
247
|
+
await writeFile(live, makePng(8, 8, RED))
|
|
248
|
+
await writeFile(build, makePng(8, 8, RED))
|
|
249
|
+
const out = join(dir, 'out')
|
|
250
|
+
const r = run(['--live-screenshot', live, '--build-screenshot', build, '--output-dir', out])
|
|
251
|
+
assert.equal(r.status, 0, `exit was ${r.status}\nstderr: ${r.stderr}`)
|
|
252
|
+
const json = JSON.parse(r.stdout.trim())
|
|
253
|
+
assert.equal(json.passed, true)
|
|
254
|
+
assert.equal(json.diffPixels, 0)
|
|
255
|
+
assert.equal(json.diffPercent, 0)
|
|
256
|
+
const diffMapStat = await stat(join(out, 'diff-map.png'))
|
|
257
|
+
assert.ok(diffMapStat.size > 0, 'diff-map.png should be written even when identical')
|
|
258
|
+
const reportRaw = await readFile(join(out, 'report.json'), 'utf8')
|
|
259
|
+
const report = JSON.parse(reportRaw)
|
|
260
|
+
assert.equal(report.diffPixels, 0)
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
await test('integration: opposite-color PNGs → diffPercent 100, exit 3', async () => {
|
|
264
|
+
const dir = join(TMPROOT, 'opposite')
|
|
265
|
+
await mkdir(dir, { recursive: true })
|
|
266
|
+
const live = join(dir, 'live.png')
|
|
267
|
+
const build = join(dir, 'build.png')
|
|
268
|
+
await writeFile(live, makePng(8, 8, RED))
|
|
269
|
+
await writeFile(build, makePng(8, 8, BLUE))
|
|
270
|
+
const out = join(dir, 'out')
|
|
271
|
+
const r = run(['--live-screenshot', live, '--build-screenshot', build, '--output-dir', out])
|
|
272
|
+
assert.equal(r.status, 3, `exit was ${r.status}\nstderr: ${r.stderr}`)
|
|
273
|
+
const json = JSON.parse(r.stdout.trim())
|
|
274
|
+
assert.equal(json.passed, false)
|
|
275
|
+
assert.equal(json.diffPixels, 64)
|
|
276
|
+
assert.equal(json.totalPixels, 64)
|
|
277
|
+
assert.equal(json.diffPercent, 100)
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
await test('integration: differing dimensions are padded, not resized', async () => {
|
|
281
|
+
// live 8x8 red; build 16x8 red. Padding the live to 16x8 with transparent
|
|
282
|
+
// pixels means the right half is "build has content, live doesn't" — every
|
|
283
|
+
// right-half pixel diffs. Resize would scale and bleed red across the
|
|
284
|
+
// whole image (nearly zero diff). We assert ~50% diff to prove padding.
|
|
285
|
+
const dir = join(TMPROOT, 'dimensions')
|
|
286
|
+
await mkdir(dir, { recursive: true })
|
|
287
|
+
const live = join(dir, 'live.png')
|
|
288
|
+
const build = join(dir, 'build.png')
|
|
289
|
+
await writeFile(live, makePng(8, 8, RED))
|
|
290
|
+
await writeFile(build, makePng(16, 8, RED))
|
|
291
|
+
const out = join(dir, 'out')
|
|
292
|
+
const r = run([
|
|
293
|
+
'--live-screenshot', live,
|
|
294
|
+
'--build-screenshot', build,
|
|
295
|
+
'--output-dir', out,
|
|
296
|
+
'--threshold', '90',
|
|
297
|
+
])
|
|
298
|
+
const json = JSON.parse(r.stdout.trim())
|
|
299
|
+
assert.equal(json.dimensions.canvas.width, 16)
|
|
300
|
+
assert.equal(json.dimensions.canvas.height, 8)
|
|
301
|
+
// The right half of `live` is transparent padding vs solid red on build → diff.
|
|
302
|
+
assert.ok(json.diffPercent >= 40 && json.diffPercent <= 60, `expected ~50% diff, got ${json.diffPercent}`)
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
await test('integration: token cross-check populates structuredDiffs', async () => {
|
|
306
|
+
const dir = join(TMPROOT, 'tokens')
|
|
307
|
+
await mkdir(dir, { recursive: true })
|
|
308
|
+
const live = join(dir, 'live.png')
|
|
309
|
+
const build = join(dir, 'build.png')
|
|
310
|
+
await writeFile(live, makePng(8, 8, RED))
|
|
311
|
+
await writeFile(build, makePng(8, 8, RED))
|
|
312
|
+
const tokensLive = join(dir, 'tokens-live.json')
|
|
313
|
+
const tokensBuild = join(dir, 'tokens-build.json')
|
|
314
|
+
await writeFile(tokensLive, JSON.stringify({
|
|
315
|
+
tokens: { h1: { present: true, 'font-size': '27px', color: '#d8112a' } },
|
|
316
|
+
geometry: { viewportOverflow: false, totalHeight: 1000 },
|
|
317
|
+
}))
|
|
318
|
+
await writeFile(tokensBuild, JSON.stringify({
|
|
319
|
+
tokens: { h1: { present: true, 'font-size': '24px', color: '#d82a11' } },
|
|
320
|
+
geometry: { viewportOverflow: false, totalHeight: 1000 },
|
|
321
|
+
}))
|
|
322
|
+
const out = join(dir, 'out')
|
|
323
|
+
const r = run([
|
|
324
|
+
'--live-screenshot', live,
|
|
325
|
+
'--build-screenshot', build,
|
|
326
|
+
'--output-dir', out,
|
|
327
|
+
'--tokens-live', tokensLive,
|
|
328
|
+
'--tokens-build', tokensBuild,
|
|
329
|
+
])
|
|
330
|
+
assert.equal(r.status, 0, `exit was ${r.status}\nstderr: ${r.stderr}`)
|
|
331
|
+
const json = JSON.parse(r.stdout.trim())
|
|
332
|
+
assert.equal(json.tokenDiff.compared, true)
|
|
333
|
+
assert.equal(json.tokenDiff.count, 2)
|
|
334
|
+
// Both diffs are 'high' (font-size delta > 1px, color hex changed)
|
|
335
|
+
assert.ok(json.structuredDiffs.length === 2)
|
|
336
|
+
assert.ok(json.structuredDiffs.every((d) => d.severity === 'high'))
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
await test('integration: passes when below custom threshold', async () => {
|
|
340
|
+
const dir = join(TMPROOT, 'threshold')
|
|
341
|
+
await mkdir(dir, { recursive: true })
|
|
342
|
+
const live = join(dir, 'live.png')
|
|
343
|
+
const build = join(dir, 'build.png')
|
|
344
|
+
// 100x100 red live, 100x100 mostly-red build with one diff strip
|
|
345
|
+
await writeFile(live, makePng(100, 100, RED))
|
|
346
|
+
const buildPng = new PNG({ width: 100, height: 100 })
|
|
347
|
+
for (let i = 0; i < 100 * 100; i++) {
|
|
348
|
+
// 1% blue: just the first row (100 of 10000 pixels = 1%)
|
|
349
|
+
const isBlue = i < 100
|
|
350
|
+
buildPng.data[i * 4 + 0] = isBlue ? 0 : 255
|
|
351
|
+
buildPng.data[i * 4 + 1] = 0
|
|
352
|
+
buildPng.data[i * 4 + 2] = isBlue ? 255 : 0
|
|
353
|
+
buildPng.data[i * 4 + 3] = 255
|
|
354
|
+
}
|
|
355
|
+
await writeFile(build, PNG.sync.write(buildPng))
|
|
356
|
+
const out = join(dir, 'out')
|
|
357
|
+
const r = run([
|
|
358
|
+
'--live-screenshot', live,
|
|
359
|
+
'--build-screenshot', build,
|
|
360
|
+
'--output-dir', out,
|
|
361
|
+
'--threshold', '5',
|
|
362
|
+
])
|
|
363
|
+
assert.equal(r.status, 0, `exit was ${r.status}\nstderr: ${r.stderr}`)
|
|
364
|
+
const json = JSON.parse(r.stdout.trim())
|
|
365
|
+
assert.equal(json.passed, true)
|
|
366
|
+
assert.ok(json.diffPercent < 5, `diffPercent ${json.diffPercent} should be < 5`)
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
await test('integration: --crop-live-bbox crops live before diff', async () => {
|
|
370
|
+
// 30x10 live: rows 0-9 are red. Build is 10x10 red. Without crop, padding
|
|
371
|
+
// makes ~67% of pixels (cols 10-29) transparent-vs-red → big diff. With
|
|
372
|
+
// --crop-live-bbox 0,0,10,10 dpr=1, live becomes 10x10 red → 0% diff.
|
|
373
|
+
const dir = join(TMPROOT, 'crop-bbox')
|
|
374
|
+
await mkdir(dir, { recursive: true })
|
|
375
|
+
const live = join(dir, 'live.png')
|
|
376
|
+
const build = join(dir, 'build.png')
|
|
377
|
+
await writeFile(live, makePng(30, 10, RED))
|
|
378
|
+
await writeFile(build, makePng(10, 10, RED))
|
|
379
|
+
const out = join(dir, 'out')
|
|
380
|
+
const r = run([
|
|
381
|
+
'--live-screenshot', live,
|
|
382
|
+
'--build-screenshot', build,
|
|
383
|
+
'--output-dir', out,
|
|
384
|
+
'--crop-live-bbox', '0,0,10,10',
|
|
385
|
+
'--crop-live-dpr', '1',
|
|
386
|
+
'--threshold', '5',
|
|
387
|
+
])
|
|
388
|
+
assert.equal(r.status, 0, `exit was ${r.status}\nstderr: ${r.stderr}`)
|
|
389
|
+
const json = JSON.parse(r.stdout.trim())
|
|
390
|
+
assert.equal(json.passed, true)
|
|
391
|
+
assert.equal(json.diffPixels, 0)
|
|
392
|
+
assert.equal(json.dimensions.live.width, 10, 'live should be cropped to 10x10')
|
|
393
|
+
assert.equal(json.dimensions.live.height, 10)
|
|
394
|
+
assert.ok(json.croppedLiveTo, 'croppedLiveTo should be populated')
|
|
395
|
+
assert.equal(json.croppedLiveTo.w, 10)
|
|
396
|
+
assert.equal(json.croppedLiveTo.h, 10)
|
|
397
|
+
assert.equal(json.croppedLiveTo.dpr, 1)
|
|
398
|
+
const cropStat = await stat(join(out, 'live-cropped.png'))
|
|
399
|
+
assert.ok(cropStat.size > 0, 'live-cropped.png should be written')
|
|
400
|
+
})
|
|
401
|
+
|
|
402
|
+
await test('integration: --crop-live-bbox with dpr=3 multiplies coordinates', async () => {
|
|
403
|
+
// 30x30 live, build 9x9. Bbox 0,0,3,3 with dpr=3 → crop to 9x9 → 0% diff.
|
|
404
|
+
const dir = join(TMPROOT, 'crop-dpr')
|
|
405
|
+
await mkdir(dir, { recursive: true })
|
|
406
|
+
const live = join(dir, 'live.png')
|
|
407
|
+
const build = join(dir, 'build.png')
|
|
408
|
+
await writeFile(live, makePng(30, 30, RED))
|
|
409
|
+
await writeFile(build, makePng(9, 9, RED))
|
|
410
|
+
const out = join(dir, 'out')
|
|
411
|
+
const r = run([
|
|
412
|
+
'--live-screenshot', live,
|
|
413
|
+
'--build-screenshot', build,
|
|
414
|
+
'--output-dir', out,
|
|
415
|
+
'--crop-live-bbox', '0,0,3,3',
|
|
416
|
+
'--crop-live-dpr', '3',
|
|
417
|
+
])
|
|
418
|
+
assert.equal(r.status, 0, `exit was ${r.status}\nstderr: ${r.stderr}`)
|
|
419
|
+
const json = JSON.parse(r.stdout.trim())
|
|
420
|
+
assert.equal(json.dimensions.live.width, 9, 'dpr should multiply bbox by 3')
|
|
421
|
+
assert.equal(json.dimensions.live.height, 9)
|
|
422
|
+
assert.equal(json.croppedLiveTo.screenshotPx.width, 9)
|
|
423
|
+
assert.equal(json.croppedLiveTo.screenshotPx.height, 9)
|
|
424
|
+
})
|
|
425
|
+
|
|
426
|
+
await test('integration: --crop-live-bbox clamps when bbox extends past edge', async () => {
|
|
427
|
+
// Live is 10x10, bbox 0,0,20,20 dpr=1 → clamp to 10x10. No error, just clamp.
|
|
428
|
+
const dir = join(TMPROOT, 'crop-clamp')
|
|
429
|
+
await mkdir(dir, { recursive: true })
|
|
430
|
+
const live = join(dir, 'live.png')
|
|
431
|
+
const build = join(dir, 'build.png')
|
|
432
|
+
await writeFile(live, makePng(10, 10, RED))
|
|
433
|
+
await writeFile(build, makePng(10, 10, RED))
|
|
434
|
+
const out = join(dir, 'out')
|
|
435
|
+
const r = run([
|
|
436
|
+
'--live-screenshot', live,
|
|
437
|
+
'--build-screenshot', build,
|
|
438
|
+
'--output-dir', out,
|
|
439
|
+
'--crop-live-bbox', '0,0,20,20',
|
|
440
|
+
'--crop-live-dpr', '1',
|
|
441
|
+
])
|
|
442
|
+
assert.equal(r.status, 0, `exit was ${r.status}\nstderr: ${r.stderr}`)
|
|
443
|
+
const json = JSON.parse(r.stdout.trim())
|
|
444
|
+
assert.equal(json.dimensions.live.width, 10)
|
|
445
|
+
assert.equal(json.dimensions.live.height, 10)
|
|
446
|
+
assert.equal(json.croppedLiveTo.screenshotPx.width, 10, 'width should be clamped')
|
|
447
|
+
assert.equal(json.croppedLiveTo.screenshotPx.height, 10)
|
|
448
|
+
})
|
|
449
|
+
|
|
450
|
+
await test('integration: --crop-live-bbox entirely outside exits 1', async () => {
|
|
451
|
+
const dir = join(TMPROOT, 'crop-outside')
|
|
452
|
+
await mkdir(dir, { recursive: true })
|
|
453
|
+
const live = join(dir, 'live.png')
|
|
454
|
+
const build = join(dir, 'build.png')
|
|
455
|
+
await writeFile(live, makePng(10, 10, RED))
|
|
456
|
+
await writeFile(build, makePng(10, 10, RED))
|
|
457
|
+
const out = join(dir, 'out')
|
|
458
|
+
const r = run([
|
|
459
|
+
'--live-screenshot', live,
|
|
460
|
+
'--build-screenshot', build,
|
|
461
|
+
'--output-dir', out,
|
|
462
|
+
'--crop-live-bbox', '50,50,10,10',
|
|
463
|
+
'--crop-live-dpr', '1',
|
|
464
|
+
])
|
|
465
|
+
assert.equal(r.status, 1, `exit was ${r.status}\nstderr: ${r.stderr}`)
|
|
466
|
+
assert.match(r.stderr, /entirely outside/)
|
|
467
|
+
})
|
|
468
|
+
|
|
469
|
+
await test('integration: omitting --crop-live-bbox leaves croppedLiveTo null', async () => {
|
|
470
|
+
const dir = join(TMPROOT, 'crop-null')
|
|
471
|
+
await mkdir(dir, { recursive: true })
|
|
472
|
+
const live = join(dir, 'live.png')
|
|
473
|
+
const build = join(dir, 'build.png')
|
|
474
|
+
await writeFile(live, makePng(10, 10, RED))
|
|
475
|
+
await writeFile(build, makePng(10, 10, RED))
|
|
476
|
+
const out = join(dir, 'out')
|
|
477
|
+
const r = run([
|
|
478
|
+
'--live-screenshot', live,
|
|
479
|
+
'--build-screenshot', build,
|
|
480
|
+
'--output-dir', out,
|
|
481
|
+
])
|
|
482
|
+
assert.equal(r.status, 0)
|
|
483
|
+
const json = JSON.parse(r.stdout.trim())
|
|
484
|
+
assert.equal(json.croppedLiveTo, null)
|
|
485
|
+
assert.equal(json.croppedBuildTo, null)
|
|
486
|
+
})
|
|
487
|
+
|
|
488
|
+
await test('integration: --crop-build-bbox crops build before diff', async () => {
|
|
489
|
+
// 10x10 live, 10x30 build (build has dead viewport space below). Without
|
|
490
|
+
// crop-build, padding live makes ~67% diff. With crop-build 0,0,10,10
|
|
491
|
+
// dpr=1, build becomes 10x10 → 0% diff. Symmetric of crop-live.
|
|
492
|
+
const dir = join(TMPROOT, 'crop-build-bbox')
|
|
493
|
+
await mkdir(dir, { recursive: true })
|
|
494
|
+
const live = join(dir, 'live.png')
|
|
495
|
+
const build = join(dir, 'build.png')
|
|
496
|
+
await writeFile(live, makePng(10, 10, RED))
|
|
497
|
+
await writeFile(build, makePng(10, 30, RED))
|
|
498
|
+
const out = join(dir, 'out')
|
|
499
|
+
const r = run([
|
|
500
|
+
'--live-screenshot', live,
|
|
501
|
+
'--build-screenshot', build,
|
|
502
|
+
'--output-dir', out,
|
|
503
|
+
'--crop-build-bbox', '0,0,10,10',
|
|
504
|
+
'--crop-build-dpr', '1',
|
|
505
|
+
'--threshold', '5',
|
|
506
|
+
])
|
|
507
|
+
assert.equal(r.status, 0, `exit was ${r.status}\nstderr: ${r.stderr}`)
|
|
508
|
+
const json = JSON.parse(r.stdout.trim())
|
|
509
|
+
assert.equal(json.passed, true)
|
|
510
|
+
assert.equal(json.diffPixels, 0)
|
|
511
|
+
assert.equal(json.dimensions.build.width, 10)
|
|
512
|
+
assert.equal(json.dimensions.build.height, 10)
|
|
513
|
+
assert.ok(json.croppedBuildTo)
|
|
514
|
+
assert.equal(json.croppedBuildTo.w, 10)
|
|
515
|
+
assert.equal(json.croppedBuildTo.h, 10)
|
|
516
|
+
const cropStat = await stat(join(out, 'build-cropped.png'))
|
|
517
|
+
assert.ok(cropStat.size > 0, 'build-cropped.png should be written')
|
|
518
|
+
})
|
|
519
|
+
|
|
520
|
+
await test('integration: --crop-build-bbox with dpr=3 multiplies coordinates', async () => {
|
|
521
|
+
// 30x30 build, live 9x9. Bbox 0,0,3,3 dpr=3 → crop build to 9x9 → 0% diff.
|
|
522
|
+
const dir = join(TMPROOT, 'crop-build-dpr')
|
|
523
|
+
await mkdir(dir, { recursive: true })
|
|
524
|
+
const live = join(dir, 'live.png')
|
|
525
|
+
const build = join(dir, 'build.png')
|
|
526
|
+
await writeFile(live, makePng(9, 9, RED))
|
|
527
|
+
await writeFile(build, makePng(30, 30, RED))
|
|
528
|
+
const out = join(dir, 'out')
|
|
529
|
+
const r = run([
|
|
530
|
+
'--live-screenshot', live,
|
|
531
|
+
'--build-screenshot', build,
|
|
532
|
+
'--output-dir', out,
|
|
533
|
+
'--crop-build-bbox', '0,0,3,3',
|
|
534
|
+
'--crop-build-dpr', '3',
|
|
535
|
+
])
|
|
536
|
+
assert.equal(r.status, 0, `exit was ${r.status}\nstderr: ${r.stderr}`)
|
|
537
|
+
const json = JSON.parse(r.stdout.trim())
|
|
538
|
+
assert.equal(json.dimensions.build.width, 9, 'dpr should multiply build bbox by 3')
|
|
539
|
+
assert.equal(json.dimensions.build.height, 9)
|
|
540
|
+
assert.equal(json.croppedBuildTo.screenshotPx.width, 9)
|
|
541
|
+
assert.equal(json.croppedBuildTo.screenshotPx.height, 9)
|
|
542
|
+
})
|
|
543
|
+
|
|
544
|
+
await test('integration: --crop-build-bbox clamps when bbox extends past edge', async () => {
|
|
545
|
+
const dir = join(TMPROOT, 'crop-build-clamp')
|
|
546
|
+
await mkdir(dir, { recursive: true })
|
|
547
|
+
const live = join(dir, 'live.png')
|
|
548
|
+
const build = join(dir, 'build.png')
|
|
549
|
+
await writeFile(live, makePng(10, 10, RED))
|
|
550
|
+
await writeFile(build, makePng(10, 10, RED))
|
|
551
|
+
const out = join(dir, 'out')
|
|
552
|
+
const r = run([
|
|
553
|
+
'--live-screenshot', live,
|
|
554
|
+
'--build-screenshot', build,
|
|
555
|
+
'--output-dir', out,
|
|
556
|
+
'--crop-build-bbox', '0,0,20,20',
|
|
557
|
+
'--crop-build-dpr', '1',
|
|
558
|
+
])
|
|
559
|
+
assert.equal(r.status, 0, `exit was ${r.status}\nstderr: ${r.stderr}`)
|
|
560
|
+
const json = JSON.parse(r.stdout.trim())
|
|
561
|
+
assert.equal(json.croppedBuildTo.screenshotPx.width, 10)
|
|
562
|
+
assert.equal(json.croppedBuildTo.screenshotPx.height, 10)
|
|
563
|
+
})
|
|
564
|
+
|
|
565
|
+
await test('integration: --crop-build-bbox entirely outside exits 1', async () => {
|
|
566
|
+
const dir = join(TMPROOT, 'crop-build-outside')
|
|
567
|
+
await mkdir(dir, { recursive: true })
|
|
568
|
+
const live = join(dir, 'live.png')
|
|
569
|
+
const build = join(dir, 'build.png')
|
|
570
|
+
await writeFile(live, makePng(10, 10, RED))
|
|
571
|
+
await writeFile(build, makePng(10, 10, RED))
|
|
572
|
+
const out = join(dir, 'out')
|
|
573
|
+
const r = run([
|
|
574
|
+
'--live-screenshot', live,
|
|
575
|
+
'--build-screenshot', build,
|
|
576
|
+
'--output-dir', out,
|
|
577
|
+
'--crop-build-bbox', '50,50,10,10',
|
|
578
|
+
'--crop-build-dpr', '1',
|
|
579
|
+
])
|
|
580
|
+
assert.equal(r.status, 1, `exit was ${r.status}\nstderr: ${r.stderr}`)
|
|
581
|
+
assert.match(r.stderr, /entirely outside/)
|
|
582
|
+
})
|
|
583
|
+
|
|
584
|
+
await test('integration: both --crop-live-bbox and --crop-build-bbox apply together', async () => {
|
|
585
|
+
// 30x30 live, 30x30 build, both red. Crop live to 0,0,10,10 dpr=1, crop
|
|
586
|
+
// build to 0,0,10,10 dpr=1 → both 10x10 red → 0% diff. Verifies the two
|
|
587
|
+
// crops are independent and both write debug PNGs.
|
|
588
|
+
const dir = join(TMPROOT, 'crop-both')
|
|
589
|
+
await mkdir(dir, { recursive: true })
|
|
590
|
+
const live = join(dir, 'live.png')
|
|
591
|
+
const build = join(dir, 'build.png')
|
|
592
|
+
await writeFile(live, makePng(30, 30, RED))
|
|
593
|
+
await writeFile(build, makePng(30, 30, RED))
|
|
594
|
+
const out = join(dir, 'out')
|
|
595
|
+
const r = run([
|
|
596
|
+
'--live-screenshot', live,
|
|
597
|
+
'--build-screenshot', build,
|
|
598
|
+
'--output-dir', out,
|
|
599
|
+
'--crop-live-bbox', '0,0,10,10',
|
|
600
|
+
'--crop-live-dpr', '1',
|
|
601
|
+
'--crop-build-bbox', '0,0,10,10',
|
|
602
|
+
'--crop-build-dpr', '1',
|
|
603
|
+
])
|
|
604
|
+
assert.equal(r.status, 0, `exit was ${r.status}\nstderr: ${r.stderr}`)
|
|
605
|
+
const json = JSON.parse(r.stdout.trim())
|
|
606
|
+
assert.equal(json.diffPixels, 0)
|
|
607
|
+
assert.equal(json.dimensions.live.width, 10)
|
|
608
|
+
assert.equal(json.dimensions.build.width, 10)
|
|
609
|
+
assert.ok(json.croppedLiveTo)
|
|
610
|
+
assert.ok(json.croppedBuildTo)
|
|
611
|
+
const liveStat = await stat(join(out, 'live-cropped.png'))
|
|
612
|
+
const buildStat = await stat(join(out, 'build-cropped.png'))
|
|
613
|
+
assert.ok(liveStat.size > 0)
|
|
614
|
+
assert.ok(buildStat.size > 0)
|
|
615
|
+
})
|
|
616
|
+
|
|
617
|
+
// Cleanup
|
|
618
|
+
await rm(TMPROOT, { recursive: true, force: true }).catch(() => {})
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
if (failed > 0) {
|
|
622
|
+
process.stdout.write(`\n${failed} failed, ${passed} passed\n`)
|
|
623
|
+
process.exit(1)
|
|
624
|
+
}
|
|
625
|
+
process.stdout.write(`\n${passed} passed\n`)
|
|
626
|
+
process.exit(0)
|