@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 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.190.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.190.0",
45
- "@fusion.dev/helix-cli-darwin-x64": "0.190.0",
46
- "@fusion.dev/helix-cli-linux-arm64": "0.190.0",
47
- "@fusion.dev/helix-cli-linux-x64": "0.190.0",
48
- "@fusion.dev/helix-cli-win32-x64": "0.190.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, four kinds:
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, for what changes on its own, such as a version number.
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
- return { file: inside(page.dir, page.files[rest] ?? rest) }
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 (page, item, { elements, readyProperty }, progress) => {
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({ error, key: item.key, op: 'item', seconds })
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.page, item, settle, progress), expired])
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
- state.lanes.push({ page: await context.newPage() })
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: { ...JSON.parse(JSON.stringify(values)), inspect: inspect !== undefined },
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 start({ mask = [], network, origins = [], serve, style = null, workers }) {
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, for what changes on its
206
- * own and says nothing about the page, such as a version number or a date.
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. */