@erclx/canon 5.66.0 → 5.67.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/CHANGELOG.md +11 -0
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/app-drive/REQUIREMENT.md +35 -0
- package/claude/skills/app-drive/SKILL.md +112 -0
- package/claude/skills/app-drive/references/config.md +38 -0
- package/claude/skills/app-drive/references/drive-record.md +70 -0
- package/claude/skills/app-drive/references/isolate.md +46 -0
- package/claude/skills/canon-cli/SKILL.md +2 -0
- package/claude/skills/create-skill/references/skill.md +1 -1
- package/claude/skills/plan-feature/references/plan.md +1 -1
- package/claude/skills/plan-intake/references/intake.md +12 -2
- package/claude/skills/review-ui/SKILL.md +5 -4
- package/claude/skills/review-ui/references/drive-copy.md +4 -0
- package/claude/skills/review-ui/references/driving.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-handoff.md +15 -2
- package/claude/skills/role-planner/SKILL.md +2 -2
- package/claude/skills/role-worker/SKILL.md +4 -6
- package/claude/skills/session-map/REQUIREMENT.md +1 -1
- package/claude/skills/session-map/SKILL.md +8 -1
- package/claude/skills/session-map/references/session.md +19 -5
- package/claude/skills/ui-record/REQUIREMENT.md +34 -0
- package/claude/skills/ui-record/SKILL.md +103 -0
- package/claude/skills/ui-record/references/config.md +40 -0
- package/claude/skills/ui-record/references/handoff.md +48 -0
- package/claude/skills/ui-record/references/take-craft.md +92 -0
- package/docs/agents/records.md +1 -0
- package/docs/workflow/parallel-features.md +1 -1
- package/docs/workflow/skill-map-on-request.md +1 -0
- package/docs/workflow/skill-map.md +1 -0
- package/docs/workflow/visual-design-workflow.md +2 -1
- package/package.json +1 -1
- package/src/audits/catalog.ts +5 -1
- package/src/browser/agent-cli.ts +68 -0
- package/src/claude/cases/workflow.ts +20 -0
- package/src/cli/help.ts +3 -0
- package/src/cli.ts +4 -0
- package/src/commands/browser.ts +102 -0
- package/src/commands/intake.ts +181 -1
- package/src/commands/pr.ts +15 -6
- package/src/commands/records.ts +18 -1
- package/src/commands/take.ts +266 -0
- package/src/context/requirements.ts +1 -11
- package/src/git/status.ts +73 -0
- package/src/intake/drive-check.ts +355 -0
- package/src/intake/folder.ts +1 -1
- package/src/markdown/scan.ts +10 -0
- package/src/pr/drive-copy.ts +97 -84
- package/src/records/session-map.ts +75 -0
- package/src/records/validate.ts +64 -8
- package/src/take/camera.ts +309 -0
- package/src/take/encode.ts +170 -0
- package/src/take/film.ts +206 -0
- package/src/take/fixtures/fade.html +49 -0
- package/src/take/fixtures/fetch.html +38 -0
- package/src/take/log.ts +109 -0
- package/src/take/perform.ts +200 -0
- package/src/take/plan.ts +193 -0
- package/src/take/screencast.ts +94 -0
- package/src/take/shell.ts +287 -0
- package/src/take/stepper.ts +252 -0
- package/src/take/zoom/NOTICE.md +45 -0
- package/src/take/zoom/math-utils.ts +69 -0
- package/src/take/zoom/region-utils.ts +55 -0
- package/src/take/zoom/spring.ts +133 -0
- package/src/tasks/archive.ts +1 -3
- package/claude/skills/review-ui/scripts/pw.sh +0 -60
package/src/take/plan.ts
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The take plan: the beats a take films, each a list of steps. It reuses
|
|
3
|
+
* `canon drive`'s kind names for the steps the two share and adds `move`,
|
|
4
|
+
* since a take films the pointer's travel and a drive skips it. `tab` is left
|
|
5
|
+
* out because a take draws its cursor from the log, and focus moved by the
|
|
6
|
+
* keyboard leaves nothing in the log to draw.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** How a beat is filmed: frame stepping, or Playwright's real-time screencast. */
|
|
10
|
+
export const ROUTES = ['step', 'screencast'] as const
|
|
11
|
+
|
|
12
|
+
export type Route = (typeof ROUTES)[number]
|
|
13
|
+
|
|
14
|
+
export type TakeStep =
|
|
15
|
+
| { readonly kind: 'move'; readonly target: string }
|
|
16
|
+
| { readonly kind: 'move'; readonly x: number; readonly y: number }
|
|
17
|
+
| { readonly kind: 'click'; readonly target: string }
|
|
18
|
+
| { readonly kind: 'fill'; readonly target: string; readonly text: string }
|
|
19
|
+
| { readonly kind: 'scroll'; readonly target: string; readonly dy: number }
|
|
20
|
+
| { readonly kind: 'wait'; readonly ms: number }
|
|
21
|
+
|
|
22
|
+
export interface Beat {
|
|
23
|
+
readonly name: string
|
|
24
|
+
/**
|
|
25
|
+
* The route asked for. A `step` beat that frame stepping cannot drive falls
|
|
26
|
+
* back to `screencast` on its own, and the take's record says which ran.
|
|
27
|
+
*/
|
|
28
|
+
readonly route: Route
|
|
29
|
+
/** Runs after the page loads and before the first frame is kept. */
|
|
30
|
+
readonly setup: readonly TakeStep[]
|
|
31
|
+
readonly steps: readonly TakeStep[]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface TakePlan {
|
|
35
|
+
readonly beats: readonly Beat[]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type TakePlanRefusal =
|
|
39
|
+
| 'unreadable-plan'
|
|
40
|
+
| 'no-beats'
|
|
41
|
+
| 'bad-beat'
|
|
42
|
+
| 'no-steps'
|
|
43
|
+
| 'unknown-route'
|
|
44
|
+
| 'bad-step'
|
|
45
|
+
|
|
46
|
+
export type TakePlanRead =
|
|
47
|
+
| { readonly kind: 'read'; readonly plan: TakePlan }
|
|
48
|
+
| {
|
|
49
|
+
readonly kind: 'refused'
|
|
50
|
+
readonly reason: TakePlanRefusal
|
|
51
|
+
readonly detail: string
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
class Refusal extends Error {
|
|
55
|
+
constructor(
|
|
56
|
+
readonly reason: TakePlanRefusal,
|
|
57
|
+
detail: string,
|
|
58
|
+
) {
|
|
59
|
+
super(detail)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Reads a take plan out of a JSON document, the same form `canon drive` reads
|
|
65
|
+
* its run in, refusing the first thing a take could not perform.
|
|
66
|
+
*/
|
|
67
|
+
export function readTakePlan(source: string): TakePlanRead {
|
|
68
|
+
let document: unknown
|
|
69
|
+
try {
|
|
70
|
+
document = JSON.parse(source)
|
|
71
|
+
} catch (error) {
|
|
72
|
+
return {
|
|
73
|
+
kind: 'refused',
|
|
74
|
+
reason: 'unreadable-plan',
|
|
75
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
return { kind: 'read', plan: readPlan(document) }
|
|
81
|
+
} catch (error) {
|
|
82
|
+
if (!(error instanceof Refusal)) throw error
|
|
83
|
+
return { kind: 'refused', reason: error.reason, detail: error.message }
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function readPlan(document: unknown): TakePlan {
|
|
88
|
+
if (!isRecord(document)) {
|
|
89
|
+
throw new Refusal('unreadable-plan', 'the document is not an object')
|
|
90
|
+
}
|
|
91
|
+
const { beats } = document
|
|
92
|
+
if (!Array.isArray(beats) || beats.length === 0) {
|
|
93
|
+
throw new Refusal(
|
|
94
|
+
'no-beats',
|
|
95
|
+
'beats carries no entry, so nothing is filmed',
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
return { beats: beats.map((entry, index) => readBeat(entry, index)) }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function readBeat(entry: unknown, index: number): Beat {
|
|
102
|
+
const at = `beat ${index + 1}`
|
|
103
|
+
if (!isRecord(entry)) throw new Refusal('bad-beat', `${at} is not an object`)
|
|
104
|
+
|
|
105
|
+
const name = typeof entry.name === 'string' ? entry.name : ''
|
|
106
|
+
if (!name) {
|
|
107
|
+
throw new Refusal(
|
|
108
|
+
'bad-beat',
|
|
109
|
+
`${at} carries no name, and its clip is named by it`,
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const route = entry.route ?? 'step'
|
|
114
|
+
if (!ROUTES.includes(route as Route)) {
|
|
115
|
+
throw new Refusal(
|
|
116
|
+
'unknown-route',
|
|
117
|
+
`${name} names route ${String(route)}. This build records on: ${ROUTES.join(', ')}.`,
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (!Array.isArray(entry.steps) || entry.steps.length === 0) {
|
|
122
|
+
throw new Refusal(
|
|
123
|
+
'no-steps',
|
|
124
|
+
`${name} names no step, so its take would film nothing`,
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
if (entry.setup !== undefined && !Array.isArray(entry.setup)) {
|
|
128
|
+
throw new Refusal('bad-beat', `${name} carries a setup that is not a list`)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
name,
|
|
133
|
+
route: route as Route,
|
|
134
|
+
setup: (entry.setup ?? []).map((step: unknown, i: number) =>
|
|
135
|
+
readStep(step, `${name}, setup step ${i + 1}`),
|
|
136
|
+
),
|
|
137
|
+
steps: entry.steps.map((step: unknown, i: number) =>
|
|
138
|
+
readStep(step, `${name}, step ${i + 1}`),
|
|
139
|
+
),
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function readStep(entry: unknown, at: string): TakeStep {
|
|
144
|
+
if (!isRecord(entry)) throw new Refusal('bad-step', `${at} is not an object`)
|
|
145
|
+
|
|
146
|
+
const target = typeof entry.target === 'string' ? entry.target : undefined
|
|
147
|
+
const needTarget = (): string => {
|
|
148
|
+
if (target) return target
|
|
149
|
+
throw new Refusal('bad-step', `${at} is a ${entry.kind} with no target`)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
switch (entry.kind) {
|
|
153
|
+
case 'move':
|
|
154
|
+
if (target) return { kind: 'move', target }
|
|
155
|
+
if (isNumber(entry.x) && isNumber(entry.y)) {
|
|
156
|
+
return { kind: 'move', x: entry.x, y: entry.y }
|
|
157
|
+
}
|
|
158
|
+
throw new Refusal(
|
|
159
|
+
'bad-step',
|
|
160
|
+
`${at} is a move with neither a target nor an x and y`,
|
|
161
|
+
)
|
|
162
|
+
case 'click':
|
|
163
|
+
return { kind: 'click', target: needTarget() }
|
|
164
|
+
case 'fill':
|
|
165
|
+
if (typeof entry.text !== 'string') {
|
|
166
|
+
throw new Refusal('bad-step', `${at} is a fill with no text`)
|
|
167
|
+
}
|
|
168
|
+
return { kind: 'fill', target: needTarget(), text: entry.text }
|
|
169
|
+
case 'scroll':
|
|
170
|
+
if (!isNumber(entry.dy)) {
|
|
171
|
+
throw new Refusal('bad-step', `${at} is a scroll with no dy in pixels`)
|
|
172
|
+
}
|
|
173
|
+
return { kind: 'scroll', target: needTarget(), dy: entry.dy }
|
|
174
|
+
case 'wait':
|
|
175
|
+
if (!isNumber(entry.ms) || entry.ms < 0) {
|
|
176
|
+
throw new Refusal('bad-step', `${at} is a wait with no ms`)
|
|
177
|
+
}
|
|
178
|
+
return { kind: 'wait', ms: entry.ms }
|
|
179
|
+
default:
|
|
180
|
+
throw new Refusal(
|
|
181
|
+
'bad-step',
|
|
182
|
+
`${at} names kind ${String(entry.kind)}, which is not move, click, fill, scroll, or wait`,
|
|
183
|
+
)
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
188
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function isNumber(value: unknown): value is number {
|
|
192
|
+
return typeof value === 'number' && Number.isFinite(value)
|
|
193
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync } from 'node:fs'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { chromium } from 'playwright-core'
|
|
5
|
+
import { probeFrames, transcode } from '@/take/encode'
|
|
6
|
+
import { FPS, type LogEvent, START, VIEWPORT } from '@/take/log'
|
|
7
|
+
import { type PointerInput, type Stage, performSteps } from '@/take/perform'
|
|
8
|
+
import type { Beat } from '@/take/plan'
|
|
9
|
+
import { HOLD_MS } from '@/take/stepper'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Films a beat in real time through Playwright's screencast, for a page frame
|
|
13
|
+
* stepping cannot drive. It runs at the viewport's own 1920x1080, so it goes
|
|
14
|
+
* soft under a zoom where a stepped take stays sharp, and its frames land when
|
|
15
|
+
* the page paints rather than one per step.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const FRAME_MS = 1000 / FPS
|
|
19
|
+
|
|
20
|
+
const SETTLE_MS = 2500
|
|
21
|
+
|
|
22
|
+
export interface ScreencastTake {
|
|
23
|
+
readonly frames: number
|
|
24
|
+
readonly events: readonly LogEvent[]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function filmScreencast(request: {
|
|
28
|
+
readonly url: string
|
|
29
|
+
readonly beat: Beat
|
|
30
|
+
readonly clip: string
|
|
31
|
+
}): Promise<ScreencastTake> {
|
|
32
|
+
const scratch = mkdtempSync(join(tmpdir(), 'canon-take-cast-'))
|
|
33
|
+
const browser = await chromium.launch()
|
|
34
|
+
try {
|
|
35
|
+
const page = await browser.newPage({ viewport: VIEWPORT })
|
|
36
|
+
await page.goto(request.url, { waitUntil: 'load' })
|
|
37
|
+
await page.waitForTimeout(SETTLE_MS)
|
|
38
|
+
|
|
39
|
+
const stageOn = (clock: () => number): Stage => ({
|
|
40
|
+
now: clock,
|
|
41
|
+
hold: (ms) => page.waitForTimeout(ms),
|
|
42
|
+
pointer: async (input) => {
|
|
43
|
+
const sent = clock()
|
|
44
|
+
await send(page.mouse, input)
|
|
45
|
+
const spent = clock() - sent
|
|
46
|
+
if (spent < FRAME_MS) await page.waitForTimeout(FRAME_MS - spent)
|
|
47
|
+
},
|
|
48
|
+
key: async (text) => {
|
|
49
|
+
await page.keyboard.insertText(text)
|
|
50
|
+
await page.waitForTimeout(FRAME_MS)
|
|
51
|
+
},
|
|
52
|
+
evaluate: (expression) => page.evaluate(expression),
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
const live = stageOn(() => performance.now())
|
|
56
|
+
await performSteps(live, request.beat.setup, { pointer: START })
|
|
57
|
+
if (request.beat.setup.length > 0) await live.hold(1000)
|
|
58
|
+
await page.mouse.move(START.x, START.y)
|
|
59
|
+
|
|
60
|
+
const webm = join(scratch, 'take.webm')
|
|
61
|
+
const events: LogEvent[] = []
|
|
62
|
+
await page.screencast.start({ path: webm, size: VIEWPORT, fps: FPS })
|
|
63
|
+
const started = performance.now()
|
|
64
|
+
const recording = stageOn(() => performance.now() - started)
|
|
65
|
+
await recording.hold(HOLD_MS)
|
|
66
|
+
await performSteps(recording, request.beat.steps, {
|
|
67
|
+
pointer: START,
|
|
68
|
+
log: events,
|
|
69
|
+
})
|
|
70
|
+
await recording.hold(HOLD_MS)
|
|
71
|
+
await page.screencast.stop()
|
|
72
|
+
|
|
73
|
+
await transcode(webm, request.clip, FPS)
|
|
74
|
+
return { frames: await probeFrames(request.clip), events }
|
|
75
|
+
} finally {
|
|
76
|
+
await browser.close()
|
|
77
|
+
rmSync(scratch, { recursive: true, force: true })
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
type Mouse = import('playwright-core').Mouse
|
|
82
|
+
|
|
83
|
+
async function send(mouse: Mouse, input: PointerInput): Promise<void> {
|
|
84
|
+
switch (input.type) {
|
|
85
|
+
case 'move':
|
|
86
|
+
return mouse.move(input.x, input.y)
|
|
87
|
+
case 'down':
|
|
88
|
+
return mouse.down()
|
|
89
|
+
case 'up':
|
|
90
|
+
return mouse.up()
|
|
91
|
+
case 'wheel':
|
|
92
|
+
return mouse.wheel(0, input.dy)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { type ChildProcess, spawn } from 'node:child_process'
|
|
2
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
|
3
|
+
import { createRequire } from 'node:module'
|
|
4
|
+
import { homedir, tmpdir } from 'node:os'
|
|
5
|
+
import { dirname, join } from 'node:path'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Launches chrome-headless-shell under begin-frame control and speaks raw CDP
|
|
9
|
+
* to one page in it. Playwright cannot ask for begin-frame control on a page it
|
|
10
|
+
* opens, and only the headless shell honors the flag, so this bypasses the
|
|
11
|
+
* engine and finds the shell Playwright installed beside Chromium.
|
|
12
|
+
*
|
|
13
|
+
* `playwright-core` exposes no path for the shell, so the path is rebuilt from
|
|
14
|
+
* its `browsers.json` revision and its cache layout. A Playwright upgrade that
|
|
15
|
+
* moves that layout surfaces here as `browser-missing` rather than as a crash.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export class ShellMissing extends Error {}
|
|
19
|
+
|
|
20
|
+
/** Each platform's folder and binary inside the shell's revision folder. */
|
|
21
|
+
const SHELL_DIRS: Record<string, readonly [string, string]> = {
|
|
22
|
+
'linux-x64': ['chrome-headless-shell-linux64', 'chrome-headless-shell'],
|
|
23
|
+
'linux-arm64': ['chrome-headless-shell-linux-arm64', 'chrome-headless-shell'],
|
|
24
|
+
'darwin-x64': ['chrome-headless-shell-mac-x64', 'chrome-headless-shell'],
|
|
25
|
+
'darwin-arm64': ['chrome-headless-shell-mac-arm64', 'chrome-headless-shell'],
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const FLAGS = [
|
|
29
|
+
'--enable-begin-frame-control',
|
|
30
|
+
'--run-all-compositor-stages-before-draw',
|
|
31
|
+
'--disable-threaded-animation',
|
|
32
|
+
'--disable-threaded-scrolling',
|
|
33
|
+
'--disable-checker-imaging',
|
|
34
|
+
'--hide-scrollbars',
|
|
35
|
+
'--force-device-scale-factor=2',
|
|
36
|
+
'--mute-audio',
|
|
37
|
+
'--no-first-run',
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
/** Resolves the installed shell, throwing `ShellMissing` naming where it looked. */
|
|
41
|
+
export function findShell(env: NodeJS.ProcessEnv = process.env): string {
|
|
42
|
+
const require = createRequire(import.meta.url)
|
|
43
|
+
const engineRoot = dirname(require.resolve('playwright-core/package.json'))
|
|
44
|
+
const manifest = JSON.parse(
|
|
45
|
+
readFileSync(join(engineRoot, 'browsers.json'), 'utf8'),
|
|
46
|
+
) as { browsers: { name: string; revision: string }[] }
|
|
47
|
+
const revision = manifest.browsers.find(
|
|
48
|
+
(browser) => browser.name === 'chromium-headless-shell',
|
|
49
|
+
)?.revision
|
|
50
|
+
const dirs = SHELL_DIRS[`${process.platform}-${process.arch}`]
|
|
51
|
+
if (!revision || !dirs) {
|
|
52
|
+
throw new ShellMissing(
|
|
53
|
+
`no chrome-headless-shell for ${process.platform}-${process.arch} in playwright-core's browsers.json`,
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const path = join(
|
|
58
|
+
registryRoot(env, engineRoot),
|
|
59
|
+
`chromium_headless_shell-${revision}`,
|
|
60
|
+
...dirs,
|
|
61
|
+
)
|
|
62
|
+
if (!existsSync(path)) {
|
|
63
|
+
throw new ShellMissing(
|
|
64
|
+
`chrome-headless-shell executable doesn't exist at ${path}`,
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
return path
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Where Playwright keeps its browsers, read the way its own registry reads it. */
|
|
71
|
+
function registryRoot(env: NodeJS.ProcessEnv, engineRoot: string): string {
|
|
72
|
+
const pinned = env.PLAYWRIGHT_BROWSERS_PATH
|
|
73
|
+
if (pinned === '0') return join(engineRoot, '.local-browsers')
|
|
74
|
+
if (pinned) return pinned
|
|
75
|
+
if (process.platform === 'darwin') {
|
|
76
|
+
return join(homedir(), 'Library', 'Caches', 'ms-playwright')
|
|
77
|
+
}
|
|
78
|
+
return join(env.XDG_CACHE_HOME || join(homedir(), '.cache'), 'ms-playwright')
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
type Listener = (params: Record<string, unknown>) => void
|
|
82
|
+
|
|
83
|
+
export interface ShellPage {
|
|
84
|
+
send<T = Record<string, unknown>>(
|
|
85
|
+
method: string,
|
|
86
|
+
params?: Record<string, unknown>,
|
|
87
|
+
): Promise<T>
|
|
88
|
+
/** Calls `listener` on every event of `method` from this page. */
|
|
89
|
+
on(method: string, listener: Listener): void
|
|
90
|
+
/** Runs an expression in the page and returns its value. */
|
|
91
|
+
evaluate<T>(expression: string): Promise<T>
|
|
92
|
+
/** Kills the shell and removes its profile, safe to call more than once. */
|
|
93
|
+
close(): void
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Opens one page at `width` by `height` with begin-frame control. The launch
|
|
98
|
+
* owns its profile folder and kills the shell on process exit, so a killed take
|
|
99
|
+
* leaves no shell running and no profile behind.
|
|
100
|
+
*/
|
|
101
|
+
export async function openShell(
|
|
102
|
+
executable: string,
|
|
103
|
+
size: { readonly width: number; readonly height: number },
|
|
104
|
+
): Promise<ShellPage> {
|
|
105
|
+
const profile = mkdtempSync(join(tmpdir(), 'canon-take-'))
|
|
106
|
+
const proc = spawn(
|
|
107
|
+
executable,
|
|
108
|
+
[
|
|
109
|
+
'--remote-debugging-port=0',
|
|
110
|
+
`--user-data-dir=${profile}`,
|
|
111
|
+
...FLAGS,
|
|
112
|
+
'about:blank',
|
|
113
|
+
],
|
|
114
|
+
{ stdio: ['ignore', 'ignore', 'pipe'] },
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
let closed = false
|
|
118
|
+
const close = (): void => {
|
|
119
|
+
if (closed) return
|
|
120
|
+
closed = true
|
|
121
|
+
process.off('exit', close)
|
|
122
|
+
process.off('SIGINT', interrupt)
|
|
123
|
+
process.off('SIGTERM', interrupt)
|
|
124
|
+
socket?.close()
|
|
125
|
+
proc.kill('SIGKILL')
|
|
126
|
+
rmSync(profile, { recursive: true, force: true })
|
|
127
|
+
}
|
|
128
|
+
// A signal skips the exit event, so it closes the shell and then re-raises
|
|
129
|
+
// itself with the default handler restored, ending the process as it would have.
|
|
130
|
+
const interrupt = (signal: NodeJS.Signals): void => {
|
|
131
|
+
close()
|
|
132
|
+
process.kill(process.pid, signal)
|
|
133
|
+
}
|
|
134
|
+
process.on('exit', close)
|
|
135
|
+
process.once('SIGINT', interrupt)
|
|
136
|
+
process.once('SIGTERM', interrupt)
|
|
137
|
+
|
|
138
|
+
let socket: WebSocket | undefined
|
|
139
|
+
try {
|
|
140
|
+
socket = new WebSocket(await devtoolsUrl(proc))
|
|
141
|
+
await new Promise((done, fail) => {
|
|
142
|
+
socket?.addEventListener('open', done, { once: true })
|
|
143
|
+
socket?.addEventListener('error', fail, { once: true })
|
|
144
|
+
})
|
|
145
|
+
return await attach(socket, size, close)
|
|
146
|
+
} catch (error) {
|
|
147
|
+
close()
|
|
148
|
+
throw error
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function devtoolsUrl(proc: ChildProcess): Promise<string> {
|
|
153
|
+
return new Promise((done, fail) => {
|
|
154
|
+
let output = ''
|
|
155
|
+
proc.stderr?.on('data', (chunk: Buffer) => {
|
|
156
|
+
output += chunk.toString()
|
|
157
|
+
const match = output.match(/DevTools listening on (ws:\S+)/)
|
|
158
|
+
if (match?.[1]) done(match[1])
|
|
159
|
+
})
|
|
160
|
+
proc.on('error', fail)
|
|
161
|
+
proc.on('exit', (code) =>
|
|
162
|
+
fail(new Error(`the shell exited ${code}: ${output.slice(-400)}`)),
|
|
163
|
+
)
|
|
164
|
+
})
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function attach(
|
|
168
|
+
socket: WebSocket,
|
|
169
|
+
size: { readonly width: number; readonly height: number },
|
|
170
|
+
close: () => void,
|
|
171
|
+
): Promise<ShellPage> {
|
|
172
|
+
let nextId = 0
|
|
173
|
+
const pending = new Map<
|
|
174
|
+
number,
|
|
175
|
+
{ resolve: (value: never) => void; reject: (error: Error) => void }
|
|
176
|
+
>()
|
|
177
|
+
const listeners: { method: string; sessionId: string; listener: Listener }[] =
|
|
178
|
+
[]
|
|
179
|
+
|
|
180
|
+
socket.addEventListener('message', (event) => {
|
|
181
|
+
const message = JSON.parse(String(event.data)) as {
|
|
182
|
+
id?: number
|
|
183
|
+
method?: string
|
|
184
|
+
sessionId?: string
|
|
185
|
+
params?: Record<string, unknown>
|
|
186
|
+
result?: never
|
|
187
|
+
error?: { message: string; data?: string }
|
|
188
|
+
}
|
|
189
|
+
if (message.id !== undefined) {
|
|
190
|
+
const waiter = pending.get(message.id)
|
|
191
|
+
if (!waiter) return
|
|
192
|
+
pending.delete(message.id)
|
|
193
|
+
if (message.error) {
|
|
194
|
+
waiter.reject(
|
|
195
|
+
new Error(
|
|
196
|
+
`${message.error.message} ${message.error.data ?? ''}`.trim(),
|
|
197
|
+
),
|
|
198
|
+
)
|
|
199
|
+
} else {
|
|
200
|
+
waiter.resolve(message.result as never)
|
|
201
|
+
}
|
|
202
|
+
return
|
|
203
|
+
}
|
|
204
|
+
for (const entry of listeners) {
|
|
205
|
+
if (
|
|
206
|
+
entry.method === message.method &&
|
|
207
|
+
entry.sessionId === message.sessionId
|
|
208
|
+
) {
|
|
209
|
+
entry.listener(message.params ?? {})
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
})
|
|
213
|
+
socket.addEventListener('close', () => {
|
|
214
|
+
for (const waiter of pending.values()) {
|
|
215
|
+
waiter.reject(new Error('the shell closed its connection'))
|
|
216
|
+
}
|
|
217
|
+
pending.clear()
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
const raw = <T>(
|
|
221
|
+
method: string,
|
|
222
|
+
params: Record<string, unknown> = {},
|
|
223
|
+
sessionId?: string,
|
|
224
|
+
): Promise<T> =>
|
|
225
|
+
new Promise<T>((resolve, reject) => {
|
|
226
|
+
nextId += 1
|
|
227
|
+
pending.set(nextId, {
|
|
228
|
+
resolve: resolve as (value: never) => void,
|
|
229
|
+
reject,
|
|
230
|
+
})
|
|
231
|
+
socket.send(
|
|
232
|
+
JSON.stringify({
|
|
233
|
+
id: nextId,
|
|
234
|
+
method,
|
|
235
|
+
params,
|
|
236
|
+
...(sessionId ? { sessionId } : {}),
|
|
237
|
+
}),
|
|
238
|
+
)
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
const { browserContextId } = await raw<{ browserContextId: string }>(
|
|
242
|
+
'Target.createBrowserContext',
|
|
243
|
+
)
|
|
244
|
+
const { targetId } = await raw<{ targetId: string }>('Target.createTarget', {
|
|
245
|
+
url: 'about:blank',
|
|
246
|
+
browserContextId,
|
|
247
|
+
enableBeginFrameControl: true,
|
|
248
|
+
width: size.width,
|
|
249
|
+
height: size.height,
|
|
250
|
+
})
|
|
251
|
+
const { sessionId } = await raw<{ sessionId: string }>(
|
|
252
|
+
'Target.attachToTarget',
|
|
253
|
+
{ targetId, flatten: true },
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
const send = <T>(method: string, params?: Record<string, unknown>) =>
|
|
257
|
+
raw<T>(method, params, sessionId)
|
|
258
|
+
|
|
259
|
+
await send('Page.enable')
|
|
260
|
+
await send('Runtime.enable')
|
|
261
|
+
|
|
262
|
+
return {
|
|
263
|
+
send,
|
|
264
|
+
on: (method, listener) => listeners.push({ method, sessionId, listener }),
|
|
265
|
+
evaluate: async <T>(expression: string): Promise<T> => {
|
|
266
|
+
const reply = await send<{
|
|
267
|
+
result: { value: T }
|
|
268
|
+
exceptionDetails?: {
|
|
269
|
+
text: string
|
|
270
|
+
exception?: { description?: string }
|
|
271
|
+
}
|
|
272
|
+
}>('Runtime.evaluate', {
|
|
273
|
+
expression,
|
|
274
|
+
awaitPromise: true,
|
|
275
|
+
returnByValue: true,
|
|
276
|
+
})
|
|
277
|
+
if (reply.exceptionDetails) {
|
|
278
|
+
throw new Error(
|
|
279
|
+
reply.exceptionDetails.exception?.description ??
|
|
280
|
+
reply.exceptionDetails.text,
|
|
281
|
+
)
|
|
282
|
+
}
|
|
283
|
+
return reply.result.value
|
|
284
|
+
},
|
|
285
|
+
close,
|
|
286
|
+
}
|
|
287
|
+
}
|