@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.
Files changed (66) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/app-drive/REQUIREMENT.md +35 -0
  4. package/claude/skills/app-drive/SKILL.md +112 -0
  5. package/claude/skills/app-drive/references/config.md +38 -0
  6. package/claude/skills/app-drive/references/drive-record.md +70 -0
  7. package/claude/skills/app-drive/references/isolate.md +46 -0
  8. package/claude/skills/canon-cli/SKILL.md +2 -0
  9. package/claude/skills/create-skill/references/skill.md +1 -1
  10. package/claude/skills/plan-feature/references/plan.md +1 -1
  11. package/claude/skills/plan-intake/references/intake.md +12 -2
  12. package/claude/skills/review-ui/SKILL.md +5 -4
  13. package/claude/skills/review-ui/references/drive-copy.md +4 -0
  14. package/claude/skills/review-ui/references/driving.md +1 -1
  15. package/claude/skills/role-orchestrator/references/orchestrator-handoff.md +15 -2
  16. package/claude/skills/role-planner/SKILL.md +2 -2
  17. package/claude/skills/role-worker/SKILL.md +4 -6
  18. package/claude/skills/session-map/REQUIREMENT.md +1 -1
  19. package/claude/skills/session-map/SKILL.md +8 -1
  20. package/claude/skills/session-map/references/session.md +19 -5
  21. package/claude/skills/ui-record/REQUIREMENT.md +34 -0
  22. package/claude/skills/ui-record/SKILL.md +103 -0
  23. package/claude/skills/ui-record/references/config.md +40 -0
  24. package/claude/skills/ui-record/references/handoff.md +48 -0
  25. package/claude/skills/ui-record/references/take-craft.md +92 -0
  26. package/docs/agents/records.md +1 -0
  27. package/docs/workflow/parallel-features.md +1 -1
  28. package/docs/workflow/skill-map-on-request.md +1 -0
  29. package/docs/workflow/skill-map.md +1 -0
  30. package/docs/workflow/visual-design-workflow.md +2 -1
  31. package/package.json +1 -1
  32. package/src/audits/catalog.ts +5 -1
  33. package/src/browser/agent-cli.ts +68 -0
  34. package/src/claude/cases/workflow.ts +20 -0
  35. package/src/cli/help.ts +3 -0
  36. package/src/cli.ts +4 -0
  37. package/src/commands/browser.ts +102 -0
  38. package/src/commands/intake.ts +181 -1
  39. package/src/commands/pr.ts +15 -6
  40. package/src/commands/records.ts +18 -1
  41. package/src/commands/take.ts +266 -0
  42. package/src/context/requirements.ts +1 -11
  43. package/src/git/status.ts +73 -0
  44. package/src/intake/drive-check.ts +355 -0
  45. package/src/intake/folder.ts +1 -1
  46. package/src/markdown/scan.ts +10 -0
  47. package/src/pr/drive-copy.ts +97 -84
  48. package/src/records/session-map.ts +75 -0
  49. package/src/records/validate.ts +64 -8
  50. package/src/take/camera.ts +309 -0
  51. package/src/take/encode.ts +170 -0
  52. package/src/take/film.ts +206 -0
  53. package/src/take/fixtures/fade.html +49 -0
  54. package/src/take/fixtures/fetch.html +38 -0
  55. package/src/take/log.ts +109 -0
  56. package/src/take/perform.ts +200 -0
  57. package/src/take/plan.ts +193 -0
  58. package/src/take/screencast.ts +94 -0
  59. package/src/take/shell.ts +287 -0
  60. package/src/take/stepper.ts +252 -0
  61. package/src/take/zoom/NOTICE.md +45 -0
  62. package/src/take/zoom/math-utils.ts +69 -0
  63. package/src/take/zoom/region-utils.ts +55 -0
  64. package/src/take/zoom/spring.ts +133 -0
  65. package/src/tasks/archive.ts +1 -3
  66. package/claude/skills/review-ui/scripts/pw.sh +0 -60
@@ -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
+ }