@erclx/canon 4.4.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.
Files changed (45) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/claude-markdown-propose/REQUIREMENT.md +0 -1
  3. package/docs/agents/audits.md +6 -6
  4. package/docs/agents/commands.md +3 -0
  5. package/docs/agents/context-audit.md +2 -2
  6. package/docs/agents/driver.md +103 -0
  7. package/docs/agents/index.md +2 -1
  8. package/docs/agents/records.md +17 -7
  9. package/docs/agents/tasks.md +1 -1
  10. package/package.json +1 -1
  11. package/scripts/core/check-ignore-parity.sh +9 -3
  12. package/src/audits/baseline.ts +1 -1
  13. package/src/browser/engine.ts +50 -7
  14. package/src/cli.ts +4 -0
  15. package/src/commands/claude.ts +7 -1
  16. package/src/commands/context.ts +3 -3
  17. package/src/commands/design.ts +6 -1
  18. package/src/commands/driver.ts +266 -0
  19. package/src/commands/feedback.ts +5 -1
  20. package/src/commands/gov.ts +2 -1
  21. package/src/commands/slides.ts +6 -1
  22. package/src/context/citations.ts +16 -5
  23. package/src/context/folders.ts +18 -8
  24. package/src/driver/drive.ts +184 -0
  25. package/src/driver/probes/details.ts +116 -0
  26. package/src/driver/probes/diagram.ts +260 -0
  27. package/src/driver/probes/focus.ts +121 -0
  28. package/src/driver/probes/viewport.ts +81 -0
  29. package/src/driver/steps.ts +266 -0
  30. package/src/gate/measures.ts +1 -1
  31. package/src/intake/folder.ts +2 -1
  32. package/src/inventory/walk.ts +6 -18
  33. package/src/record-root.ts +134 -0
  34. package/src/records/backup.ts +40 -22
  35. package/src/records/size.ts +12 -7
  36. package/src/records/validate.ts +35 -17
  37. package/src/tasks/answers.ts +14 -7
  38. package/src/tasks/archive.ts +31 -21
  39. package/src/teach/workspace.ts +2 -1
  40. package/tooling/claude/manifest.toml +1 -1
  41. package/tooling/claude/seeds/.claude/hooks/index-reminder.sh +8 -1
  42. package/tooling/claude/seeds/.claude/hooks/memory-index.sh +28 -11
  43. package/tooling/claude/seeds/.claude/hooks/scratch-guard.sh +12 -3
  44. package/tooling/claude/seeds/.claude/hooks/standards-audit.sh +4 -0
  45. package/tooling/claude/seeds/.claude/hooks/tasks-index.sh +28 -10
