@erclx/canon 5.66.0 → 5.68.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 +23 -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/pr-evidence/REQUIREMENT.md +1 -1
- package/claude/skills/pr-evidence/SKILL.md +7 -1
- package/claude/skills/pr-evidence/references/evidence.md +33 -3
- package/claude/skills/review-craft/references/ui.md +30 -10
- 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 +36 -0
- package/claude/skills/ui-record/SKILL.md +114 -0
- package/claude/skills/ui-record/references/config.md +40 -0
- package/claude/skills/ui-record/references/handoff.md +67 -0
- package/claude/skills/ui-record/references/pr-clip.md +84 -0
- package/claude/skills/ui-record/references/take-craft.md +99 -0
- package/docs/agents/records.md +2 -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 +5 -2
- 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 +252 -85
- package/src/commands/records.ts +18 -1
- package/src/commands/take.ts +302 -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/pr/evidence.ts +208 -78
- package/src/pr/frames.ts +42 -4
- package/src/pr/pixels.ts +44 -0
- package/src/records/backup.ts +28 -4
- 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 +207 -0
- package/src/take/film.ts +211 -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/pointer.ts +168 -0
- package/src/take/reel.ts +215 -0
- package/src/take/screencast.ts +97 -0
- package/src/take/shell.ts +287 -0
- package/src/take/stepper.ts +255 -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/reel.ts
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import { readFileSync, statSync } from 'node:fs'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import type { CameraTrack } from '@/take/camera'
|
|
5
|
+
import { finished } from '@/take/encode'
|
|
6
|
+
import type { FilmedBeat } from '@/take/film'
|
|
7
|
+
import { FPS } from '@/take/log'
|
|
8
|
+
import { type Frame, drawPointer } from '@/take/pointer'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Joins a take's beats into one short reel for a pull request: each beat's
|
|
12
|
+
* clip shrunk to one width, every third frame kept, the pointer drawn from its
|
|
13
|
+
* camera track, encoded as an animated WebP that GitHub plays inline. It
|
|
14
|
+
* draws the pointer and nothing else, since the zoom, captions, and cuts are
|
|
15
|
+
* the edit's work for a launch video.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export const REEL_WIDTH = 960
|
|
19
|
+
|
|
20
|
+
/** How many take frames each reel frame stands for, 60 fps down to 20. */
|
|
21
|
+
export const REEL_EVERY = 3
|
|
22
|
+
|
|
23
|
+
export const REEL_FPS = FPS / REEL_EVERY
|
|
24
|
+
|
|
25
|
+
/** WebP quality, measured at about 1.4 MB for a 9 s reel at this width. */
|
|
26
|
+
const QUALITY = 60
|
|
27
|
+
|
|
28
|
+
export interface ReelFrame extends Frame {
|
|
29
|
+
/** Which beat of the take, in plan order. */
|
|
30
|
+
readonly beat: number
|
|
31
|
+
/** The frame's place in its beat, which draws camera frame `index * REEL_EVERY`. */
|
|
32
|
+
readonly index: number
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface Reel {
|
|
36
|
+
readonly file: string
|
|
37
|
+
readonly frames: number
|
|
38
|
+
readonly seconds: number
|
|
39
|
+
readonly bytes: number
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Yields every reel frame in order with the pointer drawn on it. The decoder
|
|
44
|
+
* keeps frames by index rather than by timestamp, since an `fps` filter that
|
|
45
|
+
* rounds timestamps drifts the pointer off what it pressed.
|
|
46
|
+
*/
|
|
47
|
+
export async function* reelFrames(
|
|
48
|
+
out: string,
|
|
49
|
+
beats: readonly FilmedBeat[],
|
|
50
|
+
): AsyncGenerator<ReelFrame> {
|
|
51
|
+
for (const [beat, filmed] of beats.entries()) {
|
|
52
|
+
const camera = JSON.parse(
|
|
53
|
+
readFileSync(join(out, filmed.camera), 'utf8'),
|
|
54
|
+
) as CameraTrack
|
|
55
|
+
const { width, height } = reelSize(camera)
|
|
56
|
+
// The camera is in CSS pixels of the page, and every route's clip shrinks
|
|
57
|
+
// to the same width, so one factor places the pointer on either.
|
|
58
|
+
const factor = width / camera.viewport.width
|
|
59
|
+
|
|
60
|
+
let index = 0
|
|
61
|
+
for await (const data of decode(join(out, filmed.clip), width, height)) {
|
|
62
|
+
const at =
|
|
63
|
+
camera.frames[Math.min(index * REEL_EVERY, camera.frames.length - 1)]
|
|
64
|
+
const frame = { data, width, height, beat, index }
|
|
65
|
+
if (at) {
|
|
66
|
+
drawPointer(
|
|
67
|
+
frame,
|
|
68
|
+
{ x: at.cursor.x * factor, y: at.cursor.y * factor },
|
|
69
|
+
at.ring,
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
yield frame
|
|
73
|
+
index += 1
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Renders the reel of a filmed take to `file`, an animated WebP. */
|
|
79
|
+
export async function renderReel(
|
|
80
|
+
out: string,
|
|
81
|
+
beats: readonly FilmedBeat[],
|
|
82
|
+
file: string,
|
|
83
|
+
): Promise<Reel> {
|
|
84
|
+
const first = beats[0]
|
|
85
|
+
if (!first) throw new Error('a reel needs at least one filmed beat')
|
|
86
|
+
const { width, height } = reelSize(
|
|
87
|
+
JSON.parse(readFileSync(join(out, first.camera), 'utf8')) as CameraTrack,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
const proc = spawn(
|
|
91
|
+
'ffmpeg',
|
|
92
|
+
[
|
|
93
|
+
'-y',
|
|
94
|
+
'-loglevel',
|
|
95
|
+
'error',
|
|
96
|
+
'-f',
|
|
97
|
+
'rawvideo',
|
|
98
|
+
'-pix_fmt',
|
|
99
|
+
'rgba',
|
|
100
|
+
'-s',
|
|
101
|
+
`${width}x${height}`,
|
|
102
|
+
'-framerate',
|
|
103
|
+
String(REEL_FPS),
|
|
104
|
+
'-i',
|
|
105
|
+
'-',
|
|
106
|
+
'-c:v',
|
|
107
|
+
'libwebp_anim',
|
|
108
|
+
'-quality',
|
|
109
|
+
String(QUALITY),
|
|
110
|
+
'-loop',
|
|
111
|
+
'0',
|
|
112
|
+
'-f',
|
|
113
|
+
'webp',
|
|
114
|
+
file,
|
|
115
|
+
],
|
|
116
|
+
{ stdio: ['pipe', 'ignore', 'pipe'] },
|
|
117
|
+
)
|
|
118
|
+
const done = finished(proc)
|
|
119
|
+
// Held so an encoder that dies mid-stream surfaces through `done` rather than
|
|
120
|
+
// as an unhandled rejection or a broken pipe thrown out of the stream.
|
|
121
|
+
done.catch(() => {})
|
|
122
|
+
proc.stdin?.on('error', () => {})
|
|
123
|
+
|
|
124
|
+
let frames = 0
|
|
125
|
+
try {
|
|
126
|
+
for await (const frame of reelFrames(out, beats)) {
|
|
127
|
+
// Raced against the exit, since a write waiting on a drain from an
|
|
128
|
+
// encoder that already died would wait forever.
|
|
129
|
+
await Promise.race([write(proc.stdin, frame.data), done])
|
|
130
|
+
frames += 1
|
|
131
|
+
}
|
|
132
|
+
} catch (error) {
|
|
133
|
+
proc.kill('SIGKILL')
|
|
134
|
+
throw error
|
|
135
|
+
}
|
|
136
|
+
proc.stdin?.end()
|
|
137
|
+
await done
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
file,
|
|
141
|
+
frames,
|
|
142
|
+
seconds: Number((frames / REEL_FPS).toFixed(2)),
|
|
143
|
+
bytes: statSync(file).size,
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function reelSize(camera: CameraTrack): { width: number; height: number } {
|
|
148
|
+
const height =
|
|
149
|
+
Math.round(
|
|
150
|
+
(REEL_WIDTH * camera.viewport.height) / camera.viewport.width / 2,
|
|
151
|
+
) * 2
|
|
152
|
+
return { width: REEL_WIDTH, height }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Decodes every `REEL_EVERY`th frame of a clip as RGBA at the reel's size. */
|
|
156
|
+
async function* decode(
|
|
157
|
+
clip: string,
|
|
158
|
+
width: number,
|
|
159
|
+
height: number,
|
|
160
|
+
): AsyncGenerator<Uint8Array> {
|
|
161
|
+
const proc = spawn(
|
|
162
|
+
'ffmpeg',
|
|
163
|
+
[
|
|
164
|
+
'-loglevel',
|
|
165
|
+
'error',
|
|
166
|
+
'-i',
|
|
167
|
+
clip,
|
|
168
|
+
'-vf',
|
|
169
|
+
`select='not(mod(n\\,${REEL_EVERY}))',scale=${width}:${height}:flags=area`,
|
|
170
|
+
'-fps_mode',
|
|
171
|
+
'passthrough',
|
|
172
|
+
'-f',
|
|
173
|
+
'rawvideo',
|
|
174
|
+
'-pix_fmt',
|
|
175
|
+
'rgba',
|
|
176
|
+
'-',
|
|
177
|
+
],
|
|
178
|
+
{ stdio: ['ignore', 'pipe', 'pipe'] },
|
|
179
|
+
)
|
|
180
|
+
const done = finished(proc)
|
|
181
|
+
done.catch(() => {})
|
|
182
|
+
|
|
183
|
+
const size = width * height * 4
|
|
184
|
+
let pending: Buffer = Buffer.alloc(0)
|
|
185
|
+
let drained = false
|
|
186
|
+
try {
|
|
187
|
+
for await (const chunk of proc.stdout as AsyncIterable<Buffer>) {
|
|
188
|
+
pending = pending.length ? Buffer.concat([pending, chunk]) : chunk
|
|
189
|
+
while (pending.length >= size) {
|
|
190
|
+
// Copied out, since the frame is drawn on and outlives the chunk.
|
|
191
|
+
yield new Uint8Array(pending.subarray(0, size))
|
|
192
|
+
pending = pending.subarray(size)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
drained = true
|
|
196
|
+
} finally {
|
|
197
|
+
// A reader that stopped early leaves FFmpeg decoding into a full pipe.
|
|
198
|
+
if (!drained) proc.kill('SIGKILL')
|
|
199
|
+
}
|
|
200
|
+
await done
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function write(
|
|
204
|
+
stdin: NodeJS.WritableStream | null,
|
|
205
|
+
data: Uint8Array,
|
|
206
|
+
): Promise<void> {
|
|
207
|
+
return new Promise((resolve, reject) => {
|
|
208
|
+
if (!stdin) {
|
|
209
|
+
reject(new Error('ffmpeg stopped reading frames'))
|
|
210
|
+
return
|
|
211
|
+
}
|
|
212
|
+
if (stdin.write(data)) resolve()
|
|
213
|
+
else stdin.once('drain', () => resolve())
|
|
214
|
+
})
|
|
215
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
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 { lastFrame, 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
|
+
/** The clip's closing frame as a JPEG. */
|
|
26
|
+
readonly still: Buffer
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function filmScreencast(request: {
|
|
30
|
+
readonly url: string
|
|
31
|
+
readonly beat: Beat
|
|
32
|
+
readonly clip: string
|
|
33
|
+
}): Promise<ScreencastTake> {
|
|
34
|
+
const scratch = mkdtempSync(join(tmpdir(), 'canon-take-cast-'))
|
|
35
|
+
const browser = await chromium.launch()
|
|
36
|
+
try {
|
|
37
|
+
const page = await browser.newPage({ viewport: VIEWPORT })
|
|
38
|
+
await page.goto(request.url, { waitUntil: 'load' })
|
|
39
|
+
await page.waitForTimeout(SETTLE_MS)
|
|
40
|
+
|
|
41
|
+
const stageOn = (clock: () => number): Stage => ({
|
|
42
|
+
now: clock,
|
|
43
|
+
hold: (ms) => page.waitForTimeout(ms),
|
|
44
|
+
pointer: async (input) => {
|
|
45
|
+
const sent = clock()
|
|
46
|
+
await send(page.mouse, input)
|
|
47
|
+
const spent = clock() - sent
|
|
48
|
+
if (spent < FRAME_MS) await page.waitForTimeout(FRAME_MS - spent)
|
|
49
|
+
},
|
|
50
|
+
key: async (text) => {
|
|
51
|
+
await page.keyboard.insertText(text)
|
|
52
|
+
await page.waitForTimeout(FRAME_MS)
|
|
53
|
+
},
|
|
54
|
+
evaluate: (expression) => page.evaluate(expression),
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
const live = stageOn(() => performance.now())
|
|
58
|
+
await performSteps(live, request.beat.setup, { pointer: START })
|
|
59
|
+
if (request.beat.setup.length > 0) await live.hold(1000)
|
|
60
|
+
await page.mouse.move(START.x, START.y)
|
|
61
|
+
|
|
62
|
+
const webm = join(scratch, 'take.webm')
|
|
63
|
+
const events: LogEvent[] = []
|
|
64
|
+
await page.screencast.start({ path: webm, size: VIEWPORT, fps: FPS })
|
|
65
|
+
const started = performance.now()
|
|
66
|
+
const recording = stageOn(() => performance.now() - started)
|
|
67
|
+
await recording.hold(HOLD_MS)
|
|
68
|
+
await performSteps(recording, request.beat.steps, {
|
|
69
|
+
pointer: START,
|
|
70
|
+
log: events,
|
|
71
|
+
})
|
|
72
|
+
await recording.hold(HOLD_MS)
|
|
73
|
+
await page.screencast.stop()
|
|
74
|
+
|
|
75
|
+
await transcode(webm, request.clip, FPS)
|
|
76
|
+
const frames = await probeFrames(request.clip)
|
|
77
|
+
return { frames, events, still: await lastFrame(request.clip, frames) }
|
|
78
|
+
} finally {
|
|
79
|
+
await browser.close()
|
|
80
|
+
rmSync(scratch, { recursive: true, force: true })
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
type Mouse = import('playwright-core').Mouse
|
|
85
|
+
|
|
86
|
+
async function send(mouse: Mouse, input: PointerInput): Promise<void> {
|
|
87
|
+
switch (input.type) {
|
|
88
|
+
case 'move':
|
|
89
|
+
return mouse.move(input.x, input.y)
|
|
90
|
+
case 'down':
|
|
91
|
+
return mouse.down()
|
|
92
|
+
case 'up':
|
|
93
|
+
return mouse.up()
|
|
94
|
+
case 'wheel':
|
|
95
|
+
return mouse.wheel(0, input.dy)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -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
|
+
}
|