@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
package/docs/agents/commands.md
CHANGED
|
@@ -70,6 +70,7 @@ Full help: `canon <command> --help`. Behavior notes for the install and sync ver
|
|
|
70
70
|
| `canon audits list` | List every audit the set runs, with the corpus each reads and whether it gates (`--json`) |
|
|
71
71
|
| `canon gate run` | Run every stage that guards a branch here, scoping shell, types, and tests to the changed set (`--all`, `--no-write`, `--nested`, `--json`) |
|
|
72
72
|
| `canon inventory [subject]` | Walk every route a project declares and group its elements by the property each computes, as a listing rather than a gate (`--json`) |
|
|
73
|
+
| `canon drive <url> <run>` | Walk a page through named interactions and measure each state it reaches, reporting findings rather than gating (`--json`) |
|
|
73
74
|
| `canon capture [source]` | Render HTML capture sources to PNG, toolkit-only and absent from an installed package |
|
|
74
75
|
| `canon serve [dir]` | Serve a directory on the loopback interface and print the link that opens it, running until interrupted (`--port`, `--entry`, `--json`) |
|
|
75
76
|
| `canon upgrade` | Reinstall the CLI globally with the package manager the install path names (`--json`) |
|
|
@@ -94,6 +95,8 @@ routes = ["/", "/pricing", "/docs"]
|
|
|
94
95
|
query = "button, a[href], input, select, textarea, [tabindex]"
|
|
95
96
|
```
|
|
96
97
|
|
|
98
|
+
`canon drive` is the fourth and ships for the same reason the two before it do. What separates it from all three is the axis rather than the destination: `capture` and `inventory` each answer about a page as it loads, and every defect that exists only after a menu opens or the page scrolls is invisible to both. It takes a JSON run file naming the viewport, the probes, and the interaction sequence, since a route catalog is state a project holds and an interaction sequence is a script written for one question. Viewport heights are never defaulted, because the heights a defect hides at belong to the layout rather than to this command. It reports findings and never gates, since every probe it ships carries a class of false finding a throwaway version already produced. See `driver.md`.
|
|
99
|
+
|
|
97
100
|
## Domain commands
|
|
98
101
|
|
|
99
102
|
Each domain exposes a consistent shape where applicable: `list`, `install`, `sync`, `create`.
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Driver
|
|
3
|
+
description: Walking a page through named interactions, the probe catalog and the false finding each one carries, why viewport heights are never defaulted, and what each refusal reports
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Driver
|
|
7
|
+
|
|
8
|
+
`canon drive <url> <run>` walks a page through a sequence of interactions and measures every state it reaches. A render answers about a page as it loads, so a defect that exists only after a menu opens, an answer is chosen, or the page scrolls is invisible to one. That second axis is what this adds.
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
canon drive http://localhost:4173 run.json
|
|
12
|
+
canon drive http://localhost:4173 run.json --json
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
| Option | Behavior |
|
|
16
|
+
| -------- | --------------------------------------- |
|
|
17
|
+
| `--json` | Add a machine-readable record on stdout |
|
|
18
|
+
|
|
19
|
+
## The run file
|
|
20
|
+
|
|
21
|
+
The run is a JSON file rather than a key in a project config, because a route catalog is state a project holds and an interaction sequence is a script written for one question. It carries the viewport, the probes to run by default, and the steps.
|
|
22
|
+
|
|
23
|
+
```json
|
|
24
|
+
{
|
|
25
|
+
"viewport": { "width": 1440, "heights": [900, 1200, 1500] },
|
|
26
|
+
"probes": ["focus", "details"],
|
|
27
|
+
"steps": [
|
|
28
|
+
{ "name": "on load", "kind": "wait", "ms": 0 },
|
|
29
|
+
{ "name": "open the menu", "kind": "click", "target": "#menu summary" },
|
|
30
|
+
{
|
|
31
|
+
"name": "reach the diagram",
|
|
32
|
+
"kind": "scroll",
|
|
33
|
+
"target": "figure",
|
|
34
|
+
"probes": ["diagram-geometry", "diagram-strokes"]
|
|
35
|
+
}
|
|
36
|
+
]
|
|
37
|
+
}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Every step names what it did, and that name is carried onto each finding it produced, alongside the viewport. A step naming its own `probes` overrides the run-level list for that step alone. Omitting `probes` at the run level runs all four.
|
|
41
|
+
|
|
42
|
+
| Step kind | Fields | What it does |
|
|
43
|
+
| --------- | ------------------ | ---------------------------------------------- |
|
|
44
|
+
| `click` | `target` | Presses the first element the selector matches |
|
|
45
|
+
| `scroll` | `target` | Brings the first match into view |
|
|
46
|
+
| `fill` | `target`, `text` | Types into the first match |
|
|
47
|
+
| `tab` | `count` (optional) | Advances keyboard focus, once by default |
|
|
48
|
+
| `wait` | `ms` | Holds, for a state the page reaches alone |
|
|
49
|
+
|
|
50
|
+
Probes run after a step and never on arrival, so a run reaches the load state by opening with a `wait` step of its own, as the example above does. Nothing probes before the first step runs, which makes that leading step the only way to measure the page as it first painted, and naming it is what puts the load state on its own findings rather than under whatever ran next.
|
|
51
|
+
|
|
52
|
+
Write one in any project that does not have `canon capture`, which is toolkit-only and renders a single state from a committed source. Where capture runs, it already answers about arrival and a leading `wait` duplicates it. Where it does not, this command is the only thing measuring the page at all, and a run without that step reports every driven state and nothing about the one a visitor sees first.
|
|
53
|
+
|
|
54
|
+
Each height is driven in a context of its own from a fresh navigation, rather than by resizing the page the previous height already drove, since a sweep asks the same question of each height rather than a later question of an already-driven page.
|
|
55
|
+
|
|
56
|
+
## The probes
|
|
57
|
+
|
|
58
|
+
Four probes, a fixed catalog rather than a subject system a project extends. Each carries a class of false finding that a throwaway version of this command produced before it produced true ones, and a probe a project authored for itself would re-pay those.
|
|
59
|
+
|
|
60
|
+
| Probe | What it reports |
|
|
61
|
+
| ------------------ | ------------------------------------------------------------------------------------------ |
|
|
62
|
+
| `focus` | An element that takes keyboard focus and changes nothing visible |
|
|
63
|
+
| `details` | A `details` panel off the viewport, or a row under the tap minimum, both read open |
|
|
64
|
+
| `diagram-geometry` | An SVG label outside its frame, covered by a filled shape, or colliding with another label |
|
|
65
|
+
| `diagram-strokes` | An SVG label a stroke crosses, sampled along the stroke's own geometry |
|
|
66
|
+
|
|
67
|
+
`focus` presses Tab before it reads anything. `:focus-visible` does not match a scripted `.focus()` once the page has taken a pointer interaction, and a driver clicks by definition, so a reader without that press reports every correctly styled element as unstyled. It also ignores an outline width, color, or offset moving underneath an outline that resolves to `none`, which paints nothing.
|
|
68
|
+
|
|
69
|
+
`details` discovers every `details` on the page rather than taking a selector, measures each shut and again open, and judges only the open reading. A shut menu still reports a layout box and the box describes the trigger rather than the panel, so the shut numbers travel in the record as context. Discovery is automatic because the one real menu defect in the originating run was missed by a hand-picked probe: nobody thought to name that menu.
|
|
70
|
+
|
|
71
|
+
Both diagram probes read after `document.fonts.ready` and never compute from the markup. A generated page is routinely authored against one font and restyled to another, so a label that cleared a line at its authored width can overlap it once rendered. Every rect either probe compares is inset vertically and never horizontally, since a text rect is loose above and below the glyphs and tight to them left and right, and a horizontal inset passes a real overrun.
|
|
72
|
+
|
|
73
|
+
`diagram-strokes` is separate from `diagram-geometry` because a bounding box cannot answer for a stroke. A probe comparing filled boxes filters out every `line` and every `fill="none"` panel border before it compares, and a diagonal connector's box covers most of a diagram while the stroke itself touches almost none of it. Both paint orders are findings: a stroke drawn after a label crosses the glyphs out, and one drawn before it shows through them, since SVG text carries no plate of its own.
|
|
74
|
+
|
|
75
|
+
## Viewport heights are never defaulted
|
|
76
|
+
|
|
77
|
+
A run with no `viewport.heights` refuses. The heights that separate a passing render from a failing one belong to the layout being driven, and the only evidence behind any default this could ship is one fixture's failure range, too narrow a sample to hand every later caller a number they never chose. A scroll rail that skipped its middle sections passed at 900 and failed at 1200 and 1500, so one height reports clean over a live defect.
|
|
78
|
+
|
|
79
|
+
## What it reports
|
|
80
|
+
|
|
81
|
+
It reports findings and never gates, and the exit code says only whether the drive completed. Every probe here carries a class of false finding already paid for once, so a command ending a build on its own reading makes a claim this catalog has not earned. A caller that wants a verdict reads `findings` off the `--json` record and decides for itself.
|
|
82
|
+
|
|
83
|
+
| Reason | What it means |
|
|
84
|
+
| -------------------- | ------------------------------------------------------ |
|
|
85
|
+
| `no-run-file` | Nothing readable at the path given |
|
|
86
|
+
| `unreadable-plan` | The run file is not valid JSON |
|
|
87
|
+
| `no-steps` | The run names no step |
|
|
88
|
+
| `no-probes` | The run names an empty probe list |
|
|
89
|
+
| `unknown-probe` | The run names a probe this build does not ship |
|
|
90
|
+
| `bad-step` | A step is missing what the driver needs to perform it |
|
|
91
|
+
| `no-viewport` | The run declares no viewport |
|
|
92
|
+
| `no-width` | The viewport has no width to wrap at |
|
|
93
|
+
| `no-heights` | The viewport names no height, which is never defaulted |
|
|
94
|
+
| `engine-missing` | The browser engine is not installed in this project |
|
|
95
|
+
| `browser-missing` | The engine is installed and its browser binary is not |
|
|
96
|
+
| `server-unreachable` | Nothing answered at the URL, so no state was reached |
|
|
97
|
+
| `drive-failed` | The drive failed against a reachable page |
|
|
98
|
+
|
|
99
|
+
An unreachable page refuses rather than returning an empty report, since nothing measured and nothing found read the same to anything counting findings.
|
|
100
|
+
|
|
101
|
+
The browser binary installs separately from the package, once, with `bunx playwright install chromium`. The command ships to targets like `demo` and `inventory` rather than staying toolkit-only like `capture`, because its whole purpose is measuring a page inside someone else's project. It reaches nothing under `src/capture/`, which the package excludes, so the two surfaces move independently.
|
|
102
|
+
|
|
103
|
+
See `commands.md` for where this sits among the browser commands, and `capture.md` for the single-state render it adds an axis to.
|
package/docs/agents/index.md
CHANGED
|
@@ -18,6 +18,7 @@ CLI catalog and invocation rules for agents, split by command domain. Start with
|
|
|
18
18
|
- [Self-stated counts](counts.md): Reading a sentence that asserts a closed catalog's size, how a match is decided, the plausibility filter that keeps a generic word from matching a subset, and why the sweep reports rather than gates
|
|
19
19
|
- [Demo](demo.md): Compiling a screencast draft into a runnable plan, driving a served application to a recording and a still, the pointer the recording paints, and what each refusal reports
|
|
20
20
|
- [Docs](docs.md): How canon docs resolves the toolkit's own reference surface from an install root, and how a split domain is named
|
|
21
|
+
- [Driver](driver.md): Walking a page through named interactions, the probe catalog and the false finding each one carries, why viewport heights are never defaulted, and what each refusal reports
|
|
21
22
|
- [Merge gate](gate.md): Running the gate this repository verifies a branch with, what the stage table holds and what stays a script, how the changed set scopes three stages, and why a stage that cannot read its input reports rather than passing
|
|
22
23
|
- [Indexes](indexes.md): Flags, exit codes, and JSON shape for canon indexes regen, plus when it auto-stages what it rewrote
|
|
23
24
|
- [Install and sync](install-and-sync.md): What each install and sync verb writes, refuses, or leaves alone, and how drift is attributed in a target project
|
package/package.json
CHANGED
package/src/browser/engine.ts
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* What every browser-driving command needs to know before it can report a
|
|
3
|
-
* failure honestly: how the binary is installed,
|
|
4
|
-
*
|
|
3
|
+
* failure honestly: how the binary is installed, how each of the two ways it
|
|
4
|
+
* can be absent reads when it is thrown, and the one press that decides whether
|
|
5
|
+
* a focus reading means anything.
|
|
5
6
|
*
|
|
6
|
-
* The two are different states with different remedies. A package that
|
|
7
|
-
* resolved means the target installed the CLI without the engine, and a
|
|
8
|
-
* that was never downloaded means the engine is present and its browser
|
|
9
|
-
* Both were spelled inside `src/demo/` when `demo` was the only command
|
|
10
|
-
* a browser, and a second command is what makes them shared rather than
|
|
7
|
+
* The two absences are different states with different remedies. A package that
|
|
8
|
+
* never resolved means the target installed the CLI without the engine, and a
|
|
9
|
+
* binary that was never downloaded means the engine is present and its browser
|
|
10
|
+
* is not. Both were spelled inside `src/demo/` when `demo` was the only command
|
|
11
|
+
* driving a browser, and a second command is what makes them shared rather than
|
|
12
|
+
* local.
|
|
11
13
|
*/
|
|
12
14
|
|
|
13
15
|
/** Fetches the browser revision the pinned engine expects. */
|
|
@@ -38,3 +40,44 @@ export function isEngineMissing(error: unknown): boolean {
|
|
|
38
40
|
error.code === 'ERR_MODULE_NOT_FOUND'
|
|
39
41
|
)
|
|
40
42
|
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Separates a server nobody started from a page that failed for its own
|
|
46
|
+
* reasons. The first is a precondition no browser command can create for
|
|
47
|
+
* itself, and reporting it as an empty reading says the site gives no answers
|
|
48
|
+
* when nothing was ever asked.
|
|
49
|
+
*
|
|
50
|
+
* It moved here alongside the modality press, when `@/driver/drive` became the
|
|
51
|
+
* second module needing it.
|
|
52
|
+
*/
|
|
53
|
+
export function isServerUnreachable(error: unknown): boolean {
|
|
54
|
+
const text = error instanceof Error ? error.message : String(error)
|
|
55
|
+
return /ERR_CONNECTION_REFUSED|ERR_NAME_NOT_RESOLVED|ERR_CONNECTION_RESET|ERR_EMPTY_RESPONSE/i.test(
|
|
56
|
+
text,
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The narrowest shape the press below needs, declared here rather than imported
|
|
62
|
+
* as `Page`, so this module keeps its type surface free of the engine and every
|
|
63
|
+
* command reading a refusal string still loads without resolving it.
|
|
64
|
+
*/
|
|
65
|
+
export interface KeyboardPage {
|
|
66
|
+
readonly keyboard: { press(key: string): Promise<void> }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Puts the page in keyboard modality, which is the precondition for reading a
|
|
71
|
+
* `:focus-visible` rule at all. A scripted `.focus()` on its own leaves the
|
|
72
|
+
* browser in pointer modality, where that rule does not match, so a reader
|
|
73
|
+
* calling it alone reports every correctly styled element as unstyled.
|
|
74
|
+
*
|
|
75
|
+
* It moved here when `@/driver/probes/focus` became the second call site,
|
|
76
|
+
* matching how the launch-failure split moved out of `src/demo/` once
|
|
77
|
+
* `canon inventory` became the second command driving a browser. One press
|
|
78
|
+
* covers the page rather than the element that happens to hold focus, so it
|
|
79
|
+
* survives the blur every reader takes next.
|
|
80
|
+
*/
|
|
81
|
+
export async function enterKeyboardModality(page: KeyboardPage): Promise<void> {
|
|
82
|
+
await page.keyboard.press('Tab')
|
|
83
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { register as capture } from '@/commands/capture'
|
|
|
18
18
|
import { register as serve } from '@/commands/serve'
|
|
19
19
|
import { register as demo } from '@/commands/demo'
|
|
20
20
|
import { register as inventory } from '@/commands/inventory'
|
|
21
|
+
import { register as driver } from '@/commands/driver'
|
|
21
22
|
import { register as feedback } from '@/commands/feedback'
|
|
22
23
|
import { register as transcripts } from '@/commands/transcripts'
|
|
23
24
|
import { register as tasks } from '@/commands/tasks'
|
|
@@ -70,6 +71,7 @@ function showHelp(): void {
|
|
|
70
71
|
`${GREY}│${NC} serve [dir] ${GREY}# Serve a directory over localhost and print the preview link${NC}`,
|
|
71
72
|
`${GREY}│${NC} demo [cmd] ${GREY}# Record a running app (compile, run)${NC}`,
|
|
72
73
|
`${GREY}│${NC} inventory [subj] ${GREY}# Report one computed property across every route${NC}`,
|
|
74
|
+
`${GREY}│${NC} drive <url> <run> ${GREY}# Walk a page through named interactions and measure each state${NC}`,
|
|
73
75
|
`${GREY}│${NC} feedback ${GREY}# Write toolkit feedback from stdin to .claude/review/feedback/${NC}`,
|
|
74
76
|
`${GREY}│${NC} transcripts <url> ${GREY}# Fetch a YouTube transcript with metadata frontmatter${NC}`,
|
|
75
77
|
`${GREY}│${NC} tasks [cmd] ${GREY}# Task board commands (archive)${NC}`,
|
|
@@ -121,6 +123,7 @@ function showHelp(): void {
|
|
|
121
123
|
`${GREY}│${NC} canon capture assets/install.html`,
|
|
122
124
|
`${GREY}│${NC} canon serve .claude/teach`,
|
|
123
125
|
`${GREY}│${NC} canon inventory focus --json`,
|
|
126
|
+
`${GREY}│${NC} canon drive http://localhost:4173 run.json --json`,
|
|
124
127
|
`${GREY}│${NC} pbpaste | canon feedback`,
|
|
125
128
|
`${GREY}│${NC} canon transcripts https://youtu.be/VIDEO_ID`,
|
|
126
129
|
`${GREY}│${NC} canon tasks archive --pull-request 673 --json`,
|
|
@@ -177,6 +180,7 @@ capture(program)
|
|
|
177
180
|
serve(program)
|
|
178
181
|
demo(program)
|
|
179
182
|
inventory(program)
|
|
183
|
+
driver(program)
|
|
180
184
|
feedback(program)
|
|
181
185
|
transcripts(program)
|
|
182
186
|
tasks(program)
|
|
@@ -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
|
+
}
|