@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.
- package/claude/.claude-plugin/plugin.json +1 -1
- package/docs/agents/commands.md +3 -0
- package/docs/agents/driver.md +103 -0
- package/docs/agents/index.md +1 -0
- package/package.json +1 -1
- package/src/browser/engine.ts +50 -7
- package/src/cli.ts +4 -0
- package/src/commands/driver.ts +266 -0
- package/src/driver/drive.ts +184 -0
- package/src/driver/probes/details.ts +116 -0
- package/src/driver/probes/diagram.ts +260 -0
- package/src/driver/probes/focus.ts +121 -0
- package/src/driver/probes/viewport.ts +81 -0
- package/src/driver/steps.ts +266 -0
- package/src/inventory/walk.ts +6 -18
|
@@ -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
|
+
}
|
package/src/inventory/walk.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { chromium } from 'playwright-core'
|
|
2
2
|
import type { Browser } from 'playwright-core'
|
|
3
|
-
import {
|
|
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
|
-
|
|
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 })
|