@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,81 @@
1
+ /**
2
+ * Resolves the viewport sweep a run is driven at.
3
+ *
4
+ * The lesson this carries is that a defect can be a function of how much scroll
5
+ * remains rather than of the markup: a rail that skipped its middle sections
6
+ * passed at 900 and failed at 1200 and 1500, so a single height reports clean
7
+ * over a live defect. Every other probe measures a page, and this one decides
8
+ * how many pages there are to measure.
9
+ *
10
+ * It refuses rather than defaulting, which is the whole of its contract. The
11
+ * heights that separate a passing render from a failing one are a property of
12
+ * the layout being driven, and the only evidence on hand is one fixture's
13
+ * 900-to-1500 failure range, far too narrow a sample to ship as a default that
14
+ * every later caller would inherit without choosing it.
15
+ */
16
+
17
+ export interface Viewport {
18
+ readonly width: number
19
+ readonly height: number
20
+ }
21
+
22
+ /** What the caller wrote under `viewport`, before anything is trusted about it. */
23
+ export interface ViewportSpec {
24
+ readonly width?: unknown
25
+ readonly heights?: unknown
26
+ }
27
+
28
+ export type ViewportRefusal = 'no-viewport' | 'no-width' | 'no-heights'
29
+
30
+ export type ViewportRead =
31
+ | { readonly kind: 'resolved'; readonly viewports: readonly Viewport[] }
32
+ | {
33
+ readonly kind: 'refused'
34
+ readonly reason: ViewportRefusal
35
+ readonly detail: string
36
+ }
37
+
38
+ function refused(reason: ViewportRefusal, detail: string): ViewportRead {
39
+ return { kind: 'refused', reason, detail }
40
+ }
41
+
42
+ export function resolveViewports(spec: ViewportSpec | undefined): ViewportRead {
43
+ if (spec === undefined || typeof spec !== 'object' || spec === null) {
44
+ return refused(
45
+ 'no-viewport',
46
+ 'the run declares no viewport, and the heights a defect hides at are a property of the layout rather than of this command',
47
+ )
48
+ }
49
+
50
+ if (typeof spec.width !== 'number' || spec.width <= 0) {
51
+ return refused(
52
+ 'no-width',
53
+ 'viewport.width is not a positive number, so no render has a width to wrap at',
54
+ )
55
+ }
56
+
57
+ if (!Array.isArray(spec.heights) || spec.heights.length === 0) {
58
+ return refused(
59
+ 'no-heights',
60
+ 'viewport.heights names no height. Name every height the layout should hold at, since one height reports clean over a defect that is a function of remaining scroll.',
61
+ )
62
+ }
63
+
64
+ const viewports: Viewport[] = []
65
+ for (const height of spec.heights) {
66
+ if (typeof height !== 'number' || height <= 0) {
67
+ return refused(
68
+ 'no-heights',
69
+ `viewport.heights carries ${String(height)}, which is not a positive number`,
70
+ )
71
+ }
72
+ viewports.push({ width: spec.width, height })
73
+ }
74
+
75
+ return { kind: 'resolved', viewports }
76
+ }
77
+
78
+ /** How a viewport is named on a finding, so two sweeps read apart in one report. */
79
+ export function describeViewport(viewport: Viewport): string {
80
+ return `${viewport.width}x${viewport.height}`
81
+ }
@@ -0,0 +1,266 @@
1
+ import type { Page } from 'playwright-core'
2
+ import {
3
+ type ViewportSpec,
4
+ type ViewportRefusal,
5
+ resolveViewports,
6
+ } from '@/driver/probes/viewport'
7
+
8
+ /**
9
+ * The interaction vocabulary a caller writes a run in, and the runner that
10
+ * performs one step of it.
11
+ *
12
+ * Adapted from `@/demo/drive`'s `runStep` with the pointer travel, the video
13
+ * context, and the caption bar dropped. Driving for a recording and driving for
14
+ * a measurement want opposite things from the same actions: a recording pays
15
+ * for visible motion between two points, and a measurement wants the page in
16
+ * the next state as directly as the engine can put it there, since every
17
+ * millisecond of travel is a millisecond a probe is not reading.
18
+ */
19
+
20
+ /** How long a transition is given to finish before anything measures the result. */
21
+ const SETTLE_MS = 250
22
+
23
+ /**
24
+ * The probes a step can ask for. A fixed catalog rather than the pluggable
25
+ * subject system `@/inventory/subjects` carries, because every lesson behind
26
+ * these was a probe running at the wrong moment or trusting the wrong signal,
27
+ * rather than a probe a project needed to author for itself.
28
+ */
29
+ export const PROBE_NAMES = [
30
+ 'focus',
31
+ 'details',
32
+ 'diagram-geometry',
33
+ 'diagram-strokes',
34
+ ] as const
35
+
36
+ export type ProbeName = (typeof PROBE_NAMES)[number]
37
+
38
+ /**
39
+ * One thing a probe measured and judged wrong. The step and the viewport are
40
+ * filled in by the orchestrator rather than by the probe, since a probe runs
41
+ * with no knowledge of which pass it is on and a finding is unreadable without
42
+ * both. A `measured` row carries what a reader needs to check the judgment
43
+ * without rerunning the probe.
44
+ */
45
+ export interface Finding {
46
+ readonly probe: ProbeName
47
+ readonly selector: string
48
+ readonly detail: string
49
+ readonly measured: string
50
+ }
51
+
52
+ /** A finding once the run has said where it happened. */
53
+ export interface PlacedFinding extends Finding {
54
+ readonly step: string
55
+ readonly viewport: string
56
+ }
57
+
58
+ interface StepCommon {
59
+ /** What the step did, carried onto every finding it produced. */
60
+ readonly name: string
61
+ /** Overrides the run-level list for this step alone. */
62
+ readonly probes?: readonly ProbeName[]
63
+ }
64
+
65
+ export type DriverStep = StepCommon &
66
+ (
67
+ | { readonly kind: 'click'; readonly target: string }
68
+ | { readonly kind: 'scroll'; readonly target: string }
69
+ | { readonly kind: 'fill'; readonly target: string; readonly text: string }
70
+ | { readonly kind: 'tab'; readonly count: number }
71
+ | { readonly kind: 'wait'; readonly ms: number }
72
+ )
73
+
74
+ export interface DriverPlan {
75
+ /** Runs after every step that names no list of its own. */
76
+ readonly probes: readonly ProbeName[]
77
+ readonly viewports: readonly {
78
+ readonly width: number
79
+ readonly height: number
80
+ }[]
81
+ readonly steps: readonly DriverStep[]
82
+ }
83
+
84
+ export type PlanRefusal =
85
+ | 'unreadable-plan'
86
+ | 'no-steps'
87
+ | 'no-probes'
88
+ | 'unknown-probe'
89
+ | 'bad-step'
90
+ | ViewportRefusal
91
+
92
+ export type PlanRead =
93
+ | { readonly kind: 'read'; readonly plan: DriverPlan }
94
+ | {
95
+ readonly kind: 'refused'
96
+ readonly reason: PlanRefusal
97
+ readonly detail: string
98
+ }
99
+
100
+ function refused(reason: PlanRefusal, detail: string): PlanRead {
101
+ return { kind: 'refused', reason, detail }
102
+ }
103
+
104
+ /**
105
+ * Reads a run out of a JSON document. A file rather than a config key at the
106
+ * project root, and JSON rather than the TOML `canon inventory` declares its
107
+ * routes in, because a route catalog is state a project holds and an
108
+ * interaction sequence is a script written for one question.
109
+ */
110
+ export function readDriverPlan(source: string): PlanRead {
111
+ let document: unknown
112
+ try {
113
+ document = JSON.parse(source)
114
+ } catch (error) {
115
+ return refused(
116
+ 'unreadable-plan',
117
+ error instanceof Error ? error.message : String(error),
118
+ )
119
+ }
120
+
121
+ if (typeof document !== 'object' || document === null) {
122
+ return refused('unreadable-plan', 'the document is not an object')
123
+ }
124
+
125
+ const record = document as Record<string, unknown>
126
+
127
+ const viewports = resolveViewports(
128
+ record.viewport as ViewportSpec | undefined,
129
+ )
130
+ if (viewports.kind === 'refused') {
131
+ return refused(viewports.reason, viewports.detail)
132
+ }
133
+
134
+ const probes = readProbeList(record.probes)
135
+ if (typeof probes === 'string') return refused('unknown-probe', probes)
136
+ if (probes.length === 0) {
137
+ return refused(
138
+ 'no-probes',
139
+ 'probes names no probe, so every step would run and measure nothing',
140
+ )
141
+ }
142
+
143
+ if (!Array.isArray(record.steps) || record.steps.length === 0) {
144
+ return refused('no-steps', 'steps carries no entry, so nothing is driven')
145
+ }
146
+
147
+ const steps: DriverStep[] = []
148
+ for (const [index, entry] of record.steps.entries()) {
149
+ const step = readStep(entry, index)
150
+ if (typeof step === 'string') return refused('bad-step', step)
151
+ if (step.probes) {
152
+ const named = readProbeList(step.probes)
153
+ if (typeof named === 'string') return refused('unknown-probe', named)
154
+ // Refused for the same reason the run-level list is. An empty list here
155
+ // silently runs the step and measures nothing, and a pass reporting zero
156
+ // findings is indistinguishable from one that looked.
157
+ if (named.length === 0) {
158
+ return refused(
159
+ 'no-probes',
160
+ `${step.name} names an empty probe list, so the step would run and measure nothing`,
161
+ )
162
+ }
163
+ }
164
+ steps.push(step)
165
+ }
166
+
167
+ return {
168
+ kind: 'read',
169
+ plan: { probes, viewports: viewports.viewports, steps },
170
+ }
171
+ }
172
+
173
+ /** Returns the parsed list, or the message naming what failed. */
174
+ function readProbeList(value: unknown): ProbeName[] | string {
175
+ if (value === undefined) return [...PROBE_NAMES]
176
+ if (!Array.isArray(value)) return 'probes is not a list'
177
+
178
+ const names: ProbeName[] = []
179
+ for (const entry of value) {
180
+ if (!isProbeName(entry)) {
181
+ return `no probe named ${String(entry)}. This build ships: ${PROBE_NAMES.join(', ')}.`
182
+ }
183
+ names.push(entry)
184
+ }
185
+ return names
186
+ }
187
+
188
+ function isProbeName(value: unknown): value is ProbeName {
189
+ return typeof value === 'string' && PROBE_NAMES.includes(value as ProbeName)
190
+ }
191
+
192
+ /** Returns the parsed step, or the message naming what failed. */
193
+ function readStep(entry: unknown, index: number): DriverStep | string {
194
+ const at = `step ${index + 1}`
195
+ if (typeof entry !== 'object' || entry === null)
196
+ return `${at} is not an object`
197
+
198
+ const step = entry as Record<string, unknown>
199
+ const name = typeof step.name === 'string' ? step.name : undefined
200
+ if (!name) return `${at} carries no name, and a finding is attributed by it`
201
+
202
+ const probes = step.probes as readonly ProbeName[] | undefined
203
+ const common = { name, ...(probes ? { probes } : {}) }
204
+ const target = typeof step.target === 'string' ? step.target : undefined
205
+
206
+ switch (step.kind) {
207
+ case 'click':
208
+ if (!target) return `${at} is a click with no target`
209
+ return { ...common, kind: 'click', target }
210
+ case 'scroll':
211
+ if (!target) return `${at} is a scroll with no target`
212
+ return { ...common, kind: 'scroll', target }
213
+ case 'fill':
214
+ if (!target) return `${at} is a fill with no target`
215
+ if (typeof step.text !== 'string') return `${at} is a fill with no text`
216
+ return { ...common, kind: 'fill', target, text: step.text }
217
+ case 'tab':
218
+ return {
219
+ ...common,
220
+ kind: 'tab',
221
+ count: typeof step.count === 'number' ? step.count : 1,
222
+ }
223
+ case 'wait':
224
+ if (typeof step.ms !== 'number') return `${at} is a wait with no ms`
225
+ return { ...common, kind: 'wait', ms: step.ms }
226
+ default:
227
+ return `${at} names kind ${String(step.kind)}, which is not click, scroll, fill, tab, or wait`
228
+ }
229
+ }
230
+
231
+ /** The probes this step asks for, which is the run's list unless it names one. */
232
+ export function stepProbes(
233
+ plan: DriverPlan,
234
+ step: DriverStep,
235
+ ): readonly ProbeName[] {
236
+ return step.probes ?? plan.probes
237
+ }
238
+
239
+ /**
240
+ * Performs one step and lets whatever it started settle. The settle is the
241
+ * step's rather than each probe's, because a transition runs once and every
242
+ * probe after it would otherwise pay for the same wait again.
243
+ */
244
+ export async function runStep(page: Page, step: DriverStep): Promise<void> {
245
+ switch (step.kind) {
246
+ case 'click':
247
+ await page.locator(step.target).first().click()
248
+ break
249
+ case 'scroll':
250
+ await page.locator(step.target).first().scrollIntoViewIfNeeded()
251
+ break
252
+ case 'fill':
253
+ await page.locator(step.target).first().fill(step.text)
254
+ break
255
+ case 'tab':
256
+ for (let press = 0; press < step.count; press += 1) {
257
+ await page.keyboard.press('Tab')
258
+ }
259
+ break
260
+ case 'wait':
261
+ await page.waitForTimeout(step.ms)
262
+ return
263
+ }
264
+
265
+ await page.waitForTimeout(SETTLE_MS)
266
+ }
@@ -1,6 +1,10 @@
1
1
  import { chromium } from 'playwright-core'