@@ -0,0 +1,266 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { resolve } from 'node:path'
3
+ import type { Command } from 'commander'
4
+ import { INSTALL_BROWSER, isEngineMissing } from '@/browser/engine'
5
+ import type { DriverRefusal } from '@/driver/drive'
6
+ import { describeViewport } from '@/driver/probes/viewport'
7
+ import { PROBE_NAMES, readDriverPlan } from '@/driver/steps'
8
+ import type { PlanRefusal } from '@/driver/steps'
9
+ import { intro, logError, logInfo, logStep, logWarn, outro, plural } from '@/ui'
10
+
11
+ /**
12
+ * Holds wiring only. Every browser reference sits behind `loadDriver`, because
13
+ * `src/cli.ts` imports this module at startup and resolving the engine there
14
+ * would put a browser launch in front of every other command.
15
+ */
16
+ type Driver = typeof import('@/driver/drive')
17
+
18
+ interface RunOptions {
19
+ readonly json?: boolean
20
+ }
21
+
22
+ /**
23
+ * Spelled here rather than through `plural`, which appends a bare `s` and gets
24
+ * the plural of this noun wrong.
25
+ */
26
+ function passes(count: number): string {
27
+ return `${count} ${count === 1 ? 'pass' : 'passes'}`
28
+ }
29
+
30
+ /** What a reader does about each way the run document produced no drive. */
31
+ const PLAN_REFUSALS: Record<PlanRefusal, string> = {
32
+ 'unreadable-plan':
33
+ 'The run file is not valid JSON, so no step could be read.',
34
+ 'no-steps': 'The run names no step, so there is nothing to drive.',
35
+ 'no-probes': 'The run names no probe, so every step would measure nothing.',
36
+ 'unknown-probe': 'The run names a probe this build does not ship.',
37
+ 'bad-step': 'A step is missing something the driver needs to perform it.',
38
+ 'no-viewport': 'The run declares no viewport to render at.',
39
+ 'no-width': 'The run declares no viewport width.',
40
+ 'no-heights':
41
+ 'The run names no viewport height, and this command defaults none.',
42
+ }
43
+
44
+ const DRIVE_REFUSALS: Record<DriverRefusal, string> = {
45
+ 'browser-missing': 'The browser binary is not installed in this project.',
46
+ 'server-unreachable': 'Nothing answered at the URL, so no state was reached.',
47
+ 'drive-failed': 'The drive failed against a reachable page.',
48
+ }
49
+
50
+ export function register(program: Command): void {
51
+ program
52
+ .command('drive')
53
+ .description(
54
+ 'Walk a page through named interactions and measure each state',
55
+ )
56
+ .argument('<url>', 'Address to drive')
57
+ .argument(
58
+ '<run>',
59
+ 'JSON file naming the viewports, the probes, and the steps',
60
+ )
61
+ .helpOption('-h, --help', 'Show this help message')
62
+ .option('--json', 'Add a machine-readable record on stdout')
63
+ .addHelpText(
64
+ 'after',
65
+ [
66
+ '',
67
+ 'A render answers about a page as it loads. Every defect that exists',
68
+ 'only after a menu opens, an answer is chosen, or the page scrolls is',
69
+ 'invisible to one, which is the axis this adds. Probes run after each',
70
+ 'step and never on arrival, so a run reaches the load state by opening',
71
+ 'with a wait step of its own. Write one wherever canon capture does not',
72
+ 'run, since nothing else there measures the page as it first painted.',
73
+ '',
74
+ 'The run file carries the viewport, the default probe list, and the',
75
+ 'steps. A step names its own probes to override the default. Viewport',
76
+ 'heights are never defaulted: the heights a defect hides at are a',
77
+ 'property of the layout rather than of this command.',
78
+ '',
79
+ ' {',
80
+ ' "viewport": { "width": 1440, "heights": [900, 1200, 1500] },',
81
+ ' "probes": ["focus", "details"],',
82
+ ' "steps": [',
83
+ ' { "name": "open the menu", "kind": "click", "target": "#menu" },',
84
+ ' { "name": "reach the diagram", "kind": "scroll", "target": "figure",',
85
+ ' "probes": ["diagram-geometry", "diagram-strokes"] }',
86
+ ' ]',
87
+ ' }',
88
+ '',
89
+ 'It reports findings and never gates. Every probe here carries a class',
90
+ 'of false finding a throwaway version already produced, so a run that',
91
+ 'ends the build on its own reading is a claim this catalog has not',
92
+ 'earned. Branch on the JSON record instead.',
93
+ '',
94
+ 'Needs a reachable page and a browser binary. Install the browser with:',
95
+ ` ${INSTALL_BROWSER}`,
96
+ '',
97
+ 'Probes:',
98
+ ...PROBE_NAMES.map((name) => ` ${name}`),
99
+ '',
100
+ 'Step kinds:',
101
+ ' click <target> press the first element the selector matches',
102
+ ' scroll <target> bring the first match into view',
103
+ ' fill <target> type text into the first match',
104
+ ' tab [count] advance keyboard focus',
105
+ ' wait <ms> hold, for a state the page reaches on its own',
106
+ '',
107
+ 'Exit codes:',
108
+ ' 0 the drive completed, with its findings reported',
109
+ ' 1 refused, with the reason on stderr or in the JSON record',
110
+ '',
111
+ 'Examples:',
112
+ ' canon drive http://localhost:4173 run.json',
113
+ ' canon drive http://localhost:4173 run.json --json',
114
+ '',
115
+ ].join('\n'),
116
+ )
117
+ .action(async (url: string, run: string, opts: RunOptions) => {
118
+ process.exitCode = await runDriver(url, run, opts)
119
+ })
120
+ }
121
+
122
+ async function runDriver(
123
+ url: string,
124
+ runPath: string,
125
+ opts: RunOptions,
126
+ ): Promise<number> {
127
+ const emitJson = opts.json ?? false
128
+ const path = resolve(runPath)
129
+
130
+ intro(`canon drive ${url}`)
131
+
132
+ let source: string
133
+ try {
134
+ source = readFileSync(path, 'utf8')
135
+ } catch (error) {
136
+ return refuse(
137
+ emitJson,
138
+ url,
139
+ 'no-run-file',
140
+ `No run file at ${path}. ${error instanceof Error ? error.message : String(error)}`,
141
+ )
142
+ }
143
+
144
+ const read = readDriverPlan(source)
145
+ if (read.kind === 'refused') {
146
+ return refuse(
147
+ emitJson,
148
+ url,
149
+ read.reason,
150
+ `${PLAN_REFUSALS[read.reason]} ${read.detail}`,
151
+ )
152
+ }
153
+
154
+ const { plan } = read
155
+
156
+ logStep('Scope')
157
+ logInfo(
158
+ `${plural(plan.steps.length, 'step')} at ${plan.viewports.map(describeViewport).join(', ')}, measuring ${plan.probes.join(', ')}`,
159
+ )
160
+
161
+ const driver = await loadDriver()
162
+ if (!driver) {
163
+ logStep('Browser')
164
+ logError('the browser engine is not installed in this project')
165
+ logWarn(`Install it with: ${INSTALL_BROWSER}`)
166
+ outro()
167
+ emit(emitJson, {
168
+ url,
169
+ run: path,
170
+ reason: 'engine-missing',
171
+ install: INSTALL_BROWSER,
172
+ })
173
+ return 1
174
+ }
175
+
176
+ const result = await driver.drive({ url, plan })
177
+
178
+ if (result.status === 'failed') {
179
+ logStep('Refused')
180
+ logWarn(DRIVE_REFUSALS[result.reason])
181
+ if (result.reason === 'server-unreachable') {
182
+ logWarn(`Start whatever serves ${url}, then run this again.`)
183
+ }
184
+ if (result.reason === 'browser-missing') {
185
+ logWarn(`Install the browser binary with: ${INSTALL_BROWSER}`)
186
+ }
187
+ logWarn(result.message.split('\n')[0] ?? '')
188
+ outro()
189
+ emit(emitJson, {
190
+ url,
191
+ run: path,
192
+ reason: result.reason,
193
+ message: result.message,
194
+ ...(result.reason === 'browser-missing'
195
+ ? { install: INSTALL_BROWSER }
196
+ : {}),
197
+ })
198
+ return 1
199
+ }
200
+
201
+ logStep('Passes')
202
+ for (const pass of result.passes) {
203
+ logInfo(
204
+ `${pass.viewport} ${pass.step} ${plural(pass.probes, 'probe')} ${plural(pass.findings, 'finding')}`,
205
+ )
206
+ }
207
+
208
+ logStep('Findings')
209
+ if (result.findings.length === 0) {
210
+ logInfo(
211
+ `nothing across ${passes(result.passes.length)}, which is a reading rather than a pass mark`,
212
+ )
213
+ } else {
214
+ logInfo(
215
+ `${plural(result.findings.length, 'finding')} across ${passes(result.passes.length)}`,
216
+ )
217
+ for (const finding of result.findings) {
218
+ logInfo(` ${finding.probe} ${finding.selector}`)
219
+ logInfo(` ${finding.detail}`)
220
+ logInfo(` after ${finding.step} at ${finding.viewport}`)
221
+ logInfo(` ${finding.measured}`)
222
+ }
223
+ }
224
+ outro()
225
+
226
+ emit(emitJson, {
227
+ url,
228
+ run: path,
229
+ viewports: plan.viewports,
230
+ passes: result.passes,
231
+ findings: result.findings,
232
+ durationMs: result.durationMs,
233
+ })
234
+ return 0
235
+ }
236
+
237
+ /**
238
+ * Frames a refusal on stderr and puts the record on stdout, so an operator
239
+ * reading the terminal sees the reason rather than a command that appeared to
240
+ * do nothing.
241
+ */
242
+ function refuse(
243
+ emitJson: boolean,
244
+ url: string,
245
+ reason: string,
246
+ message: string,
247
+ ): number {
248
+ logStep('Refused')
249
+ logWarn(message)
250
+ outro()
251
+ emit(emitJson, { url, reason, message })
252
+ return 1
253
+ }
254
+
255
+ async function loadDriver(): Promise<Driver | undefined> {
256
+ try {
257
+ return await import('@/driver/drive')
258
+ } catch (error) {
259
+ if (isEngineMissing(error)) return undefined
260
+ throw error
261
+ }
262
+ }
263
+
264
+ function emit(json: boolean, record: unknown): void {
265
+ if (json) process.stdout.write(`${JSON.stringify(record)}\n`)
266
+ }
@@ -3,6 +3,7 @@ import { join } from 'node:path'
3
3
  import type { Command } from 'commander'
