@fusion.dev/helix-cli 0.190.0 → 0.191.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/package.json +6 -6
- package/visual/README.md +51 -3
- package/visual/driver.mjs +180 -8
- package/visual/index.d.ts +111 -3
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fusion.dev/helix-cli",
|
|
3
3
|
"description": "The Helix command line, for a workspace that reaches it through npm, and the visual tests it runs.",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.191.0",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Ross Sheppard <ross.sheppard@fusion.dev>",
|
|
7
7
|
"repository": {
|
|
@@ -41,11 +41,11 @@
|
|
|
41
41
|
"visual-regression"
|
|
42
42
|
],
|
|
43
43
|
"optionalDependencies": {
|
|
44
|
-
"@fusion.dev/helix-cli-darwin-arm64": "0.
|
|
45
|
-
"@fusion.dev/helix-cli-darwin-x64": "0.
|
|
46
|
-
"@fusion.dev/helix-cli-linux-arm64": "0.
|
|
47
|
-
"@fusion.dev/helix-cli-linux-x64": "0.
|
|
48
|
-
"@fusion.dev/helix-cli-win32-x64": "0.
|
|
44
|
+
"@fusion.dev/helix-cli-darwin-arm64": "0.191.0",
|
|
45
|
+
"@fusion.dev/helix-cli-darwin-x64": "0.191.0",
|
|
46
|
+
"@fusion.dev/helix-cli-linux-arm64": "0.191.0",
|
|
47
|
+
"@fusion.dev/helix-cli-linux-x64": "0.191.0",
|
|
48
|
+
"@fusion.dev/helix-cli-win32-x64": "0.191.0"
|
|
49
49
|
},
|
|
50
50
|
"peerDependencies": {
|
|
51
51
|
"playwright": ">=1.50"
|
package/visual/README.md
CHANGED
|
@@ -76,7 +76,7 @@ export default defineConfig({
|
|
|
76
76
|
})
|
|
77
77
|
```
|
|
78
78
|
|
|
79
|
-
- **`sources`**: where the entries come from,
|
|
79
|
+
- **`sources`**: where the entries come from, five kinds:
|
|
80
80
|
- `html` reads every file its `glob` matches under `root`, described next.
|
|
81
81
|
- `server` pictures a server's `routes`, and every route its `sitemap` lists.
|
|
82
82
|
`/` is named `index`, and a route given as `{ name, path }` takes its name.
|
|
@@ -84,6 +84,7 @@ export default defineConfig({
|
|
|
84
84
|
`include` and `exclude` filter them by tag.
|
|
85
85
|
- `catalog` pictures every `{ id, url }` in a JSON file, each `url` relative to
|
|
86
86
|
the server.
|
|
87
|
+
- `pairs` compares two renders of each entry with each other, described below.
|
|
87
88
|
|
|
88
89
|
The three on a server take its `url`, a `start` command that runs only when
|
|
89
90
|
nothing answers there and is stopped when the run ends, and `baselines`, the
|
|
@@ -102,6 +103,19 @@ export default defineConfig({
|
|
|
102
103
|
ends in `/` serves a directory.
|
|
103
104
|
- **`settle.readyProperty`**: a property your elements carry holding a promise
|
|
104
105
|
that resolves once they have rendered. `helix visual` awaits it before each shot.
|
|
106
|
+
`settle.elements` limits the custom elements a run waits for to one tag
|
|
107
|
+
prefix, for a framework that writes hyphenated tags it never defines.
|
|
108
|
+
- **`aria`**: keeps each entry's accessibility snapshot, Playwright's
|
|
109
|
+
`ariaSnapshot()` of its page, beside its baselines as `<stem>.aria.yml`, and
|
|
110
|
+
compares it as text, so a lost label fails even when no pixel moved. A
|
|
111
|
+
snapshot leaves out what `mask` names. `{ hide: [...] }` keeps snapshots and
|
|
112
|
+
leaves out more, for what is read aloud and never drawn, such as a table
|
|
113
|
+
hidden visually: masked, its box would be painted over whatever it sits
|
|
114
|
+
under.
|
|
115
|
+
- **`checks.code`**: fails a run on a code block that draws all of its text in
|
|
116
|
+
one color, as a highlighter that failed leaves it. It reads the page after
|
|
117
|
+
the first shot and needs no baseline. `selector` names your code blocks and
|
|
118
|
+
`minimum` the fewest characters a block holds before it is judged.
|
|
105
119
|
- **`diff`**: how a shot is compared, for the whole config or for one source.
|
|
106
120
|
`mode: 'pixel'` counts the pixels past `threshold`, 0.1 unless you set it,
|
|
107
121
|
and fails above `pixels`, 0 unless you set it. `mode: 'perceptual'` scores
|
|
@@ -110,7 +124,8 @@ export default defineConfig({
|
|
|
110
124
|
- **`exceptions`**: each names an `entry`, optionally a `shot`, and a `reason`.
|
|
111
125
|
`unstable: true` skips the shot and names it in every run. `pixels` lets the
|
|
112
126
|
shot differ by that many, and the run fails once the shot stops differing, so
|
|
113
|
-
an exception that is no longer needed is removed.
|
|
127
|
+
an exception that is no longer needed is removed. `differs: true` tolerates a
|
|
128
|
+
known difference between a pair's two sides, and fails once they match.
|
|
114
129
|
- **`inspect`**: `run` is called with the Playwright page after each entry's
|
|
115
130
|
first shot, and `report` once at the end with everything `run` returned.
|
|
116
131
|
`report` returns `{ failed: true }` to fail the run.
|
|
@@ -119,7 +134,8 @@ export default defineConfig({
|
|
|
119
134
|
- **`timings`**: a file to write each entry name's seconds to, which is how you
|
|
120
135
|
measure `shard.cost`.
|
|
121
136
|
- **`mask`**: selectors whose elements are painted over in one fixed color in
|
|
122
|
-
every picture
|
|
137
|
+
every picture and left out of every accessibility snapshot, for what changes
|
|
138
|
+
on its own, such as a version number.
|
|
123
139
|
- **`style`**: a stylesheet applied while each picture is taken, to hold still
|
|
124
140
|
what `mask` cannot, such as a masked element whose width follows its text.
|
|
125
141
|
- **`report`**: where each run writes its report, `.helix/visual` unless you
|
|
@@ -128,6 +144,38 @@ export default defineConfig({
|
|
|
128
144
|
when a run fails.
|
|
129
145
|
- **`fingerprint`**, **`network`**, and **`platform`** are described below.
|
|
130
146
|
|
|
147
|
+
## Pairs
|
|
148
|
+
|
|
149
|
+
A `pairs` source compares two renders of each entry with each other, with no
|
|
150
|
+
baseline: React against plain HTML, a server render against a client render, a
|
|
151
|
+
framework wrapper against the element it wraps. Both sides render on one machine
|
|
152
|
+
seconds apart, so the platform cancels out. Pairs run anywhere, and nothing they
|
|
153
|
+
picture is committed.
|
|
154
|
+
|
|
155
|
+
```ts
|
|
156
|
+
{
|
|
157
|
+
kind: 'pairs',
|
|
158
|
+
labels: ['HTML', 'TSX'],
|
|
159
|
+
name: 'parity',
|
|
160
|
+
prepare: async ({ changed }) => ({
|
|
161
|
+
pairs: [{ a: { body: html }, b: { body: '<div id="root"></div>' }, name: 'button' }],
|
|
162
|
+
serve: { '/bundle/': '/absolute/path/to/bundles/' },
|
|
163
|
+
}),
|
|
164
|
+
}
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
- **`prepare`** builds the pairs. It runs in Node, so it can call your bundler
|
|
168
|
+
or your server renderer, and it receives the git ref `--changed` named. Each
|
|
169
|
+
side is a `body` the harness wraps, with its own `styles`, `scripts`, `head`,
|
|
170
|
+
`dir`, and `files`, or a `url`, or an `error` when it could not be built.
|
|
171
|
+
- **`check`** is called on each side's page after its first shot, with the page
|
|
172
|
+
and every console message it logged, and returns problems that fail the pair.
|
|
173
|
+
- **`errors`** counts console errors: `new`, an error the second side logs and
|
|
174
|
+
the first does not, is the default, `any` counts all of them, and `ignore`
|
|
175
|
+
none.
|
|
176
|
+
- **`compare: false`** judges a pair by its `check`, its errors, and its tags
|
|
177
|
+
alone, with no pictures compared.
|
|
178
|
+
|
|
131
179
|
## What every run does
|
|
132
180
|
|
|
133
181
|
- **Capture holds still.** Before each shot `helix visual` waits for every custom
|
package/visual/driver.mjs
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* stay here, where they run.
|
|
12
12
|
* - `fetch` and `wait` ask a server for a page, or wait for it to answer, so
|
|
13
13
|
* the binary needs no HTTP of its own.
|
|
14
|
+
* - `prepare` runs a pairs source's `prepare` and answers with its pairs.
|
|
14
15
|
* - `start` serves the harness origin and launches Chromium.
|
|
15
16
|
* - `capture` pictures entries, answering `shot` for each picture as it is
|
|
16
17
|
* taken, `item` when an entry is done, and `captured` at the end.
|
|
@@ -116,6 +117,8 @@ const state = {
|
|
|
116
117
|
origins: new Set(),
|
|
117
118
|
/** `/source/name/` to the directory relative requests resolve in, and its renames. */
|
|
118
119
|
pages: new Map(),
|
|
120
|
+
/** Each pairs source's `prepare` and `check`, by the source's name. */
|
|
121
|
+
pairs: new Map(),
|
|
119
122
|
/** What `inspect.run` returned for each entry, for `inspect.report`. */
|
|
120
123
|
runs: [],
|
|
121
124
|
serve: [],
|
|
@@ -156,7 +159,8 @@ const lookup = (path, document) => {
|
|
|
156
159
|
if (rest === '') {
|
|
157
160
|
return { html: state.documents.get(document) }
|
|
158
161
|
}
|
|
159
|
-
|
|
162
|
+
/* A side with no directory of its own serves nothing beside it. */
|
|
163
|
+
return { file: page.dir ? inside(page.dir, page.files[rest] ?? rest) : undefined }
|
|
160
164
|
}
|
|
161
165
|
if (at === 0) {
|
|
162
166
|
break
|
|
@@ -288,10 +292,92 @@ const within = (seconds, promise, what) => {
|
|
|
288
292
|
return Promise.race([promise, expired]).finally(() => clearTimeout(timer))
|
|
289
293
|
}
|
|
290
294
|
|
|
295
|
+
/**
|
|
296
|
+
* The accessibility snapshot of the page, without what `mask` and `aria.hide`
|
|
297
|
+
* name.
|
|
298
|
+
*
|
|
299
|
+
* **What a run paints over, it does not judge in the snapshot either.** A
|
|
300
|
+
* masked version number is still text in the accessibility tree, so every
|
|
301
|
+
* release would change the snapshot that the mask kept out of the picture.
|
|
302
|
+
* Each masked element is given `aria-hidden` for the snapshot, and the value
|
|
303
|
+
* it had is put back after.
|
|
304
|
+
*/
|
|
305
|
+
const ariaSnapshot = async (page, mask) => {
|
|
306
|
+
const MARK = 'data-helix-visual-aria-hidden'
|
|
307
|
+
for (const selector of mask) {
|
|
308
|
+
await page.locator(selector).evaluateAll((elements, mark) => {
|
|
309
|
+
for (const element of elements) {
|
|
310
|
+
element.setAttribute(mark, element.getAttribute('aria-hidden') ?? '')
|
|
311
|
+
element.setAttribute('aria-hidden', 'true')
|
|
312
|
+
}
|
|
313
|
+
}, MARK)
|
|
314
|
+
}
|
|
315
|
+
try {
|
|
316
|
+
return `${await page.locator('body').ariaSnapshot()}\n`
|
|
317
|
+
} finally {
|
|
318
|
+
/* A locator, as above, so an element inside a shadow root is found too. */
|
|
319
|
+
await page.locator(`[${MARK}]`).evaluateAll((elements, mark) => {
|
|
320
|
+
for (const element of elements) {
|
|
321
|
+
const was = element.getAttribute(mark)
|
|
322
|
+
if (was === '') {
|
|
323
|
+
element.removeAttribute('aria-hidden')
|
|
324
|
+
} else {
|
|
325
|
+
element.setAttribute('aria-hidden', was)
|
|
326
|
+
}
|
|
327
|
+
element.removeAttribute(mark)
|
|
328
|
+
}
|
|
329
|
+
}, MARK)
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Each code block that draws all of its text in one color, as a sentence.
|
|
335
|
+
*
|
|
336
|
+
* **A highlighter that fails leaves nothing to say so.** On October 8, 2026
|
|
337
|
+
* every code block on Fusion's site lost its colors because two copies of the
|
|
338
|
+
* highlighting library were installed: the parser tagged tokens with one and
|
|
339
|
+
* the styles read the other, nothing threw, and the text came out plain. A
|
|
340
|
+
* block of `minimum` characters or more whose every character resolves to one
|
|
341
|
+
* color is that fault, and it needs no baseline to see.
|
|
342
|
+
*/
|
|
343
|
+
const codeBlocks = (page, { minimum, selector }) =>
|
|
344
|
+
page.evaluate(
|
|
345
|
+
({ minimum, selector }) => {
|
|
346
|
+
const found = []
|
|
347
|
+
for (const block of document.querySelectorAll(selector)) {
|
|
348
|
+
const colors = new Set()
|
|
349
|
+
let characters = 0
|
|
350
|
+
const walker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT)
|
|
351
|
+
for (let node = walker.nextNode(); node !== null; node = walker.nextNode()) {
|
|
352
|
+
const text = node.textContent.replace(/\s+/g, '')
|
|
353
|
+
if (text === '' || node.parentElement === null) {
|
|
354
|
+
continue
|
|
355
|
+
}
|
|
356
|
+
colors.add(getComputedStyle(node.parentElement).color)
|
|
357
|
+
characters += text.length
|
|
358
|
+
}
|
|
359
|
+
if (characters >= minimum && colors.size === 1) {
|
|
360
|
+
const sample = block.textContent.trim().replace(/\s+/g, ' ').slice(0, 40)
|
|
361
|
+
found.push(
|
|
362
|
+
`a code block draws its ${characters} characters in one color, ${[...colors][0]}: "${sample}"`,
|
|
363
|
+
)
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
return found
|
|
367
|
+
},
|
|
368
|
+
{ minimum, selector },
|
|
369
|
+
)
|
|
370
|
+
|
|
291
371
|
/** One entry, every shot of it from one navigation. */
|
|
292
|
-
const take = async (
|
|
372
|
+
const take = async (lane, item, { elements, readyProperty }, progress) => {
|
|
373
|
+
const { page } = lane
|
|
293
374
|
const started = performance.now()
|
|
294
375
|
let error = null
|
|
376
|
+
let problems = []
|
|
377
|
+
let findings = []
|
|
378
|
+
let aria = null
|
|
379
|
+
lane.errors.length = 0
|
|
380
|
+
lane.messages.length = 0
|
|
295
381
|
try {
|
|
296
382
|
for (const [index, shot] of item.shots.entries()) {
|
|
297
383
|
progress.stage = index === 0 ? 'loading the page' : `settling for ${shot.name}`
|
|
@@ -356,6 +442,24 @@ const take = async (page, item, { elements, readyProperty }, progress) => {
|
|
|
356
442
|
result: await state.config.inspect.run({ entry: item.entry, page }),
|
|
357
443
|
})
|
|
358
444
|
}
|
|
445
|
+
/* The page checks and the accessibility snapshot, on the page that was
|
|
446
|
+
pictured, after its first picture. */
|
|
447
|
+
if (index === 0 && item.checks?.code) {
|
|
448
|
+
progress.stage = 'checking its code blocks'
|
|
449
|
+
findings = findings.concat(await codeBlocks(page, item.checks.code))
|
|
450
|
+
}
|
|
451
|
+
if (index === 0 && item.aria) {
|
|
452
|
+
progress.stage = 'taking its accessibility snapshot'
|
|
453
|
+
aria = await ariaSnapshot(page, [...state.mask, ...state.hide])
|
|
454
|
+
}
|
|
455
|
+
/* A pair's own check, on each side's page after its first picture. */
|
|
456
|
+
if (index === 0 && item.check) {
|
|
457
|
+
progress.stage = 'checking the page'
|
|
458
|
+
const found = await state.pairs
|
|
459
|
+
.get(item.check)
|
|
460
|
+
.check({ console: [...lane.messages], entry: item.entry, page })
|
|
461
|
+
problems = (Array.isArray(found) ? found : found ? [found] : []).map(String)
|
|
462
|
+
}
|
|
359
463
|
}
|
|
360
464
|
} catch (caught) {
|
|
361
465
|
error = caught instanceof Error ? caught.message.split('\n')[0] : String(caught)
|
|
@@ -367,7 +471,16 @@ const take = async (page, item, { elements, readyProperty }, progress) => {
|
|
|
367
471
|
console.error(
|
|
368
472
|
`helix: ${item.key}: ${error === null ? `${item.shots.length} ${item.shots.length === 1 ? 'shot' : 'shots'}` : 'failed'} in ${seconds.toFixed(1)} s`,
|
|
369
473
|
)
|
|
370
|
-
say({
|
|
474
|
+
say({
|
|
475
|
+
aria,
|
|
476
|
+
error,
|
|
477
|
+
errors: item.errors ? [...lane.errors] : [],
|
|
478
|
+
findings,
|
|
479
|
+
key: item.key,
|
|
480
|
+
op: 'item',
|
|
481
|
+
problems,
|
|
482
|
+
seconds,
|
|
483
|
+
})
|
|
371
484
|
}
|
|
372
485
|
|
|
373
486
|
/**
|
|
@@ -392,7 +505,7 @@ const bounded = async (lane, item, settle) => {
|
|
|
392
505
|
const expired = new Promise((done) => {
|
|
393
506
|
timer = setTimeout(() => done(true), limit * 1000)
|
|
394
507
|
})
|
|
395
|
-
const finished = await Promise.race([take(lane
|
|
508
|
+
const finished = await Promise.race([take(lane, item, settle, progress), expired])
|
|
396
509
|
clearTimeout(timer)
|
|
397
510
|
if (finished !== true) {
|
|
398
511
|
return
|
|
@@ -403,7 +516,24 @@ const bounded = async (lane, item, settle) => {
|
|
|
403
516
|
say({ error: message, key: item.key, op: 'item', seconds: limit })
|
|
404
517
|
const context = lane.page.context()
|
|
405
518
|
await lane.page.close().catch(() => {})
|
|
406
|
-
lane.page = await context.newPage()
|
|
519
|
+
lane.page = await listened(lane, await context.newPage())
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* `page`, with every console message, and the errors and uncaught exceptions
|
|
524
|
+
* apart, recorded on `lane`, which `take` empties before each entry. A pair
|
|
525
|
+
* names an error one side logged and the other did not, and a pairs source's
|
|
526
|
+
* `check` is handed the messages, a warning being something only it can judge.
|
|
527
|
+
*/
|
|
528
|
+
const listened = (lane, page) => {
|
|
529
|
+
page.on('console', (message) => {
|
|
530
|
+
lane.messages.push({ text: message.text(), type: message.type() })
|
|
531
|
+
if (message.type() === 'error') {
|
|
532
|
+
lane.errors.push(message.text())
|
|
533
|
+
}
|
|
534
|
+
})
|
|
535
|
+
page.on('pageerror', (caught) => lane.errors.push(caught.message))
|
|
536
|
+
return page
|
|
407
537
|
}
|
|
408
538
|
|
|
409
539
|
/**
|
|
@@ -437,7 +567,9 @@ const lanes = async (count) => {
|
|
|
437
567
|
return state.origins.has(origin) ? route.continue() : route.abort()
|
|
438
568
|
})
|
|
439
569
|
}
|
|
440
|
-
|
|
570
|
+
const lane = { errors: [], messages: [] }
|
|
571
|
+
lane.page = listened(lane, await context.newPage())
|
|
572
|
+
state.lanes.push(lane)
|
|
441
573
|
}
|
|
442
574
|
return state.lanes.slice(0, count)
|
|
443
575
|
}
|
|
@@ -481,9 +613,30 @@ const handlers = {
|
|
|
481
613
|
)
|
|
482
614
|
}
|
|
483
615
|
state.config = config
|
|
616
|
+
/* A pairs source's functions stay here and are reported as given. */
|
|
617
|
+
const sources = (config.sources ?? []).map((source) => {
|
|
618
|
+
if (source?.kind !== 'pairs') {
|
|
619
|
+
return source
|
|
620
|
+
}
|
|
621
|
+
if (typeof source.prepare !== 'function') {
|
|
622
|
+
throw new Error(
|
|
623
|
+
`the pairs source \`${source.name}\` needs \`prepare\`, a function that returns its pairs`,
|
|
624
|
+
)
|
|
625
|
+
}
|
|
626
|
+
if (source.check !== undefined && typeof source.check !== 'function') {
|
|
627
|
+
throw new Error(
|
|
628
|
+
`the pairs source \`${source.name}\` gives a \`check\` that is not a function`,
|
|
629
|
+
)
|
|
630
|
+
}
|
|
631
|
+
state.pairs.set(source.name, { check: source.check, prepare: source.prepare })
|
|
632
|
+
return { ...source, check: source.check !== undefined, prepare: true }
|
|
633
|
+
})
|
|
484
634
|
const { inspect, ...values } = config
|
|
485
635
|
say({
|
|
486
|
-
config: {
|
|
636
|
+
config: {
|
|
637
|
+
...JSON.parse(JSON.stringify({ ...values, sources })),
|
|
638
|
+
inspect: inspect !== undefined,
|
|
639
|
+
},
|
|
487
640
|
op: 'config',
|
|
488
641
|
})
|
|
489
642
|
},
|
|
@@ -552,7 +705,26 @@ const handlers = {
|
|
|
552
705
|
say({ failed: Boolean(reported?.failed), op: 'finished' })
|
|
553
706
|
},
|
|
554
707
|
|
|
555
|
-
async
|
|
708
|
+
async prepare({ changed, source }) {
|
|
709
|
+
const pair = state.pairs.get(source)
|
|
710
|
+
if (pair === undefined) {
|
|
711
|
+
throw new Error(`no pairs source is called \`${source}\``)
|
|
712
|
+
}
|
|
713
|
+
const made = await pair.prepare({ changed: changed ?? undefined })
|
|
714
|
+
const answer = Array.isArray(made) ? { pairs: made } : (made ?? {})
|
|
715
|
+
if (!Array.isArray(answer.pairs)) {
|
|
716
|
+
throw new Error(`\`prepare\` for \`${source}\` returned no \`pairs\` array`)
|
|
717
|
+
}
|
|
718
|
+
say({
|
|
719
|
+
note: answer.note ?? null,
|
|
720
|
+
op: 'prepared',
|
|
721
|
+
pairs: answer.pairs,
|
|
722
|
+
serve: answer.serve ?? {},
|
|
723
|
+
})
|
|
724
|
+
},
|
|
725
|
+
|
|
726
|
+
async start({ hide = [], mask = [], network, origins = [], serve, style = null, workers }) {
|
|
727
|
+
state.hide = hide
|
|
556
728
|
state.mask = mask
|
|
557
729
|
state.style = style ?? undefined
|
|
558
730
|
state.network = network
|
package/visual/index.d.ts
CHANGED
|
@@ -129,8 +129,97 @@ export interface CatalogSource extends Served {
|
|
|
129
129
|
kind: 'catalog'
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
+
/** One side of a pair: a document the harness writes, or a page at an address. */
|
|
133
|
+
export interface Side {
|
|
134
|
+
/** The markup of the page's body. */
|
|
135
|
+
body?: string
|
|
136
|
+
/** The directory relative requests from the page resolve in. Nothing is served beside a side with none. */
|
|
137
|
+
dir?: string
|
|
138
|
+
/** Why the side could not be built, such as a server render that threw. The pair fails with it, unpictured. */
|
|
139
|
+
error?: string
|
|
140
|
+
/** Relative requests answered by another file in `dir`: `{ 'styles.css': 'preview.css' }`. */
|
|
141
|
+
files?: Record<string, string>
|
|
142
|
+
/** Markup placed after the harness style and before the deferred scripts, such as a style of the side's own. */
|
|
143
|
+
head?: string
|
|
144
|
+
/** URLs of scripts the page loads, each with `defer`. */
|
|
145
|
+
scripts?: string[]
|
|
146
|
+
/** URLs of stylesheets the page links, before the harness style. */
|
|
147
|
+
styles?: string[]
|
|
148
|
+
/** A page to load in place of a document, such as a server-rendered route. */
|
|
149
|
+
url?: string
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Two sides of one entry, compared with each other. */
|
|
153
|
+
export interface Pair {
|
|
154
|
+
/** The first side. */
|
|
155
|
+
a: Side
|
|
156
|
+
/** The second side. */
|
|
157
|
+
b: Side
|
|
158
|
+
/** What the pair is packed into shards and timed by, such as its component family. Its name when not given. */
|
|
159
|
+
group?: string
|
|
160
|
+
/** The second half of the pair's id, `<source>/<name>`. May contain `/`. */
|
|
161
|
+
name: string
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** What a pairs source's `prepare` returns: its pairs, or its pairs with files to serve and a note to print. */
|
|
165
|
+
export type Prepared =
|
|
166
|
+
| Pair[]
|
|
167
|
+
| {
|
|
168
|
+
/** A sentence printed with the run, such as what a scoped run left out. */
|
|
169
|
+
note?: string
|
|
170
|
+
/** The pairs. */
|
|
171
|
+
pairs: Pair[]
|
|
172
|
+
/** URL paths served from absolute paths on disk, such as a directory of bundles. */
|
|
173
|
+
serve?: Record<string, string>
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Entries with two sides, compared with each other at every shot and with no baseline: React against
|
|
178
|
+
* plain HTML, a server render against a client render, a framework wrapper against the element it wraps.
|
|
179
|
+
* Both sides render on one machine seconds apart, so the platform cancels out and nothing is committed.
|
|
180
|
+
*/
|
|
181
|
+
export interface PairsSource {
|
|
182
|
+
/**
|
|
183
|
+
* Called on each side's page after its first shot. Returns problems that fail the pair, or none.
|
|
184
|
+
* `console` is every message the page logged.
|
|
185
|
+
*/
|
|
186
|
+
check?: (context: {
|
|
187
|
+
/** Every console message the page logged, in order. */
|
|
188
|
+
console: { text: string; type: string }[]
|
|
189
|
+
/** The pair, and `side`, the label of the side on the page. */
|
|
190
|
+
entry: Entry & { side: string }
|
|
191
|
+
/** The Playwright page. */
|
|
192
|
+
page: Page
|
|
193
|
+
}) => Promise<string[] | undefined> | string[] | undefined
|
|
194
|
+
/** Whether the two sides' pictures are compared. `true` when not given. */
|
|
195
|
+
compare?: boolean
|
|
196
|
+
/** The diff for this source's shots, in place of the config's. */
|
|
197
|
+
diff?: Diff
|
|
198
|
+
/**
|
|
199
|
+
* Which console errors fail a pair. `new`, an error the second side logs and the first does not, is the
|
|
200
|
+
* default. `any` counts every error on either side, and `ignore` none.
|
|
201
|
+
*/
|
|
202
|
+
errors?: 'any' | 'ignore' | 'new'
|
|
203
|
+
/** The harness's colors and inset, or `false` to picture each side as it is. */
|
|
204
|
+
harness?: boolean | Harness
|
|
205
|
+
/** Picks this source out of the others. */
|
|
206
|
+
kind: 'pairs'
|
|
207
|
+
/** What the two sides are called in a report. `['a', 'b']` when not given. */
|
|
208
|
+
labels?: [string, string]
|
|
209
|
+
/** The first half of each entry's id. One word. */
|
|
210
|
+
name: string
|
|
211
|
+
/**
|
|
212
|
+
* Builds the pairs. `changed` is the git ref `--changed` named, for a source that decides what a change
|
|
213
|
+
* since it reaches.
|
|
214
|
+
*/
|
|
215
|
+
prepare: (context: {
|
|
216
|
+
/** The git ref `--changed` named, when it was given. */
|
|
217
|
+
changed?: string
|
|
218
|
+
}) => Promise<Prepared> | Prepared
|
|
219
|
+
}
|
|
220
|
+
|
|
132
221
|
/** Any source of entries. */
|
|
133
|
-
export type Source = CatalogSource | HtmlSource | ServerSource | StorybookSource
|
|
222
|
+
export type Source = CatalogSource | HtmlSource | PairsSource | ServerSource | StorybookSource
|
|
134
223
|
|
|
135
224
|
/** An entry as `inspect` sees it. */
|
|
136
225
|
export interface Entry {
|
|
@@ -177,6 +266,8 @@ export interface Inspect<Result = unknown> {
|
|
|
177
266
|
|
|
178
267
|
/** One exception to the comparison, with the reason it exists. */
|
|
179
268
|
export interface Exception {
|
|
269
|
+
/** A known difference between a pair's two sides, tolerated however large. It fails the run once the two match on two takes. */
|
|
270
|
+
differs?: boolean
|
|
180
271
|
/** An entry's id, `styled/aura`, or its name in every source, `aura`. */
|
|
181
272
|
entry: string
|
|
182
273
|
/** What was measured to justify it, said beside it in every run. */
|
|
@@ -193,6 +284,22 @@ export interface Exception {
|
|
|
193
284
|
|
|
194
285
|
/** A `visual.config.ts`. */
|
|
195
286
|
export interface Config {
|
|
287
|
+
/**
|
|
288
|
+
* Whether each entry's accessibility snapshot, Playwright's `ariaSnapshot()` of its page, is kept beside
|
|
289
|
+
* its baselines as `<stem>.aria.yml` and compared as text. A lost label fails even when no pixel moved.
|
|
290
|
+
* `{ hide }` keeps it and leaves out the elements its selectors name, as `mask` does, for what is read
|
|
291
|
+
* aloud and never drawn, which `mask` would paint over by its box.
|
|
292
|
+
*/
|
|
293
|
+
aria?: boolean | { hide: string[] }
|
|
294
|
+
/** Checks run on each entry's page after its first shot, which need no baseline. */
|
|
295
|
+
checks?: {
|
|
296
|
+
/**
|
|
297
|
+
* A code block that draws all of its text in one color, as a highlighter that failed leaves it. `true`
|
|
298
|
+
* reads `pre code` and a `pre` with no `code`; give `selector` for code blocks of your own, and
|
|
299
|
+
* `minimum`, 20 when not given, for the fewest characters a block holds before it is judged.
|
|
300
|
+
*/
|
|
301
|
+
code?: boolean | { minimum?: number; selector?: string }
|
|
302
|
+
}
|
|
196
303
|
/** How a shot is compared with its baseline, unless its source sets its own. */
|
|
197
304
|
diff?: Diff
|
|
198
305
|
/** Exceptions to the comparison, each with its reason. */
|
|
@@ -202,8 +309,9 @@ export interface Config {
|
|
|
202
309
|
/** Checks of your own on each page, after its first picture. */
|
|
203
310
|
inspect?: Inspect
|
|
204
311
|
/**
|
|
205
|
-
* Selectors whose elements are painted over in one fixed color in every picture
|
|
206
|
-
* own and says nothing about the page, such as a version
|
|
312
|
+
* Selectors whose elements are painted over in one fixed color in every picture and left out of every
|
|
313
|
+
* accessibility snapshot, for what changes on its own and says nothing about the page, such as a version
|
|
314
|
+
* number or a date.
|
|
207
315
|
*/
|
|
208
316
|
mask?: string[]
|
|
209
317
|
/** `origin` refuses every request off the harness origin, and is the default. `open` lets them through. */
|