@fusion.dev/helix-cli 0.192.0 → 0.194.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 +23 -0
- package/visual/checks.mjs +800 -0
- package/visual/driver.mjs +24 -3
- package/visual/index.d.ts +39 -0
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.194.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.194.0",
|
|
45
|
+
"@fusion.dev/helix-cli-darwin-x64": "0.194.0",
|
|
46
|
+
"@fusion.dev/helix-cli-linux-arm64": "0.194.0",
|
|
47
|
+
"@fusion.dev/helix-cli-linux-x64": "0.194.0",
|
|
48
|
+
"@fusion.dev/helix-cli-win32-x64": "0.194.0"
|
|
49
49
|
},
|
|
50
50
|
"peerDependencies": {
|
|
51
51
|
"playwright": ">=1.50"
|
package/visual/README.md
CHANGED
|
@@ -116,6 +116,27 @@ export default defineConfig({
|
|
|
116
116
|
one color, as a highlighter that failed leaves it. It reads the page after
|
|
117
117
|
the first shot and needs no baseline. `selector` names your code blocks and
|
|
118
118
|
`minimum` the fewest characters a block holds before it is judged.
|
|
119
|
+
- **`checks.clipped`**: fails a run on a box under `overflow: hidden` that cuts
|
|
120
|
+
off its own text, where no `text-overflow` or line clamp says it means to.
|
|
121
|
+
Only words that cross the edge count, so a carousel's slides out of view do
|
|
122
|
+
not.
|
|
123
|
+
- **`checks.overlap`**: fails a run on two boxes of text or controls that draw
|
|
124
|
+
over each other in the flow. A box positioned absolutely, fixed or sticky,
|
|
125
|
+
given a `z-index`, transformed, floated or in the top layer is placed on
|
|
126
|
+
purpose, and leaves the pair alone.
|
|
127
|
+
- **`checks.contrast`**: fails a run on an ink below WCAG 2.1 AA against the
|
|
128
|
+
ground it is painted on, in each color scheme the shots picture. The ground
|
|
129
|
+
is composited through every translucent layer, and a ground that is a
|
|
130
|
+
picture or a video is not judged. Text hidden from the accessibility tree is
|
|
131
|
+
a glyph for the eye, and answers to the 3:1 of a graphic. `rules` names the colors of lines that
|
|
132
|
+
separate, which answer to no target.
|
|
133
|
+
- Each check takes `ignore`, selectors it leaves alone with everything inside
|
|
134
|
+
them, and an exception's `checks` excuses one entry from the checks it names.
|
|
135
|
+
- **`manifest`**: custom elements manifests, relative to the config. A shot
|
|
136
|
+
that differs is told region by region: the element a reader would name that
|
|
137
|
+
covers the region, and the file a manifest says defines it. A baseline
|
|
138
|
+
carries its elements' boxes inside its PNG once a run writes it, and from
|
|
139
|
+
then on the run also says how far each element moved.
|
|
119
140
|
- **`diff`**: how a shot is compared, for the whole config or for one source.
|
|
120
141
|
`mode: 'pixel'` counts the pixels past `threshold`, 0.1 unless you set it,
|
|
121
142
|
and fails above `pixels`, 0 unless you set it. `mode: 'perceptual'` scores
|
|
@@ -126,6 +147,8 @@ export default defineConfig({
|
|
|
126
147
|
shot differ by that many, and the run fails once the shot stops differing, so
|
|
127
148
|
an exception that is no longer needed is removed. `differs: true` tolerates a
|
|
128
149
|
known difference between a pair's two sides, and fails once they match.
|
|
150
|
+
`checks` excuses the entry from the page checks it names, and fails once a
|
|
151
|
+
named check finds nothing there.
|
|
129
152
|
- **`inspect`**: `run` is called with the Playwright page after each entry's
|
|
130
153
|
first shot, and `report` once at the end with everything `run` returned.
|
|
131
154
|
`report` returns `{ failed: true }` to fail the run.
|
|
@@ -0,0 +1,800 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The page checks that read the page a shot pictured, and need no baseline.
|
|
3
|
+
*
|
|
4
|
+
* **One evaluation with its helpers inside it**, because a function handed to
|
|
5
|
+
* the page carries nothing from this module: the walk through shadow roots,
|
|
6
|
+
* the parent in the flat tree, and the words for an element are written once
|
|
7
|
+
* here and shared by every check that runs.
|
|
8
|
+
*
|
|
9
|
+
* Each finding is `{ check, text }`, so the binary can hold one to an
|
|
10
|
+
* exception that names its check.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Reads the page for the checks `options` turns on: `clipped`, `overlap` and
|
|
15
|
+
* `contrast`, each with its own settings, or absent.
|
|
16
|
+
*/
|
|
17
|
+
export const readPage = (page, options) =>
|
|
18
|
+
page.evaluate((options) => {
|
|
19
|
+
const findings = []
|
|
20
|
+
|
|
21
|
+
/* **Every element, shadow roots included**, because the boxes a component
|
|
22
|
+
lays out for itself are inside one, and the light tree alone holds only
|
|
23
|
+
what its user wrote. */
|
|
24
|
+
const everything = () => {
|
|
25
|
+
const found = []
|
|
26
|
+
const visit = (root) => {
|
|
27
|
+
for (const element of root.querySelectorAll('*')) {
|
|
28
|
+
found.push(element)
|
|
29
|
+
if (element.shadowRoot) {
|
|
30
|
+
visit(element.shadowRoot)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
visit(document)
|
|
35
|
+
return found
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/* **Up the flat tree, not the DOM tree.** Slotted content is laid out and
|
|
39
|
+
painted where its slot sits, so its parent is the slot. */
|
|
40
|
+
const up = (node) => {
|
|
41
|
+
if (node.assignedSlot) {
|
|
42
|
+
return node.assignedSlot
|
|
43
|
+
}
|
|
44
|
+
if (node.parentElement) {
|
|
45
|
+
return node.parentElement
|
|
46
|
+
}
|
|
47
|
+
const above = node.getRootNode()
|
|
48
|
+
return above instanceof ShadowRoot ? above.host : null
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/* Down the flat tree: a slot's assigned nodes, a host's shadow root, or a
|
|
52
|
+
node's own children. */
|
|
53
|
+
const down = (node) => {
|
|
54
|
+
if (node instanceof HTMLSlotElement) {
|
|
55
|
+
const assigned = node.assignedNodes({ flatten: true })
|
|
56
|
+
return assigned.length > 0 ? assigned : [...node.childNodes]
|
|
57
|
+
}
|
|
58
|
+
if (node.shadowRoot) {
|
|
59
|
+
return [...node.shadowRoot.childNodes]
|
|
60
|
+
}
|
|
61
|
+
return [...node.childNodes]
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const textsUnder = (node) => {
|
|
65
|
+
const found = []
|
|
66
|
+
const visit = (one) => {
|
|
67
|
+
for (const child of down(one)) {
|
|
68
|
+
if (child.nodeType === Node.TEXT_NODE) {
|
|
69
|
+
if (child.textContent.trim() !== '') {
|
|
70
|
+
found.push(child)
|
|
71
|
+
}
|
|
72
|
+
} else if (child.nodeType === Node.ELEMENT_NODE) {
|
|
73
|
+
visit(child)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
visit(node)
|
|
78
|
+
return found
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const within = (node, ancestor) => {
|
|
82
|
+
for (let at = node; at; at = up(at)) {
|
|
83
|
+
if (at === ancestor) {
|
|
84
|
+
return true
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return false
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const ignored = (element, selectors) =>
|
|
91
|
+
selectors.length > 0 &&
|
|
92
|
+
(() => {
|
|
93
|
+
for (let at = element; at; at = up(at)) {
|
|
94
|
+
if (at.matches && selectors.some((selector) => at.matches(selector))) {
|
|
95
|
+
return true
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return false
|
|
99
|
+
})()
|
|
100
|
+
|
|
101
|
+
/* **A box of one pixel is a label for a screen reader**, the
|
|
102
|
+
`clip-path: inset(50%)` pattern, and nobody is asked to read it. */
|
|
103
|
+
const shown = (element) => {
|
|
104
|
+
const box = element.getBoundingClientRect()
|
|
105
|
+
return (
|
|
106
|
+
box.width > 1 &&
|
|
107
|
+
box.height > 1 &&
|
|
108
|
+
element.checkVisibility({ opacityProperty: true, visibilityProperty: true })
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/* The words for an element in a finding: its tag, and the part it plays
|
|
113
|
+
inside the component that holds it. */
|
|
114
|
+
const describe = (element) => {
|
|
115
|
+
const holder = element.getRootNode()
|
|
116
|
+
const own =
|
|
117
|
+
element.part?.length > 0
|
|
118
|
+
? `::part(${[...element.part].join(' ')})`
|
|
119
|
+
: element.id
|
|
120
|
+
? `#${element.id}`
|
|
121
|
+
: element.classList.length > 0
|
|
122
|
+
? `.${element.classList[0]}`
|
|
123
|
+
: ''
|
|
124
|
+
const self = `${element.localName}${own}`
|
|
125
|
+
return holder instanceof ShadowRoot ? `${holder.host.localName} ${self}` : self
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const sample = (text) => {
|
|
129
|
+
const one = text.trim().replace(/\s+/g, ' ')
|
|
130
|
+
return one.length > 40 ? `${one.slice(0, 40)}…` : one
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const rectsOf = (text) => {
|
|
134
|
+
const range = document.createRange()
|
|
135
|
+
range.selectNodeContents(text)
|
|
136
|
+
return [...range.getClientRects()].filter((rect) => rect.width >= 1 && rect.height >= 1)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/* **Clipped text: a box that cuts its own words off.** A box under
|
|
140
|
+
`overflow: hidden` or `clip` whose text runs past its edge, unless the
|
|
141
|
+
author said so with `text-overflow` or a line clamp. Only words that
|
|
142
|
+
cross the edge count: words wholly outside the box are scrolled away,
|
|
143
|
+
as a carousel's other slides are, and nobody is shown half of them. A
|
|
144
|
+
word is reported once, against the innermost box that cuts it. */
|
|
145
|
+
if (options.clipped) {
|
|
146
|
+
const { ignore } = options.clipped
|
|
147
|
+
const cut = new Map()
|
|
148
|
+
for (const element of everything()) {
|
|
149
|
+
if (ignored(element, ignore) || !shown(element)) {
|
|
150
|
+
continue
|
|
151
|
+
}
|
|
152
|
+
const style = getComputedStyle(element)
|
|
153
|
+
const clipsX = ['hidden', 'clip'].includes(style.overflowX)
|
|
154
|
+
const clipsY = ['hidden', 'clip'].includes(style.overflowY)
|
|
155
|
+
const saidX = style.textOverflow !== 'clip'
|
|
156
|
+
const saidY = style.webkitLineClamp !== '' && style.webkitLineClamp !== 'none'
|
|
157
|
+
const overX = clipsX && !saidX && element.scrollWidth > element.clientWidth + 1
|
|
158
|
+
const overY = clipsY && !saidY && element.scrollHeight > element.clientHeight + 1
|
|
159
|
+
if (!overX && !overY) {
|
|
160
|
+
continue
|
|
161
|
+
}
|
|
162
|
+
const box = element.getBoundingClientRect()
|
|
163
|
+
const left = box.left + element.clientLeft
|
|
164
|
+
const top = box.top + element.clientTop
|
|
165
|
+
const right = left + element.clientWidth
|
|
166
|
+
const bottom = top + element.clientHeight
|
|
167
|
+
for (const text of textsUnder(element)) {
|
|
168
|
+
if (ignored(text.parentElement, ignore) || !shown(text.parentElement)) {
|
|
169
|
+
continue
|
|
170
|
+
}
|
|
171
|
+
/* Words inside a scrolling box below this one are scrolled content,
|
|
172
|
+
and a line half in view at a scroller's edge is how scrolling
|
|
173
|
+
looks. */
|
|
174
|
+
let scrolls = false
|
|
175
|
+
for (let at = text.parentElement; at && at !== element; at = up(at)) {
|
|
176
|
+
if (at.nodeType === Node.ELEMENT_NODE) {
|
|
177
|
+
const { overflowX, overflowY } = getComputedStyle(at)
|
|
178
|
+
if ([overflowX, overflowY].some((one) => one === 'auto' || one === 'scroll')) {
|
|
179
|
+
scrolls = true
|
|
180
|
+
break
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (scrolls) {
|
|
185
|
+
continue
|
|
186
|
+
}
|
|
187
|
+
for (const rect of rectsOf(text)) {
|
|
188
|
+
const crosses =
|
|
189
|
+
rect.right > left && rect.left < right && rect.bottom > top && rect.top < bottom
|
|
190
|
+
if (!crosses) {
|
|
191
|
+
continue
|
|
192
|
+
}
|
|
193
|
+
const past = [
|
|
194
|
+
['right', overX ? rect.right - right : 0],
|
|
195
|
+
['left', overX ? left - rect.left : 0],
|
|
196
|
+
['bottom', overY ? rect.bottom - bottom : 0],
|
|
197
|
+
['top', overY ? top - rect.top : 0],
|
|
198
|
+
].sort((a, b) => b[1] - a[1])[0]
|
|
199
|
+
if (past[1] <= 1) {
|
|
200
|
+
continue
|
|
201
|
+
}
|
|
202
|
+
const before = cut.get(text)
|
|
203
|
+
if (before === undefined || within(element, before.element)) {
|
|
204
|
+
cut.set(text, { edge: past[0], element, pixels: Math.round(past[1]) })
|
|
205
|
+
}
|
|
206
|
+
break
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
const said = new Set()
|
|
211
|
+
for (const [text, { edge, element, pixels }] of cut) {
|
|
212
|
+
const line = `${describe(element)} cuts off its text ${pixels} px past its ${edge} edge: "${sample(text.textContent)}"`
|
|
213
|
+
if (!said.has(line)) {
|
|
214
|
+
said.add(line)
|
|
215
|
+
findings.push({ check: 'clipped', text: line })
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/* **Overlap: two boxes that draw over each other with nothing saying
|
|
221
|
+
they should.** Text is judged by its words' own boxes and a control by
|
|
222
|
+
its border box. A pair where one holds the other is a layout. Either
|
|
223
|
+
one being placed on purpose is a stacking reason, and the pair is left
|
|
224
|
+
alone: positioned absolutely, fixed or sticky, given a `z-index`,
|
|
225
|
+
transformed, floated or in the top layer, itself or any box between it
|
|
226
|
+
and what holds both, or put by a grid in the cell the other is in. What
|
|
227
|
+
is left is two boxes in the flow that collide, which is a word running
|
|
228
|
+
into its neighbor. */
|
|
229
|
+
if (options.overlap) {
|
|
230
|
+
const { ignore } = options.overlap
|
|
231
|
+
const CONTROL =
|
|
232
|
+
'a[href], button, input:not([type="hidden"]), select, textarea, summary, [role="button"], [role="link"], [role="checkbox"], [role="radio"], [role="switch"], [role="tab"], [role="menuitem"], [role="option"], [role="slider"], [tabindex]:not([tabindex="-1"])'
|
|
233
|
+
/* **What is in view of a box, not all of it.** A line scrolled out of
|
|
234
|
+
a list, or past a box that clips, draws nothing where its rectangle
|
|
235
|
+
says, so each rectangle is cut to the client box of every ancestor
|
|
236
|
+
that does not let its content overflow. */
|
|
237
|
+
const views = new Map()
|
|
238
|
+
const viewOf = (element) => {
|
|
239
|
+
if (!views.has(element)) {
|
|
240
|
+
let view = { bottom: Infinity, left: -Infinity, right: Infinity, top: -Infinity }
|
|
241
|
+
for (let at = up(element); at; at = up(at)) {
|
|
242
|
+
if (at.nodeType !== Node.ELEMENT_NODE) {
|
|
243
|
+
continue
|
|
244
|
+
}
|
|
245
|
+
const style = getComputedStyle(at)
|
|
246
|
+
const clipsX = style.overflowX !== 'visible'
|
|
247
|
+
const clipsY = style.overflowY !== 'visible'
|
|
248
|
+
if (clipsX || clipsY) {
|
|
249
|
+
const box = at.getBoundingClientRect()
|
|
250
|
+
const left = box.left + at.clientLeft
|
|
251
|
+
const top = box.top + at.clientTop
|
|
252
|
+
view = {
|
|
253
|
+
bottom: clipsY ? Math.min(view.bottom, top + at.clientHeight) : view.bottom,
|
|
254
|
+
left: clipsX ? Math.max(view.left, left) : view.left,
|
|
255
|
+
right: clipsX ? Math.min(view.right, left + at.clientWidth) : view.right,
|
|
256
|
+
top: clipsY ? Math.max(view.top, top) : view.top,
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
views.set(element, view)
|
|
261
|
+
}
|
|
262
|
+
return views.get(element)
|
|
263
|
+
}
|
|
264
|
+
const inView = (element, rects) => {
|
|
265
|
+
const view = viewOf(element)
|
|
266
|
+
return rects
|
|
267
|
+
.map((rect) => {
|
|
268
|
+
const left = Math.max(rect.left, view.left)
|
|
269
|
+
const right = Math.min(rect.right, view.right)
|
|
270
|
+
const top = Math.max(rect.top, view.top)
|
|
271
|
+
const bottom = Math.min(rect.bottom, view.bottom)
|
|
272
|
+
return { bottom, height: bottom - top, left, right, top, width: right - left }
|
|
273
|
+
})
|
|
274
|
+
.filter((rect) => rect.width > 0 && rect.height > 0)
|
|
275
|
+
}
|
|
276
|
+
const boxes = []
|
|
277
|
+
for (const element of everything()) {
|
|
278
|
+
if (ignored(element, ignore) || !shown(element)) {
|
|
279
|
+
continue
|
|
280
|
+
}
|
|
281
|
+
if (element.matches(CONTROL)) {
|
|
282
|
+
const rects = inView(element, [element.getBoundingClientRect()])
|
|
283
|
+
if (rects.length > 0) {
|
|
284
|
+
boxes.push({ element, rects })
|
|
285
|
+
}
|
|
286
|
+
continue
|
|
287
|
+
}
|
|
288
|
+
const own = [...element.childNodes].filter(
|
|
289
|
+
(child) => child.nodeType === Node.TEXT_NODE && child.textContent.trim() !== '',
|
|
290
|
+
)
|
|
291
|
+
/* **The band a line's ink sits in, not its glyph box.** A glyph box
|
|
292
|
+
runs from the font's ascent to its descent, so the empty space under
|
|
293
|
+
a large figure's digits reached the label set close below it. A
|
|
294
|
+
fifth off the top and off the bottom leaves the capitals and the
|
|
295
|
+
x-height, which is where two lines that collide meet. */
|
|
296
|
+
const bands = own.flatMap(rectsOf).map((rect) => {
|
|
297
|
+
const trim = rect.height / 5
|
|
298
|
+
return {
|
|
299
|
+
bottom: rect.bottom - trim,
|
|
300
|
+
height: rect.height - 2 * trim,
|
|
301
|
+
left: rect.left,
|
|
302
|
+
right: rect.right,
|
|
303
|
+
top: rect.top + trim,
|
|
304
|
+
}
|
|
305
|
+
})
|
|
306
|
+
const rects = inView(element, bands)
|
|
307
|
+
if (rects.length > 0) {
|
|
308
|
+
boxes.push({ element, rects })
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
const placed = new Map()
|
|
312
|
+
/* A flex or grid item stacks by `z-index` with no `position`. */
|
|
313
|
+
const laidOut = (node) => {
|
|
314
|
+
const parent = up(node)
|
|
315
|
+
return (
|
|
316
|
+
parent?.nodeType === Node.ELEMENT_NODE &&
|
|
317
|
+
/flex|grid/.test(getComputedStyle(parent).display)
|
|
318
|
+
)
|
|
319
|
+
}
|
|
320
|
+
const onPurpose = (node) => {
|
|
321
|
+
if (!placed.has(node)) {
|
|
322
|
+
const style = getComputedStyle(node)
|
|
323
|
+
placed.set(
|
|
324
|
+
node,
|
|
325
|
+
['absolute', 'fixed', 'sticky'].includes(style.position) ||
|
|
326
|
+
(style.zIndex !== 'auto' && (style.position !== 'static' || laidOut(node))) ||
|
|
327
|
+
style.transform !== 'none' ||
|
|
328
|
+
style.translate !== 'none' ||
|
|
329
|
+
style.float !== 'none' ||
|
|
330
|
+
node.matches(':popover-open, :modal'),
|
|
331
|
+
)
|
|
332
|
+
}
|
|
333
|
+
return placed.get(node)
|
|
334
|
+
}
|
|
335
|
+
const reason = (a, b) => {
|
|
336
|
+
const line = new Set()
|
|
337
|
+
for (let at = a; at; at = up(at)) {
|
|
338
|
+
line.add(at)
|
|
339
|
+
}
|
|
340
|
+
let shared = null
|
|
341
|
+
let fromB = null
|
|
342
|
+
for (let at = b; at; at = up(at)) {
|
|
343
|
+
if (line.has(at)) {
|
|
344
|
+
shared = at
|
|
345
|
+
break
|
|
346
|
+
}
|
|
347
|
+
if (at.nodeType === Node.ELEMENT_NODE && onPurpose(at)) {
|
|
348
|
+
return true
|
|
349
|
+
}
|
|
350
|
+
fromB = at
|
|
351
|
+
}
|
|
352
|
+
let fromA = null
|
|
353
|
+
for (let at = a; at && at !== shared; at = up(at)) {
|
|
354
|
+
if (at.nodeType === Node.ELEMENT_NODE && onPurpose(at)) {
|
|
355
|
+
return true
|
|
356
|
+
}
|
|
357
|
+
fromA = at
|
|
358
|
+
}
|
|
359
|
+
/* Two items a grid puts in one cell are stacked by its author, as a
|
|
360
|
+
range slider's two inputs are. */
|
|
361
|
+
if (
|
|
362
|
+
shared?.nodeType === Node.ELEMENT_NODE &&
|
|
363
|
+
fromA?.nodeType === Node.ELEMENT_NODE &&
|
|
364
|
+
fromB?.nodeType === Node.ELEMENT_NODE &&
|
|
365
|
+
/grid/.test(getComputedStyle(shared).display)
|
|
366
|
+
) {
|
|
367
|
+
const one = getComputedStyle(fromA)
|
|
368
|
+
const other = getComputedStyle(fromB)
|
|
369
|
+
if (
|
|
370
|
+
one.gridRowStart !== 'auto' &&
|
|
371
|
+
one.gridColumnStart !== 'auto' &&
|
|
372
|
+
one.gridRowStart === other.gridRowStart &&
|
|
373
|
+
one.gridColumnStart === other.gridColumnStart
|
|
374
|
+
) {
|
|
375
|
+
return true
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
return false
|
|
379
|
+
}
|
|
380
|
+
const said = new Set()
|
|
381
|
+
for (let i = 0; i < boxes.length; i += 1) {
|
|
382
|
+
for (let j = i + 1; j < boxes.length; j += 1) {
|
|
383
|
+
const a = boxes[i]
|
|
384
|
+
const b = boxes[j]
|
|
385
|
+
if (within(a.element, b.element) || within(b.element, a.element)) {
|
|
386
|
+
continue
|
|
387
|
+
}
|
|
388
|
+
let worst = null
|
|
389
|
+
for (const one of a.rects) {
|
|
390
|
+
for (const other of b.rects) {
|
|
391
|
+
const width = Math.min(one.right, other.right) - Math.max(one.left, other.left)
|
|
392
|
+
const height = Math.min(one.bottom, other.bottom) - Math.max(one.top, other.top)
|
|
393
|
+
/* More than a pixel each way: ink bands do not meet even in
|
|
394
|
+
lines set at a line height of 0.9, and a pixel is two borders
|
|
395
|
+
drawn as one, as a button group does. */
|
|
396
|
+
if (width > 2 && height > 1) {
|
|
397
|
+
if (worst === null || width * height > worst.width * worst.height) {
|
|
398
|
+
worst = { height, width }
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
if (worst === null || reason(a.element, b.element)) {
|
|
404
|
+
continue
|
|
405
|
+
}
|
|
406
|
+
const words = (box) =>
|
|
407
|
+
sample(box.element.textContent) || box.element.getAttribute('aria-label') || ''
|
|
408
|
+
const line = `${describe(a.element)} and ${describe(b.element)} overlap by ${Math.round(worst.width)} by ${Math.round(worst.height)} px: "${words(a)}" and "${words(b)}"`
|
|
409
|
+
if (!said.has(line)) {
|
|
410
|
+
said.add(line)
|
|
411
|
+
findings.push({ check: 'overlap', text: line })
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/* **Contrast: every ink against the ground it is really painted on.**
|
|
418
|
+
The instrument is the hard part, and each of these was once a component
|
|
419
|
+
reported broken when the probe was wrong: a transparent background
|
|
420
|
+
paints nothing, so the walk composites every partly-opaque layer up the
|
|
421
|
+
flat tree onto the first opaque one; a reading during a transition is
|
|
422
|
+
the previous value, which the settling before any shot already rules
|
|
423
|
+
out; and a ground that is not an ancestor, a sticky bar over a hero, is
|
|
424
|
+
found by reading the paint stack at the text's center, where a stranger
|
|
425
|
+
in between means nothing is claimed. */
|
|
426
|
+
if (options.contrast) {
|
|
427
|
+
const { component, ignore, large, largeBold, rules, scheme, target } = options.contrast
|
|
428
|
+
|
|
429
|
+
/* Read through a canvas, because a computed color comes back in the
|
|
430
|
+
notation it was written in, OKLCH included, and painting it is the
|
|
431
|
+
one way to get sRGB out of every notation. */
|
|
432
|
+
const swatch = document.createElement('canvas')
|
|
433
|
+
swatch.width = 1
|
|
434
|
+
swatch.height = 1
|
|
435
|
+
const brush = swatch.getContext('2d', { willReadFrequently: true })
|
|
436
|
+
const parse = (value) => {
|
|
437
|
+
if (!value || value === 'transparent' || value === 'none') {
|
|
438
|
+
return null
|
|
439
|
+
}
|
|
440
|
+
brush.fillStyle = '#010203'
|
|
441
|
+
brush.fillStyle = value
|
|
442
|
+
if (brush.fillStyle === '#010203' && !/^#010203$/i.test(value.trim())) {
|
|
443
|
+
return null
|
|
444
|
+
}
|
|
445
|
+
brush.clearRect(0, 0, 1, 1)
|
|
446
|
+
brush.fillRect(0, 0, 1, 1)
|
|
447
|
+
const [r, g, b, a] = brush.getImageData(0, 0, 1, 1).data
|
|
448
|
+
return { a: a / 255, b, g, r }
|
|
449
|
+
}
|
|
450
|
+
const over = (top, bottom) => ({
|
|
451
|
+
a: 1,
|
|
452
|
+
b: top.b * top.a + bottom.b * (1 - top.a),
|
|
453
|
+
g: top.g * top.a + bottom.g * (1 - top.a),
|
|
454
|
+
r: top.r * top.a + bottom.r * (1 - top.a),
|
|
455
|
+
})
|
|
456
|
+
const luminance = ({ b, g, r }) => {
|
|
457
|
+
const lin = (value) => {
|
|
458
|
+
const c = value / 255
|
|
459
|
+
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4
|
|
460
|
+
}
|
|
461
|
+
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b)
|
|
462
|
+
}
|
|
463
|
+
const ratio = (ink, ground) => {
|
|
464
|
+
const a = luminance(ink)
|
|
465
|
+
const b = luminance(ground)
|
|
466
|
+
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05)
|
|
467
|
+
}
|
|
468
|
+
const hex = ({ b, g, r }) =>
|
|
469
|
+
`#${[r, g, b].map((v) => Math.round(v).toString(16).padStart(2, '0')).join('')}`
|
|
470
|
+
|
|
471
|
+
/* What sits behind a picture, a video or a background image is not one
|
|
472
|
+
color, so no ratio against it means anything. Only media that covers
|
|
473
|
+
the text counts. */
|
|
474
|
+
const MEDIA = 'video, img, canvas, iframe, object, svg image'
|
|
475
|
+
const unbounded = (node, box) => {
|
|
476
|
+
if (getComputedStyle(node).backgroundImage !== 'none') {
|
|
477
|
+
return true
|
|
478
|
+
}
|
|
479
|
+
for (const painted of node.querySelectorAll?.(MEDIA) ?? []) {
|
|
480
|
+
const area = painted.getBoundingClientRect()
|
|
481
|
+
if (
|
|
482
|
+
area.left <= box.left &&
|
|
483
|
+
area.top <= box.top &&
|
|
484
|
+
area.right >= box.right &&
|
|
485
|
+
area.bottom >= box.bottom
|
|
486
|
+
) {
|
|
487
|
+
return true
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
return false
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/* Up the flat tree, every layer that is not fully transparent until one
|
|
494
|
+
is opaque, a modal's `::backdrop` included, which has no node. A
|
|
495
|
+
masked background is a glyph in the shape of its mask, and no
|
|
496
|
+
ground. */
|
|
497
|
+
const groundOf = (element, box) => {
|
|
498
|
+
const stack = []
|
|
499
|
+
let node = element
|
|
500
|
+
while (node) {
|
|
501
|
+
const style = getComputedStyle(node)
|
|
502
|
+
const masked = style.maskImage !== 'none' && style.maskImage !== ''
|
|
503
|
+
const layers = [
|
|
504
|
+
masked ? null : parse(style.backgroundColor),
|
|
505
|
+
parse(getComputedStyle(node, '::backdrop').backgroundColor),
|
|
506
|
+
]
|
|
507
|
+
for (const layer of layers) {
|
|
508
|
+
if (!layer || layer.a === 0) {
|
|
509
|
+
continue
|
|
510
|
+
}
|
|
511
|
+
stack.push(layer)
|
|
512
|
+
if (layer.a === 1) {
|
|
513
|
+
let composited = stack.pop()
|
|
514
|
+
while (stack.length > 0) {
|
|
515
|
+
composited = over(stack.pop(), composited)
|
|
516
|
+
}
|
|
517
|
+
return { color: composited, on: node }
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
if (unbounded(node, box)) {
|
|
521
|
+
return null
|
|
522
|
+
}
|
|
523
|
+
node = up(node)
|
|
524
|
+
}
|
|
525
|
+
/* **What is left is the canvas**, which the browser paints behind a
|
|
526
|
+
page that gives its root no background: white, or dark under a dark
|
|
527
|
+
`color-scheme`. */
|
|
528
|
+
let composited = canvas
|
|
529
|
+
while (stack.length > 0) {
|
|
530
|
+
composited = over(stack.pop(), composited)
|
|
531
|
+
}
|
|
532
|
+
return { color: composited, on: document.documentElement }
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/* A shadow root's `elementsFromPoint` reports the outer stack again, so
|
|
536
|
+
each element is entered once. */
|
|
537
|
+
const stackAt = (x, y, root = document, seen = new Set()) => {
|
|
538
|
+
const layers = []
|
|
539
|
+
for (const element of root.elementsFromPoint(x, y)) {
|
|
540
|
+
if (seen.has(element)) {
|
|
541
|
+
continue
|
|
542
|
+
}
|
|
543
|
+
seen.add(element)
|
|
544
|
+
if (element.shadowRoot) {
|
|
545
|
+
layers.push(...stackAt(x, y, element.shadowRoot, seen))
|
|
546
|
+
}
|
|
547
|
+
layers.push(element)
|
|
548
|
+
}
|
|
549
|
+
return layers
|
|
550
|
+
}
|
|
551
|
+
const contested = (element, ground, box) => {
|
|
552
|
+
const stack = stackAt(box.left + box.width / 2, box.top + box.height / 2)
|
|
553
|
+
const seen = stack.indexOf(element)
|
|
554
|
+
const floor = stack.indexOf(ground.on)
|
|
555
|
+
if (seen < 0 || floor < 0) {
|
|
556
|
+
return false
|
|
557
|
+
}
|
|
558
|
+
const line = new Set()
|
|
559
|
+
for (let node = element; node; node = up(node)) {
|
|
560
|
+
line.add(node)
|
|
561
|
+
}
|
|
562
|
+
for (const between of stack.slice(seen + 1, floor)) {
|
|
563
|
+
const paint = parse(getComputedStyle(between).backgroundColor)
|
|
564
|
+
if (!line.has(between) && paint && paint.a > 0) {
|
|
565
|
+
return true
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
return false
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const canvas = (() => {
|
|
572
|
+
const probe = document.createElement('span')
|
|
573
|
+
probe.style.backgroundColor = 'Canvas'
|
|
574
|
+
document.documentElement.append(probe)
|
|
575
|
+
const color = parse(getComputedStyle(probe).backgroundColor) ?? {
|
|
576
|
+
a: 1,
|
|
577
|
+
b: 255,
|
|
578
|
+
g: 255,
|
|
579
|
+
r: 255,
|
|
580
|
+
}
|
|
581
|
+
probe.remove()
|
|
582
|
+
return { ...color, a: 1 }
|
|
583
|
+
})()
|
|
584
|
+
|
|
585
|
+
/* The colors that draw a rule and not an ink, read off the live page:
|
|
586
|
+
a line that separates is held to no target meant for a glyph. */
|
|
587
|
+
const probe = document.createElement('span')
|
|
588
|
+
probe.style.position = 'absolute'
|
|
589
|
+
document.body.append(probe)
|
|
590
|
+
const lines = new Set(
|
|
591
|
+
rules.map((rule) => {
|
|
592
|
+
probe.style.color = rule
|
|
593
|
+
return getComputedStyle(probe).color
|
|
594
|
+
}),
|
|
595
|
+
)
|
|
596
|
+
probe.remove()
|
|
597
|
+
|
|
598
|
+
/* An icon is ink too, held to the 3:1 of a graphic. Only a drawing of
|
|
599
|
+
one paint is read: a chart's root carries a paint its marks override,
|
|
600
|
+
and its figure would be of a color that is nowhere on screen. */
|
|
601
|
+
const iconInk = (element) => {
|
|
602
|
+
const paints = new Set()
|
|
603
|
+
for (const node of [element, ...element.querySelectorAll('*')]) {
|
|
604
|
+
const style = getComputedStyle(node)
|
|
605
|
+
for (const paint of [style.fill, style.stroke]) {
|
|
606
|
+
if (paint && paint !== 'none') {
|
|
607
|
+
paints.add(paint)
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
if (paints.size !== 1) {
|
|
612
|
+
return null
|
|
613
|
+
}
|
|
614
|
+
const [only] = [...paints]
|
|
615
|
+
return lines.has(only) ? null : parse(only)
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/* A slotted label is painted by the box it lands in, through as many
|
|
619
|
+
slots as there are shadow roots between them. */
|
|
620
|
+
const painterOf = (element) => {
|
|
621
|
+
let node = [...element.childNodes].find(
|
|
622
|
+
(child) =>
|
|
623
|
+
child.nodeType === Node.TEXT_NODE &&
|
|
624
|
+
child.textContent.trim() !== '' &&
|
|
625
|
+
child.assignedSlot,
|
|
626
|
+
)
|
|
627
|
+
if (!node) {
|
|
628
|
+
return element
|
|
629
|
+
}
|
|
630
|
+
while (node.assignedSlot) {
|
|
631
|
+
node = node.assignedSlot
|
|
632
|
+
}
|
|
633
|
+
return node.parentElement ?? element
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/* Scrolled to, so paint order can be read where the browser is looking,
|
|
637
|
+
and every scroll put back after, so the shots after this one picture
|
|
638
|
+
the page where it was. */
|
|
639
|
+
const scrolled = everything()
|
|
640
|
+
.filter((one) => one.scrollHeight > one.clientHeight || one.scrollWidth > one.clientWidth)
|
|
641
|
+
.map((one) => [one, one.scrollLeft, one.scrollTop])
|
|
642
|
+
const pageAt = [window.scrollX, window.scrollY]
|
|
643
|
+
const said = new Set()
|
|
644
|
+
for (const element of everything()) {
|
|
645
|
+
if (ignored(element, ignore)) {
|
|
646
|
+
continue
|
|
647
|
+
}
|
|
648
|
+
const text = [...element.childNodes].some(
|
|
649
|
+
(child) => child.nodeType === Node.TEXT_NODE && child.textContent.trim() !== '',
|
|
650
|
+
)
|
|
651
|
+
if (!text && !(element instanceof SVGSVGElement)) {
|
|
652
|
+
continue
|
|
653
|
+
}
|
|
654
|
+
let box = element.getBoundingClientRect()
|
|
655
|
+
if (box.width <= 1 || box.height <= 1) {
|
|
656
|
+
continue
|
|
657
|
+
}
|
|
658
|
+
const painter = text ? painterOf(element) : element
|
|
659
|
+
if (painter !== element) {
|
|
660
|
+
const paints = painter.getBoundingClientRect()
|
|
661
|
+
if (paints.width <= 1 || paints.height <= 1) {
|
|
662
|
+
continue
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
const style = getComputedStyle(painter)
|
|
666
|
+
if (style.visibility === 'hidden' || style.opacity === '0') {
|
|
667
|
+
continue
|
|
668
|
+
}
|
|
669
|
+
const ink = text ? parse(style.color) : iconInk(element)
|
|
670
|
+
if (!ink) {
|
|
671
|
+
continue
|
|
672
|
+
}
|
|
673
|
+
if (box.top < 0 || box.bottom > window.innerHeight) {
|
|
674
|
+
element.scrollIntoView({ block: 'center' })
|
|
675
|
+
box = element.getBoundingClientRect()
|
|
676
|
+
}
|
|
677
|
+
if (box.top < 0 || box.bottom > window.innerHeight) {
|
|
678
|
+
continue
|
|
679
|
+
}
|
|
680
|
+
const ground = groundOf(painter, box)
|
|
681
|
+
if (!ground || contested(painter, ground, box)) {
|
|
682
|
+
continue
|
|
683
|
+
}
|
|
684
|
+
const size = Number.parseFloat(style.fontSize)
|
|
685
|
+
const bold = Number.parseInt(style.fontWeight, 10) >= 700
|
|
686
|
+
/* **Text hidden from the accessibility tree is a glyph drawn for the
|
|
687
|
+
eye**, an arrow beside a link's words, and a graphic answers to the
|
|
688
|
+
graphic's target. */
|
|
689
|
+
let drawn = false
|
|
690
|
+
for (let at = element; at; at = up(at)) {
|
|
691
|
+
if (at.nodeType === Node.ELEMENT_NODE && at.getAttribute('aria-hidden') === 'true') {
|
|
692
|
+
drawn = true
|
|
693
|
+
break
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
const wanted =
|
|
697
|
+
!text || drawn || size >= large || (bold && size >= largeBold) ? component : target
|
|
698
|
+
const flat = ink.a === 1 ? ink : over(ink, ground.color)
|
|
699
|
+
const measured = ratio(flat, ground.color)
|
|
700
|
+
if (measured < wanted) {
|
|
701
|
+
const line = `${describe(painter)} is ${hex(flat)} on ${hex(ground.color)} in ${scheme}, ${measured.toFixed(2)}:1 against ${wanted}:1: "${sample(element.textContent ?? '')}"`
|
|
702
|
+
if (!said.has(line)) {
|
|
703
|
+
said.add(line)
|
|
704
|
+
findings.push({ check: 'contrast', text: line })
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
for (const [one, left, top] of scrolled) {
|
|
709
|
+
one.scrollLeft = left
|
|
710
|
+
one.scrollTop = top
|
|
711
|
+
}
|
|
712
|
+
window.scrollTo(pageAt[0], pageAt[1])
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
return findings
|
|
716
|
+
}, options)
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* Where each element a reader would name sat when the page was pictured, in
|
|
720
|
+
* the picture's own pixels.
|
|
721
|
+
*
|
|
722
|
+
* **Named, because a change is told by what it happened to.** A custom
|
|
723
|
+
* element and each part it lays out, anything with an `id` or a
|
|
724
|
+
* `data-testid`, and the landmarks, headings, controls and pictures a page is
|
|
725
|
+
* read by. Each is keyed by the named elements above it and its place among
|
|
726
|
+
* its namesakes, so the same element is found again in another picture of the
|
|
727
|
+
* page, which is how a run says it moved.
|
|
728
|
+
*/
|
|
729
|
+
export const boxesOf = (page) =>
|
|
730
|
+
page.evaluate(() => {
|
|
731
|
+
const LIMIT = 4000
|
|
732
|
+
const READ =
|
|
733
|
+
'header, nav, main, aside, footer, section, form, h1, h2, h3, h4, h5, h6, a[href], button, input, select, textarea, img, svg, video, canvas, [role]'
|
|
734
|
+
const up = (node) => {
|
|
735
|
+
if (node.assignedSlot) {
|
|
736
|
+
return node.assignedSlot
|
|
737
|
+
}
|
|
738
|
+
if (node.parentElement) {
|
|
739
|
+
return node.parentElement
|
|
740
|
+
}
|
|
741
|
+
const above = node.getRootNode()
|
|
742
|
+
return above instanceof ShadowRoot ? above.host : null
|
|
743
|
+
}
|
|
744
|
+
const label = (element) => {
|
|
745
|
+
if (element.part?.length > 0) {
|
|
746
|
+
const holder = element.getRootNode()
|
|
747
|
+
return `${holder instanceof ShadowRoot ? '' : element.localName}::part(${[...element.part].join(' ')})`
|
|
748
|
+
}
|
|
749
|
+
if (element.id) {
|
|
750
|
+
return `${element.localName}#${element.id}`
|
|
751
|
+
}
|
|
752
|
+
const test = element.getAttribute('data-testid')
|
|
753
|
+
return test ? `${element.localName}[data-testid="${test}"]` : element.localName
|
|
754
|
+
}
|
|
755
|
+
const named = (element) =>
|
|
756
|
+
element.localName.includes('-') ||
|
|
757
|
+
element.part?.length > 0 ||
|
|
758
|
+
element.id !== '' ||
|
|
759
|
+
element.hasAttribute('data-testid') ||
|
|
760
|
+
element.matches(READ)
|
|
761
|
+
/* What holds an element in a key: the custom elements and the elements
|
|
762
|
+
with an `id` above it, which a reader names it by. */
|
|
763
|
+
const holds = (element) => element.localName.includes('-') || element.id !== ''
|
|
764
|
+
const found = []
|
|
765
|
+
const seen = new Map()
|
|
766
|
+
const visit = (root) => {
|
|
767
|
+
for (const element of root.querySelectorAll('*')) {
|
|
768
|
+
if (found.length >= LIMIT) {
|
|
769
|
+
return
|
|
770
|
+
}
|
|
771
|
+
if (named(element)) {
|
|
772
|
+
const box = element.getBoundingClientRect()
|
|
773
|
+
if (box.width > 0 && box.height > 0) {
|
|
774
|
+
const path = [label(element)]
|
|
775
|
+
for (let at = up(element); at; at = up(at)) {
|
|
776
|
+
if (at.nodeType === Node.ELEMENT_NODE && holds(at)) {
|
|
777
|
+
path.unshift(label(at))
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
const joined = path.join(' ')
|
|
781
|
+
const count = (seen.get(joined) ?? 0) + 1
|
|
782
|
+
seen.set(joined, count)
|
|
783
|
+
found.push({
|
|
784
|
+
height: Math.round(box.height),
|
|
785
|
+
key: count === 1 ? joined : `${joined} (${count})`,
|
|
786
|
+
tag: element.localName,
|
|
787
|
+
width: Math.round(box.width),
|
|
788
|
+
x: Math.round(box.left + window.scrollX),
|
|
789
|
+
y: Math.round(box.top + window.scrollY),
|
|
790
|
+
})
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
if (element.shadowRoot) {
|
|
794
|
+
visit(element.shadowRoot)
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
visit(document)
|
|
799
|
+
return found
|
|
800
|
+
})
|
package/visual/driver.mjs
CHANGED
|
@@ -39,6 +39,7 @@ import { createServer } from 'node:http'
|
|
|
39
39
|
import { extname, resolve, sep } from 'node:path'
|
|
40
40
|
import { StringDecoder } from 'node:string_decoder'
|
|
41
41
|
import { pathToFileURL } from 'node:url'
|
|
42
|
+
import { boxesOf, readPage } from './checks.mjs'
|
|
42
43
|
|
|
43
44
|
/** The `id` of the harness `<style>` the binary writes into every page. */
|
|
44
45
|
const STYLE_ID = 'helix-visual-harness'
|
|
@@ -376,6 +377,8 @@ const take = async (lane, item, { elements, readyProperty }, progress) => {
|
|
|
376
377
|
let problems = []
|
|
377
378
|
let findings = []
|
|
378
379
|
let aria = null
|
|
380
|
+
/* The color schemes contrast has been read in. */
|
|
381
|
+
const read = new Set()
|
|
379
382
|
lane.errors.length = 0
|
|
380
383
|
lane.messages.length = 0
|
|
381
384
|
try {
|
|
@@ -432,7 +435,10 @@ const take = async (lane, item, { elements, readyProperty }, progress) => {
|
|
|
432
435
|
if (progress.abandoned) {
|
|
433
436
|
return
|
|
434
437
|
}
|
|
435
|
-
|
|
438
|
+
/* Where the named elements sat in this picture, when the run asked:
|
|
439
|
+
written into a baseline, and read to say what a change happened to. */
|
|
440
|
+
const boxes = item.boxes ? await boxesOf(page) : null
|
|
441
|
+
say({ boxes, key: item.key, op: 'shot', png: png.toString('base64'), shot: shot.name })
|
|
436
442
|
/* After the first picture, on the page that was pictured, so what the
|
|
437
443
|
inspection reads cannot move a pixel of that picture. */
|
|
438
444
|
if (index === 0 && item.inspect) {
|
|
@@ -443,10 +449,25 @@ const take = async (lane, item, { elements, readyProperty }, progress) => {
|
|
|
443
449
|
})
|
|
444
450
|
}
|
|
445
451
|
/* The page checks and the accessibility snapshot, on the page that was
|
|
446
|
-
pictured, after its first picture.
|
|
452
|
+
pictured, after its first picture. Contrast reads again after the
|
|
453
|
+
first picture in each color scheme, because a palette has two halves
|
|
454
|
+
and only one is on screen at a time. */
|
|
447
455
|
if (index === 0 && item.checks?.code) {
|
|
448
456
|
progress.stage = 'checking its code blocks'
|
|
449
|
-
|
|
457
|
+
const blocks = await codeBlocks(page, item.checks.code)
|
|
458
|
+
findings = findings.concat(blocks.map((text) => ({ check: 'code', text })))
|
|
459
|
+
}
|
|
460
|
+
const contrast = item.checks?.contrast && !read.has(shot.scheme)
|
|
461
|
+
if ((index === 0 && (item.checks?.clipped || item.checks?.overlap)) || contrast) {
|
|
462
|
+
progress.stage = 'reading the page'
|
|
463
|
+
read.add(shot.scheme)
|
|
464
|
+
findings = findings.concat(
|
|
465
|
+
await readPage(page, {
|
|
466
|
+
clipped: index === 0 ? (item.checks.clipped ?? null) : null,
|
|
467
|
+
contrast: contrast ? { ...item.checks.contrast, scheme: shot.scheme } : null,
|
|
468
|
+
overlap: index === 0 ? (item.checks.overlap ?? null) : null,
|
|
469
|
+
}),
|
|
470
|
+
)
|
|
450
471
|
}
|
|
451
472
|
if (index === 0 && item.aria) {
|
|
452
473
|
progress.stage = 'taking its accessibility snapshot'
|
package/visual/index.d.ts
CHANGED
|
@@ -266,6 +266,11 @@ export interface Inspect<Result = unknown> {
|
|
|
266
266
|
|
|
267
267
|
/** One exception to the comparison, with the reason it exists. */
|
|
268
268
|
export interface Exception {
|
|
269
|
+
/**
|
|
270
|
+
* Page checks whose findings on this entry are excused, by name: `clipped`, `code`, `contrast` or
|
|
271
|
+
* `overlap`. Each fails the run once its check finds nothing on the entry.
|
|
272
|
+
*/
|
|
273
|
+
checks?: Array<'clipped' | 'code' | 'contrast' | 'overlap'>
|
|
269
274
|
/** A known difference between a pair's two sides, tolerated however large. It fails the run once the two match on two takes. */
|
|
270
275
|
differs?: boolean
|
|
271
276
|
/** An entry's id, `styled/aura`, or its name in every source, `aura`. */
|
|
@@ -299,6 +304,35 @@ export interface Config {
|
|
|
299
304
|
* `minimum`, 20 when not given, for the fewest characters a block holds before it is judged.
|
|
300
305
|
*/
|
|
301
306
|
code?: boolean | { minimum?: number; selector?: string }
|
|
307
|
+
/**
|
|
308
|
+
* A box under `overflow: hidden` or `clip` that cuts off its own text, where no `text-overflow` or line
|
|
309
|
+
* clamp says it means to. Only words that cross the box's edge count, so slides scrolled wholly out of
|
|
310
|
+
* view do not. `ignore` names elements the check leaves alone, with everything inside them.
|
|
311
|
+
*/
|
|
312
|
+
clipped?: boolean | { ignore?: string[] }
|
|
313
|
+
/**
|
|
314
|
+
* Each ink against the ground it is painted on, in every color scheme the shots picture, against WCAG
|
|
315
|
+
* 2.1 AA: `target`, 4.5, for text, and `component`, 3, for icons, for text hidden from the accessibility
|
|
316
|
+
* tree, and for text from `large` pixels, 24, or from `largeBold` when bold, 18.66. A ground behind a
|
|
317
|
+
* picture, a video or a background image is not one color, and is not judged. `rules` are colors that
|
|
318
|
+
* draw a line that separates, held to no target.
|
|
319
|
+
*/
|
|
320
|
+
contrast?:
|
|
321
|
+
| boolean
|
|
322
|
+
| {
|
|
323
|
+
component?: number
|
|
324
|
+
ignore?: string[]
|
|
325
|
+
large?: number
|
|
326
|
+
largeBold?: number
|
|
327
|
+
rules?: string[]
|
|
328
|
+
target?: number
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Two boxes of text or controls that draw over each other in the flow. A box positioned absolutely,
|
|
332
|
+
* fixed or sticky, given a `z-index`, transformed, floated or in the top layer is placed on purpose, and
|
|
333
|
+
* leaves the pair alone.
|
|
334
|
+
*/
|
|
335
|
+
overlap?: boolean | { ignore?: string[] }
|
|
302
336
|
}
|
|
303
337
|
/** How a shot is compared with its baseline, unless its source sets its own. */
|
|
304
338
|
diff?: Diff
|
|
@@ -308,6 +342,11 @@ export interface Config {
|
|
|
308
342
|
fingerprint?: string
|
|
309
343
|
/** Checks of your own on each page, after its first picture. */
|
|
310
344
|
inspect?: Inspect
|
|
345
|
+
/**
|
|
346
|
+
* Custom elements manifests, `custom-elements.json`, relative to the config. A change traced to a custom
|
|
347
|
+
* element names the file its manifest says defines it.
|
|
348
|
+
*/
|
|
349
|
+
manifest?: string[]
|
|
311
350
|
/**
|
|
312
351
|
* Selectors whose elements are painted over in one fixed color in every picture and left out of every
|
|
313
352
|
* accessibility snapshot, for what changes on its own and says nothing about the page, such as a version
|