4
4
  import { deriveSlug, deriveTitle } from '@/commands/feedback-format'
5
5
  import { PROJECT_ROOT } from '@/project-root'
6
+ import { creationRel } from '@/record-root'
6
7
  import { createGithubIssue } from '@/github'
7
8
  import { frameError, frameSuccess, palette } from '@/ui'
8
9
 
@@ -39,7 +40,10 @@ function isToolkitSource(): boolean {
39
40
  * single ignore entry and the single backed-folder entry it already had.
40
41
  */
41
42
  function writeLocal(body: string): string {
42
- const relativeDir = join('.claude', 'review', 'feedback')
43
+ // Creation, so the destination is the creation default rather than the
44
+ // resolved read root. A toolkit checkout that has migrated its records already
45
+ // carries the folder and resolves the same path either way.
46
+ const relativeDir = creationRel('review', 'feedback')
43
47
  const reviewDir = join(PROJECT_ROOT, relativeDir)
44
48
  mkdirSync(reviewDir, { recursive: true })
45
49
  const filename = `feedback-${deriveSlug(body)}-${timestamp()}.md`
@@ -9,6 +9,7 @@ import {
9
9
  scanCounts,
10
10
  } from '@/counts/scan'
11
11
  import { PROJECT_ROOT } from '@/project-root'
12
+ import { creationRel, SCRATCH } from '@/record-root'
12
13
  import { createGovAdapter } from '@/gov/adapter'
13
14
  import { regenConsumedRules } from '@/gov/consumed'
14
15
  import { installRules, lookupRules } from '@/gov/install'
@@ -65,7 +66,7 @@ import {
65
66
  select,
66
67
  } from '@/ui'
67
68
 
68
- const PAYLOAD_REL = join('.claude', '.tmp', 'gov', 'rules.md')
69
+ const PAYLOAD_REL = creationRel(SCRATCH, 'gov', 'rules.md')
69
70
  const RULES_REL = join('.claude', 'rules')
70
71
 
71
72
  interface InstallOptions {
@@ -1,6 +1,7 @@
1
1
  import { existsSync } from 'node:fs'
2
2
  import { resolve } from 'node:path'
3
3
  import type { Command } from 'commander'
4
+ import { creationRel } from '@/record-root'
4
5
  import { LAYOUTS } from '@/slides/layouts'
5
6
  import { openDeck } from '@/slides/open'
6
7
  import { renderSlidesDoc } from '@/slides/render'
@@ -16,7 +17,11 @@ export function register(program: Command): void {
16
17
  .command('render')
17
18
  .description('Render a SLIDES.md source into a PowerPoint deck')
18
19
  .option('-s, --source <path>', 'Source SLIDES.md path', '.claude/SLIDES.md')
19
- .option('-o, --out <path>', 'Output directory', '.claude/review/slides')
20
+ .option(
21
+ '-o, --out <path>',
22
+ 'Output directory',
23
+ creationRel('review', 'slides'),
24
+ )
20
25
  .option('-v, --variant <variant>', 'Override variant (light or dark)')
21
26
  .option(
22
27
  '-m, --mirror <path>',
@@ -2,6 +2,7 @@ import { existsSync } from 'node:fs'
2
2
  import { readFile } from 'node:fs/promises'
3
3
  import { resolve } from 'node:path'
4
4
  import { listRepositoryFiles } from '@/git-files'
5
+ import { RECORD_ROOTS } from '@/record-root'
5
6
 
6
7
  /**
7
8
  * Suppresses citation checking for the source line carrying it.
@@ -76,21 +77,31 @@ export function isFixture(rel: string): boolean {
76
77
  return rel.split('/').some((segment) => FIXTURE_SEGMENTS.includes(segment))
77
78
  }
78
79
 
80
+ /**
81
+ * Both record roots are spelled, so a citation into a folder that has moved is
82
+ * still resolved. A pattern fixed at one root matches nothing after the move and
83
+ * reports nothing, which is a stale reference passing the check that exists to
84
+ * find it rather than a check that fails.
85
+ */
79
86
  export function citationPattern(folders: readonly string[]): RegExp {
80
- const names = folders.map((name) =>
81
- name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'),
82
- )
87
+ const names = folders.map((name) => escape(name))
88
+ const roots = RECORD_ROOTS.map((name) => escape(name))
89
+
83
90
  return new RegExp(
84
- `\\.claude/(?:${names.join('|')})/[A-Za-z0-9._/-]+\\.md`,
91
+ `(?:${roots.join('|')})/(?:${names.join('|')})/[A-Za-z0-9._/-]+\\.md`,
85
92
  'g',
86
93
  )
87
94
  }
88
95
 
96
+ function escape(value: string): string {
97
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
98
+ }
99
+
89
100
  /**
90
101
  * A backticked filename carrying no folder, the form a reference takes when it
91
102
  * names a sibling rather than a path.
92
103
  *
93
- * `citationPattern` spells the `.claude/` prefix and cannot see this shape at
104
+ * `citationPattern` spells a record-root prefix and cannot see this shape at
94
105
  * all, which is the reason the form rule exists. Widening that expression to
95
106
  * admit a bare name was the alternative and it puts one match in the position of
96
107
  * answering two questions, since a spelled path is a reference by construction
@@ -1,9 +1,10 @@
1
1
  import { existsSync } from 'node:fs'
2
2
  import { dirname, relative, resolve } from 'node:path'
3
3
  import { INDEX_FILE, listIndexes } from '@/indexes/walk'
4
+ import { RECORD_ROOTS } from '@/record-root'
4
5
 
5
6
  /**
6
- * Folder names under `.claude/` audited by default.
7
+ * Folder names under a record root audited by default.
7
8
  *
8
9
  * A named list rather than the index-plus-entry contract read off disk, so a
9
10
  * generated tree satisfying that contract is never measured against a rule
@@ -12,8 +13,8 @@ import { INDEX_FILE, listIndexes } from '@/indexes/walk'
12
13
  * here.
13
14
  *
14
15
  * It doubles as the citation check's scope, since `citationPattern` spells only
15
- * these names. A `.claude/` folder left off the list is never resolved, so a
16
- * path into one goes stale silently rather than failing a push.
16
+ * these names. A folder left off the list is never resolved, so a path into one
17
+ * goes stale silently rather than failing a push.
17
18
  */
18
19
  export const DEFAULT_FOLDERS: readonly string[] = [
19
20
  'context',
@@ -21,8 +22,17 @@ export const DEFAULT_FOLDERS: readonly string[] = [
21
22
  'wireframes',
22
23
  ]
23
24
 
24
- /** The base every folder in the default list sits under. */
25
- const CLAUDE_BASE = '.claude'
25
+ /**
26
+ * The bases every folder in the default list is looked for under, in the record
27
+ * roots' own precedence order.
28
+ *
29
+ * `diagrams` is the one name here that is a session record and moves with them,
30
+ * so the list has to carry the root it moves to. `context` and `wireframes` are
31
+ * tracked and stay, which leaves them resolvable at a root nothing will ever put
32
+ * them under. That costs one `existsSync` apiece and is cheaper than a per-name
33
+ * base map that would state the same split twice.
34
+ */
35
+ const CLAUDE_BASES: readonly string[] = RECORD_ROOTS
26
36
 
27
37
  /** The project root, reached only by a name the caller asked for. */
28
38
  const ROOT_BASE = '.'
@@ -58,7 +68,7 @@ export interface AuditedFolder {
58
68
  }
59
69
 
60
70
  /**
61
- * Names the requested `.claude/` folders that actually exist, which is the
71
+ * Names the requested record-root folders that actually exist, which is the
62
72
  * citation check's scope.
63
73
  *
64
74
  * A skill or seed pointing into `.claude/wireframes/` is a live instruction for
@@ -76,7 +86,7 @@ export function presentNames(folders: readonly AuditedFolder[]): string[] {
76
86
  return [
77
87
  ...new Set(
78
88
  folders
79
- .filter((folder) => folder.base === CLAUDE_BASE)
89
+ .filter((folder) => CLAUDE_BASES.includes(folder.base))
80
90
  .map((folder) => folder.name),
81
91
  ),
82
92
  ]
@@ -157,7 +167,7 @@ export async function resolveFolders(
157
167
  names: readonly string[] = DEFAULT_FOLDERS,
158
168
  { canResolveAtRoot = false }: ResolveOptions = {},
159
169
  ): Promise<FolderResolution> {
160
- const bases = canResolveAtRoot ? [CLAUDE_BASE, ROOT_BASE] : [CLAUDE_BASE]
170
+ const bases = canResolveAtRoot ? [...CLAUDE_BASES, ROOT_BASE] : CLAUDE_BASES
161
171
  const folders: AuditedFolder[] = []
162
172
  const missing: string[] = []
163
173
 
@@ -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
+ }