@erclx/canon 4.5.0 → 4.6.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.
@@ -0,0 +1,184 @@
1
+ import { chromium } from 'playwright-core'
2
+ import type { Browser, Page } from 'playwright-core'
3
+ import { isBrowserMissing, isServerUnreachable } from '@/browser/engine'
4
+ import { probeDetails } from '@/driver/probes/details'
5
+ import {
6
+ probeDiagramGeometry,
7
+ probeDiagramStrokes,
8
+ } from '@/driver/probes/diagram'
9
+ import { probeFocus } from '@/driver/probes/focus'
10
+ import { describeViewport } from '@/driver/probes/viewport'
11
+ import type { Viewport } from '@/driver/probes/viewport'
12
+ import { runStep, stepProbes } from '@/driver/steps'
13
+ import type {
14
+ DriverPlan,
15
+ Finding,
16
+ PlacedFinding,
17
+ ProbeName,
18
+ } from '@/driver/steps'
19
+
20
+ /**
21
+ * Walks a page through a caller's interaction sequence and measures each state
22
+ * it reaches. Every browser reference the driver adds lives here, and
23
+ * `src/commands/driver.ts` reaches it through a dynamic import so no other
24
+ * command resolves the engine at startup.
25
+ *
26
+ * Like `@/demo/drive` and `@/inventory/walk`, and unlike `@/capture/render`,
27
+ * this module ships. A command whose whole purpose is measuring someone else's
28
+ * page cannot stay toolkit-only, and nothing here reaches into `@/capture/`,
29
+ * which `files` in `package.json` excludes from the published package, so the
30
+ * two surfaces move independently.
31
+ *
32
+ * What separates it from `canon capture` is the axis it adds. A render answers
33
+ * about a page as it loads, and every defect that exists only after a menu
34
+ * opens, an answer is chosen, or the page scrolls is invisible to one. Probes
35
+ * therefore run after a step rather than on arrival, and a run reaches the load
36
+ * state by opening with a `wait` step of its own. That matters most where
37
+ * capture does not run, since it is toolkit-only and this command is then the
38
+ * only thing measuring the page at all.
39
+ */
40
+
41
+ /** Each probe keyed by the name a caller writes in the run. */
42
+ const PROBES: Record<ProbeName, (page: Page) => Promise<Finding[]>> = {
43
+ focus: probeFocus,
44
+ details: probeDetails,
45
+ 'diagram-geometry': probeDiagramGeometry,
46
+ 'diagram-strokes': probeDiagramStrokes,
47
+ }
48
+
49
+ export interface DriveOptions {
50
+ readonly url: string
51
+ readonly plan: DriverPlan
52
+ }
53
+
54
+ /** One step measured at one viewport, so a sweep reads as passes rather than a total. */
55
+ export interface PassReading {
56
+ readonly viewport: string
57
+ readonly step: string
58
+ readonly probes: number
59
+ readonly findings: number
60
+ }
61
+
62
+ export type DriverRefusal =
63
+ | 'browser-missing'
64
+ | 'server-unreachable'
65
+ | 'drive-failed'
66
+
67
+ export type DriverResult =
68
+ | {
69
+ readonly status: 'driven'
70
+ readonly findings: readonly PlacedFinding[]
71
+ readonly passes: readonly PassReading[]
72
+ readonly durationMs: number
73
+ }
74
+ | {
75
+ readonly status: 'failed'
76
+ readonly reason: DriverRefusal
77
+ readonly message: string
78
+ }
79
+
80
+ function failed(reason: DriverRefusal, error: unknown): DriverResult {
81
+ return {
82
+ status: 'failed',
83
+ reason,
84
+ message: error instanceof Error ? error.message : String(error),
85
+ }
86
+ }
87
+
88
+ export async function drive(options: DriveOptions): Promise<DriverResult> {
89
+ const started = Date.now()
90
+
91
+ let browser: Browser
92
+ try {
93
+ browser = await chromium.launch()
94
+ } catch (error) {
95
+ return failed(
96
+ isBrowserMissing(error) ? 'browser-missing' : 'drive-failed',
97
+ error,
98
+ )
99
+ }
100
+
101
+ const findings: PlacedFinding[] = []
102
+ const passes: PassReading[] = []
103
+
104
+ try {
105
+ for (const viewport of options.plan.viewports) {
106
+ const pass = await driveViewport(browser, options, viewport)
107
+ findings.push(...pass.findings)
108
+ passes.push(...pass.passes)
109
+ }
110
+ } catch (error) {
111
+ return failed(
112
+ isServerUnreachable(error) ? 'server-unreachable' : 'drive-failed',
113
+ error,
114
+ )
115
+ } finally {
116
+ // Dropped rather than propagated, because a close that fails beside a drive
117
+ // that already failed would replace the refusal the caller is about to
118
+ // receive with a reason about teardown.
119
+ await browser.close().catch(() => undefined)
120
+ }
121
+
122
+ return {
123
+ status: 'driven',
124
+ findings,
125
+ passes,
126
+ durationMs: Date.now() - started,
127
+ }
128
+ }
129
+
130
+ /**
131
+ * One full pass of the sequence at one viewport, in a context of its own.
132
+ *
133
+ * A fresh context per height rather than a resize of the last one, because a
134
+ * step already run has left the page in a state the next height would inherit,
135
+ * and a sweep exists to ask the same question of each rather than to ask a
136
+ * later one of a page the earlier heights already drove.
137
+ */
138
+ async function driveViewport(
139
+ browser: Browser,
140
+ options: DriveOptions,
141
+ viewport: Viewport,
142
+ ): Promise<{ findings: PlacedFinding[]; passes: PassReading[] }> {
143
+ const context = await browser.newContext({
144
+ viewport,
145
+ // Pointed the opposite way from `@/demo/drive`, which asks for the motion
146
+ // the interface was designed with. A measurement wants it suppressed, since
147
+ // geometry read mid-transition is a reading of neither state.
148
+ reducedMotion: 'reduce',
149
+ })
150
+
151
+ const findings: PlacedFinding[] = []
152
+ const passes: PassReading[] = []
153
+ const where = describeViewport(viewport)
154
+
155
+ try {
156
+ const page = await context.newPage()
157
+ await page.goto(options.url, { waitUntil: 'domcontentloaded' })
158
+
159
+ for (const step of options.plan.steps) {
160
+ await runStep(page, step)
161
+
162
+ const names = stepProbes(options.plan, step)
163
+ let count = 0
164
+ for (const name of names) {
165
+ const probe = PROBES[name]
166
+ for (const finding of await probe(page)) {
167
+ findings.push({ ...finding, step: step.name, viewport: where })
168
+ count += 1
169
+ }
170
+ }
171
+
172
+ passes.push({
173
+ viewport: where,
174
+ step: step.name,
175
+ probes: names.length,
176
+ findings: count,
177
+ })
178
+ }
179
+ } finally {
180
+ await context.close().catch(() => undefined)
181
+ }
182
+
183
+ return { findings, passes }
184
+ }
@@ -0,0 +1,116 @@
1
+ import type { Page } from 'playwright-core'
2
+ import type { Finding } from '@/driver/steps'
3
+
4
+ /**
5
+ * Measures every `<details>` on the page twice, once shut and once open, and
6
+ * judges only the open reading.
7
+ *
8
+ * Two lessons meet here and they pull opposite ways. A closed `<details>` still
9
+ * reports a layout box and the box is nonsense: at 390 pixels wide a shut menu
10
+ * measures 13, so its rows read as 18-pixel tap targets hanging off the
11
+ * viewport, which is where 134 false findings came from in a single run.
12
+ * Filtering the closed ones out then opens a blind spot the same size, since
13
+ * the one real menu defect on that page was an open dropdown sitting 35 pixels
14
+ * off a phone screen and true only while open. Neither reading alone is enough,
15
+ * so both are taken and the closed one is carried as context rather than judged.
16
+ *
17
+ * Discovery is automatic rather than selector-driven. The real defect was
18
+ * missed because nobody thought to name that menu, and a probe that takes a
19
+ * selector reproduces that failure every time the author's attention is the
20
+ * thing at fault.
21
+ */
22
+
23
+ /** The smallest a control may be before a finger cannot reliably hit it. */
24
+ const MIN_TAP_PX = 24
25
+
26
+ /** Ignores a sub-pixel overhang, which is a rounding artifact rather than a defect. */
27
+ const OVERFLOW_TOLERANCE_PX = 1
28
+
29
+ export async function probeDetails(page: Page): Promise<Finding[]> {
30
+ const rows = await page.evaluate(
31
+ ([minTap, tolerance]: [number, number]) => {
32
+ const describe = (element: Element): string => {
33
+ const tag = element.tagName.toLowerCase()
34
+ if (element.id) return `${tag}#${element.id}`
35
+ const className = element.getAttribute('class')?.trim().split(/\s+/)[0]
36
+ return className ? `${tag}.${className}` : tag
37
+ }
38
+
39
+ const box = (element: Element) => {
40
+ const rect = element.getBoundingClientRect()
41
+ return {
42
+ left: rect.left,
43
+ right: rect.right,
44
+ width: rect.width,
45
+ height: rect.height,
46
+ }
47
+ }
48
+
49
+ const found: {
50
+ selector: string
51
+ detail: string
52
+ measured: string
53
+ }[] = []
54
+
55
+ const width = document.documentElement.clientWidth
56
+
57
+ for (const element of Array.from(document.querySelectorAll('details'))) {
58
+ if (!(element instanceof HTMLDetailsElement)) continue
59
+
60
+ // Named for the state it was actually in rather than assumed shut. A
61
+ // step that clicked the menu open leaves this reading an open one, and
62
+ // calling it shut would report a number as evidence of the opposite
63
+ // state to the one it was taken in.
64
+ const wasOpen = element.open
65
+ const entry = box(element)
66
+ const entryState = wasOpen ? 'already open' : 'shut'
67
+ element.open = true
68
+
69
+ const rows = Array.from(
70
+ element.querySelectorAll('a[href], button, [role="menuitem"], li'),
71
+ )
72
+
73
+ // The panel is routinely out of flow, so the element's own box stops at
74
+ // the summary and says nothing about where the menu landed. Every part
75
+ // is measured and the furthest overhang is the finding.
76
+ let worst: { part: Element; off: number; left: number } | undefined
77
+ for (const part of [element, ...element.querySelectorAll('*')]) {
78
+ const partBox = box(part)
79
+ if (partBox.width === 0 && partBox.height === 0) continue
80
+ const off = Math.max(partBox.right - width, -partBox.left)
81
+ if (off <= tolerance) continue
82
+ if (worst && off <= worst.off) continue
83
+ worst = { part, off, left: partBox.left }
84
+ }
85
+
86
+ if (worst) {
87
+ found.push({
88
+ selector: describe(worst.part),
89
+ detail: `sits ${Math.round(worst.off)}px outside the viewport once ${describe(element)} is open`,
90
+ measured: `open at left ${Math.round(worst.left)} in a ${width}px viewport, against an ${entryState} ${describe(element)} box of ${Math.round(entry.width)}x${Math.round(entry.height)} that measures nothing about where the panel lands`,
91
+ })
92
+ }
93
+
94
+ for (const row of rows) {
95
+ const item = box(row)
96
+ if (item.width === 0 && item.height === 0) continue
97
+ if (item.width >= minTap && item.height >= minTap) continue
98
+ found.push({
99
+ selector: `${describe(element)} ${describe(row)}`,
100
+ detail: `is ${Math.round(item.width)}x${Math.round(item.height)} once open, under the ${minTap}px minimum`,
101
+ measured: `read in the open state, since the ${entryState} box of ${Math.round(entry.width)}x${Math.round(entry.height)} reports every row inside it wrong`,
102
+ })
103
+ }
104
+
105
+ // Restored because the probe runs between the caller's own steps, and a
106
+ // menu this left hanging open is a state no later step asked for.
107
+ element.open = wasOpen
108
+ }
109
+
110
+ return found
111
+ },
112
+ [MIN_TAP_PX, OVERFLOW_TOLERANCE_PX] as [number, number],
113
+ )
114
+
115
+ return rows.map((row) => ({ probe: 'details' as const, ...row }))
116
+ }
@@ -0,0 +1,260 @@
1
+ import type { Page } from 'playwright-core'
2
+ import type { Finding } from '@/driver/steps'
3
+
4
+ /**
5
+ * Reads SVG label geometry off the rendered page, in two probes that differ by
6
+ * what they compare a label against.
7
+ *
8
+ * Everything here is measured in the browser after `document.fonts.ready` and
9
+ * never computed from the markup, because a generated page is routinely
10
+ * authored against one font and rendered in another. A stylesheet restyling
11
+ * `figure svg text` to a hand face after the coordinates were picked against
12
+ * monospace moves every label, so a label that cleared a line at its authored
13
+ * width can overlap it once rendered and the markup still says it does not.
14
+ *
15
+ * `clientWidth` is not the reading. Comparing it to `scrollWidth` flags every
16
+ * label whose string is wider than its element box whether or not anything is
17
+ * actually cut, which is why both probes are geometry against other geometry.
18
+ */
19
+
20
+ /**
21
+ * Trims the slack a text rect carries above and below the glyphs. Vertical
22
+ * only: a text rect is loose top and bottom and tight to the glyphs left and
23
+ * right, and a one-pixel horizontal inset was enough to pass a label overrunning
24
+ * a panel border by three tenths of a unit. Insetting the vertical band alone
25
+ * stayed free of false positives across 41 strokes.
26
+ */
27
+ const VERTICAL_INSET_PX = 2
28
+
29
+ /**
30
+ * How far two labels overlap before it counts. An SVG text rect is the full
31
+ * font box rather than the ink, so two properly spaced lines touch by a pixel
32
+ * and a zero threshold reports every one of them.
33
+ */
34
+ const COLLISION_PX = 2
35
+
36
+ /** Sampling interval along a stroke, in device pixels. */
37
+ const STROKE_SAMPLE_PX = 2
38
+
39
+ /** Bounds the sampling of one stroke, so a long path cannot stall the pass. */
40
+ const MAX_STROKE_SAMPLES = 600
41
+
42
+ export async function probeDiagramGeometry(page: Page): Promise<Finding[]> {
43
+ const rows = await page.evaluate(
44
+ async ([inset, collision]: [number, number]) => {
45
+ await document.fonts.ready
46
+
47
+ const describe = (element: Element): string => {
48
+ const owner = element.closest('svg')
49
+ const label = (element.textContent ?? '').trim().slice(0, 40)
50
+ const id = owner?.id ? `svg#${owner.id}` : 'svg'
51
+ return label ? `${id} text "${label}"` : `${id} text`
52
+ }
53
+
54
+ const insetRect = (element: Element) => {
55
+ const rect = element.getBoundingClientRect()
56
+ return {
57
+ left: rect.left,
58
+ right: rect.right,
59
+ top: rect.top + inset,
60
+ bottom: rect.bottom - inset,
61
+ }
62
+ }
63
+
64
+ const overlap = (
65
+ a: { left: number; right: number; top: number; bottom: number },
66
+ b: { left: number; right: number; top: number; bottom: number },
67
+ ) => ({
68
+ x: Math.min(a.right, b.right) - Math.max(a.left, b.left),
69
+ y: Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top),
70
+ })
71
+
72
+ const found: { selector: string; detail: string; measured: string }[] = []
73
+
74
+ for (const root of Array.from(document.querySelectorAll('svg'))) {
75
+ const painted = Array.from(root.querySelectorAll('*'))
76
+ const labels = Array.from(root.querySelectorAll('text'))
77
+ const frame = root.getBoundingClientRect()
78
+
79
+ for (const label of labels) {
80
+ const rect = insetRect(label)
81
+ if (rect.right <= rect.left || rect.bottom <= rect.top) continue
82
+
83
+ if (
84
+ rect.left < frame.left - collision ||
85
+ rect.right > frame.right + collision ||
86
+ rect.top < frame.top - collision ||
87
+ rect.bottom > frame.bottom + collision
88
+ ) {
89
+ found.push({
90
+ selector: describe(label),
91
+ detail: 'is painted outside its own frame',
92
+ measured: `label spans ${Math.round(rect.left)}..${Math.round(rect.right)} horizontally and ${Math.round(rect.top)}..${Math.round(rect.bottom)} vertically, against a frame of ${Math.round(frame.left)}..${Math.round(frame.right)} and ${Math.round(frame.top)}..${Math.round(frame.bottom)}, vertical inset ${inset}px`,
93
+ })
94
+ }
95
+
96
+ const order = painted.indexOf(label)
97
+ for (const shape of painted) {
98
+ if (shape === label) continue
99
+ if (shape.tagName.toLowerCase() === 'text') continue
100
+ if (getComputedStyle(shape).fill === 'none') continue
101
+ if (!shape.getBoundingClientRect) continue
102
+
103
+ const box = shape.getBoundingClientRect()
104
+ if (box.width === 0 || box.height === 0) continue
105
+
106
+ const hit = overlap(rect, {
107
+ left: box.left,
108
+ right: box.right,
109
+ top: box.top,
110
+ bottom: box.bottom,
111
+ })
112
+ if (hit.x <= collision || hit.y <= collision) continue
113
+
114
+ const after = painted.indexOf(shape) > order
115
+ found.push({
116
+ selector: describe(label),
117
+ detail: after
118
+ ? `is covered by a filled ${shape.tagName.toLowerCase()} painted after it`
119
+ : `sits over a filled ${shape.tagName.toLowerCase()} with no plate behind the glyphs`,
120
+ measured: `${Math.round(hit.x)}x${Math.round(hit.y)}px of overlap, vertical inset ${inset}px`,
121
+ })
122
+ }
123
+ }
124
+
125
+ for (let first = 0; first < labels.length; first += 1) {
126
+ for (let second = first + 1; second < labels.length; second += 1) {
127
+ const a = labels[first]
128
+ const b = labels[second]
129
+ if (!a || !b) continue
130
+
131
+ const hit = overlap(insetRect(a), insetRect(b))
132
+ if (hit.x <= collision || hit.y <= collision) continue
133
+
134
+ found.push({
135
+ selector: describe(a),
136
+ detail: `collides with ${describe(b)}`,
137
+ measured: `${Math.round(hit.x)}x${Math.round(hit.y)}px of overlap, past the ${collision}px threshold, vertical inset ${inset}px`,
138
+ })
139
+ }
140
+ }
141
+ }
142
+
143
+ return found
144
+ },
145
+ [VERTICAL_INSET_PX, COLLISION_PX] as [number, number],
146
+ )
147
+
148
+ return rows.map((row) => ({ probe: 'diagram-geometry' as const, ...row }))
149
+ }
150
+
151
+ /**
152
+ * Reports a stroke crossing a label, sampled along the stroke's own geometry.
153
+ *
154
+ * A bounding box cannot answer this. The first version of the occlusion probe
155
+ * counted a shape as occluding only when it carried a fill, which filtered out
156
+ * every `line` and every `fill="none"` panel border before the comparison ran,
157
+ * and a label sitting on an arrow passed every run until a person saw it. A
158
+ * diagonal connector's box also covers most of the diagram while the stroke
159
+ * itself touches almost none of it, so the box would report the opposite error
160
+ * once the fill filter came off.
161
+ *
162
+ * Both paint orders are findings. A stroke drawn after the label crosses the
163
+ * glyphs out, and one drawn before it shows through them, since SVG text
164
+ * carries no background plate of its own.
165
+ */
166
+ export async function probeDiagramStrokes(page: Page): Promise<Finding[]> {
167
+ const rows = await page.evaluate(
168
+ async ([inset, interval, maxSamples]: [number, number, number]) => {
169
+ await document.fonts.ready
170
+
171
+ const describe = (element: Element): string => {
172
+ const owner = element.closest('svg')
173
+ const label = (element.textContent ?? '').trim().slice(0, 40)
174
+ const id = owner?.id ? `svg#${owner.id}` : 'svg'
175
+ return label ? `${id} text "${label}"` : `${id} text`
176
+ }
177
+
178
+ const insetRect = (element: Element) => {
179
+ const rect = element.getBoundingClientRect()
180
+ return {
181
+ left: rect.left,
182
+ right: rect.right,
183
+ top: rect.top + inset,
184
+ bottom: rect.bottom - inset,
185
+ }
186
+ }
187
+
188
+ const found: { selector: string; detail: string; measured: string }[] = []
189
+
190
+ for (const root of Array.from(document.querySelectorAll('svg'))) {
191
+ const painted = Array.from(root.querySelectorAll('*'))
192
+ const labels = Array.from(root.querySelectorAll('text'))
193
+ if (labels.length === 0) continue
194
+
195
+ for (const shape of painted) {
196
+ if (!(shape instanceof SVGGeometryElement)) continue
197
+ if (getComputedStyle(shape).stroke === 'none') continue
198
+
199
+ // Read off the shape rather than off the root, because
200
+ // `getPointAtLength` answers in the shape's own user space and a
201
+ // generated diagram nests almost everything under a transformed `g`.
202
+ // The root's matrix drops that translation, which puts every sampled
203
+ // point somewhere the label is not and reports the page clean.
204
+ const matrix = shape.getScreenCTM()
205
+ if (!matrix) continue
206
+
207
+ let length = 0
208
+ try {
209
+ length = shape.getTotalLength()
210
+ } catch {
211
+ continue
212
+ }
213
+ if (length === 0) continue
214
+
215
+ const samples = Math.min(
216
+ maxSamples,
217
+ Math.max(2, Math.ceil(length / interval)),
218
+ )
219
+ const order = painted.indexOf(shape)
220
+ const crossed = new Map<Element, number>()
221
+
222
+ for (let step = 0; step <= samples; step += 1) {
223
+ const point = shape.getPointAtLength((length * step) / samples)
224
+ const screen = new DOMPoint(point.x, point.y).matrixTransform(
225
+ matrix,
226
+ )
227
+
228
+ for (const label of labels) {
229
+ const rect = insetRect(label)
230
+ if (rect.bottom <= rect.top) continue
231
+ if (screen.x < rect.left || screen.x > rect.right) continue
232
+ if (screen.y < rect.top || screen.y > rect.bottom) continue
233
+ crossed.set(label, (crossed.get(label) ?? 0) + 1)
234
+ }
235
+ }
236
+
237
+ for (const [label, hits] of crossed) {
238
+ const after = order > painted.indexOf(label)
239
+ found.push({
240
+ selector: describe(label),
241
+ detail: after
242
+ ? `is crossed out by a ${shape.tagName.toLowerCase()} stroked after it`
243
+ : `is painted over a ${shape.tagName.toLowerCase()} stroke that shows through the glyphs`,
244
+ measured: `${hits} of ${samples + 1} points sampled along the stroke land inside the label, vertical inset ${inset}px`,
245
+ })
246
+ }
247
+ }
248
+ }
249
+
250
+ return found
251
+ },
252
+ [VERTICAL_INSET_PX, STROKE_SAMPLE_PX, MAX_STROKE_SAMPLES] as [
253
+ number,
254
+ number,
255
+ number,
256
+ ],
257
+ )
258
+
259
+ return rows.map((row) => ({ probe: 'diagram-strokes' as const, ...row }))
260
+ }
@@ -0,0 +1,121 @@
1
+ import type { Page } from 'playwright-core'
2
+ import { enterKeyboardModality } from '@/browser/engine'
3
+ import type { Finding } from '@/driver/steps'
4
+
5
+ /**
6
+ * Reports every element that takes focus and changes nothing a person can see.
7
+ *
8
+ * The lesson is in the first line of the run rather than in the comparison. A
9
+ * probe that focuses each element and reads its computed outline reports every
10
+ * correctly styled element as unstyled, because `:focus-visible` does not match
11
+ * a scripted `.focus()` while the browser is in pointer modality. That version
12
+ * produced 27 false findings against a page whose rings were all present. One
13
+ * Tab press ahead of the read puts the page in keyboard modality and the rule
14
+ * matches for the rest of the pass, which is why `enterKeyboardModality` is a
15
+ * shared helper rather than a line here.
16
+ *
17
+ * The reader below is serialized to source and evaluated by the browser, so
18
+ * every helper it needs is declared inside its own body. A reference to
19
+ * anything at module scope typechecks and throws once the page calls it.
20
+ */
21
+
22
+ /** The elements a keyboard reaches without the page authoring a tab order. */
23
+ const FOCUSABLE =
24
+ 'a[href], button, input, select, textarea, summary, [tabindex]:not([tabindex="-1"])'
25
+
26
+ /** What a ring is allowed to move, read at rest and again under focus. */
27
+ const PROPERTIES = [
28
+ 'outlineStyle',
29
+ 'outlineWidth',
30
+ 'outlineColor',
31
+ 'outlineOffset',
32
+ 'boxShadow',
33
+ 'borderColor',
34
+ 'backgroundColor',
35
+ 'color',
36
+ 'textDecorationLine',
37
+ ] as const
38
+
39
+ export async function probeFocus(page: Page): Promise<Finding[]> {
40
+ await enterKeyboardModality(page)
41
+
42
+ const rows = await page.evaluate(
43
+ ([query, properties]: [string, readonly string[]]) => {
44
+ const describe = (element: Element): string => {
45
+ const tag = element.tagName.toLowerCase()
46
+ if (element.id) return `${tag}#${element.id}`
47
+ const className = element.getAttribute('class')?.trim().split(/\s+/)[0]
48
+ return className ? `${tag}.${className}` : tag
49
+ }
50
+
51
+ const snapshot = (element: Element): string[] => {
52
+ const computed = getComputedStyle(element)
53
+ return properties.map(
54
+ (property) => computed[property as 'color'] as string,
55
+ )
56
+ }
57
+
58
+ // The modality press left one element focused, and reading its rest state
59
+ // while it still holds focus reports no difference for whatever ring it
60
+ // actually draws. Every element starts and ends this pass blurred.
61
+ const entry = document.activeElement
62
+ if (entry instanceof HTMLElement) entry.blur()
63
+
64
+ const found: { selector: string; visible: boolean; detail: string }[] = []
65
+
66
+ for (const element of Array.from(document.querySelectorAll(query))) {
67
+ if (!(element instanceof HTMLElement)) continue
68
+ // Tested by whether the element paints a box rather than by
69
+ // `offsetParent`, which is null for anything positioned `fixed` and
70
+ // would skip a pinned control that is plainly on screen.
71
+ if (element.getClientRects().length === 0) continue
72
+ if (getComputedStyle(element).visibility === 'hidden') continue
73
+
74
+ const rest = snapshot(element)
75
+ element.focus()
76
+ // An element the browser refuses to focus is skipped rather than
77
+ // reported. A disabled control has no ring by design, and folding it
78
+ // into the same row as a control that should have one and does not
79
+ // gives two findings one remedy.
80
+ if (document.activeElement !== element) {
81
+ element.blur()
82
+ continue
83
+ }
84
+
85
+ const focused = snapshot(element)
86
+ // An outline that resolves to `none` paints nothing, so a width, a
87
+ // color, or an offset moving underneath it is a computed difference a
88
+ // person cannot see. A stylesheet that clears `outline` for one control
89
+ // while a broader rule still sets `outline-offset` produces exactly
90
+ // that, and counting it would report the control as correctly ringed.
91
+ const blind =
92
+ focused[properties.indexOf('outlineStyle')] === 'none'
93
+ ? (property: string) => property.startsWith('outline')
94
+ : () => false
95
+ const moved = properties.filter(
96
+ (property, index) =>
97
+ rest[index] !== focused[index] && !blind(property),
98
+ )
99
+ element.blur()
100
+
101
+ found.push({
102
+ selector: describe(element),
103
+ visible: moved.length > 0,
104
+ detail: moved.join(', '),
105
+ })
106
+ }
107
+
108
+ return found
109
+ },
110
+ [FOCUSABLE, PROPERTIES] as [string, readonly string[]],
111
+ )
112
+
113
+ return rows
114
+ .filter((row) => !row.visible)
115
+ .map((row) => ({
116
+ probe: 'focus' as const,
117
+ selector: row.selector,
118
+ detail: 'takes keyboard focus and changes nothing visible',
119
+ measured: `no movement across ${PROPERTIES.length} properties under :focus-visible`,
120
+ }))
121
+ }