2
2
  import type { Browser } from 'playwright-core'
3
- import { isBrowserMissing } from '@/browser/engine'
3
+ import {
4
+ enterKeyboardModality,
5
+ isBrowserMissing,
6
+ isServerUnreachable,
7
+ } from '@/browser/engine'
4
8
  import { routeUrl } from '@/inventory/config'
5
9
  import type { Reading } from '@/inventory/group'
6
10
  import type { Subject } from '@/inventory/subjects'
@@ -61,19 +65,6 @@ function failed(reason: WalkRefusal, error: unknown): WalkResult {
61
65
  }
62
66
  }
63
67
 
64
- /**
65
- * Separates a server nobody started from a page that failed for its own
66
- * reasons. The first is the precondition this command cannot create, and
67
- * reporting it as an empty listing would say the site gives no answers when
68
- * nothing was ever asked.
69
- */
70
- function isServerUnreachable(error: unknown): boolean {
71
- const text = error instanceof Error ? error.message : String(error)
72
- return /ERR_CONNECTION_REFUSED|ERR_NAME_NOT_RESOLVED|ERR_CONNECTION_RESET|ERR_EMPTY_RESPONSE/i.test(
73
- text,
74
- )
75
- }
76
-
77
68
  export async function walk(options: WalkOptions): Promise<WalkResult> {
78
69
  const started = Date.now()
79
70
 
@@ -98,10 +89,7 @@ export async function walk(options: WalkOptions): Promise<WalkResult> {
98
89
  waitUntil: 'domcontentloaded',
99
90
  })
100
91
 
101
- // Puts the page in keyboard modality before anything is focused, because
102
- // a `:focus-visible` ring is the treatment a pointer never reveals and
103
- // programmatic focus alone does not match it.
104
- await page.keyboard.press('Tab')
92
+ await enterKeyboardModality(page)
105
93
 
106
94
  const rows = await page.evaluate(options.subject.read, options.query)
107
95
  for (const row of rows) readings.push({ route, ...row })