@crouton-kit/capture 1.3.8 → 1.3.10

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 (136) hide show
  1. package/bin/capture +29841 -8539
  2. package/package.json +6 -3
  3. package/src/capture.ts +86 -173
  4. package/src/cdp/a11y.ts +113 -14
  5. package/src/cdp/args.ts +300 -205
  6. package/src/cdp/bridge/protocol.ts +93 -6
  7. package/src/cdp/bridge/server.ts +118 -53
  8. package/src/cdp/bridge/spawn.ts +53 -6
  9. package/src/cdp/client.ts +13 -3
  10. package/src/cdp/commands/bridge-serve.ts +20 -14
  11. package/src/cdp/commands/cdp.ts +259 -90
  12. package/src/cdp/commands/gate-guard.ts +43 -0
  13. package/src/cdp/commands/lib.ts +339 -213
  14. package/src/cdp/commands/measure/census.ts +76 -20
  15. package/src/cdp/commands/measure/check.ts +100 -26
  16. package/src/cdp/commands/measure/diff.ts +109 -21
  17. package/src/cdp/commands/measure/explain.ts +121 -19
  18. package/src/cdp/commands/measure/index.ts +31 -39
  19. package/src/cdp/commands/measure/map-ax.ts +52 -0
  20. package/src/cdp/commands/measure/map-focus.ts +41 -13
  21. package/src/cdp/commands/measure/map-layers.ts +49 -15
  22. package/src/cdp/commands/measure/map-scroll.ts +85 -15
  23. package/src/cdp/commands/measure/snap.ts +405 -25
  24. package/src/cdp/commands/measure/sweep.ts +217 -20
  25. package/src/cdp/commands/motion/index.ts +18 -17
  26. package/src/cdp/commands/motion/jank.ts +119 -9
  27. package/src/cdp/commands/motion/mask.ts +81 -12
  28. package/src/cdp/commands/motion/rec.ts +520 -19
  29. package/src/cdp/commands/motion/response.ts +65 -15
  30. package/src/cdp/commands/motion/timeline.ts +139 -14
  31. package/src/cdp/commands/page/click.ts +194 -0
  32. package/src/cdp/commands/page/elements.ts +154 -0
  33. package/src/cdp/commands/page/exec.ts +317 -0
  34. package/src/cdp/commands/page/index.ts +72 -0
  35. package/src/cdp/commands/page/navigate.ts +330 -0
  36. package/src/cdp/commands/page/scroll.ts +107 -0
  37. package/src/cdp/commands/page/shot.ts +208 -0
  38. package/src/cdp/commands/page/type.ts +104 -0
  39. package/src/cdp/commands/tab/index.ts +59 -0
  40. package/src/cdp/commands/tab/list.ts +167 -0
  41. package/src/cdp/commands/tab/network.ts +162 -0
  42. package/src/cdp/commands/tab/open.ts +90 -0
  43. package/src/cdp/commands/tab/reset.ts +160 -0
  44. package/src/cdp/connection.ts +368 -37
  45. package/src/cdp/detect.ts +42 -13
  46. package/src/cdp/dispatch.ts +74 -28
  47. package/src/cdp/har-recorder.ts +648 -115
  48. package/src/cdp/leaf-grammar.ts +99 -0
  49. package/src/cdp/measure/census.ts +205 -0
  50. package/src/cdp/measure/check.ts +318 -0
  51. package/src/cdp/measure/collectors/animation.ts +385 -0
  52. package/src/cdp/measure/collectors/ax.ts +278 -0
  53. package/src/cdp/measure/collectors/focus.ts +1041 -0
  54. package/src/cdp/measure/collectors/forms.ts +905 -0
  55. package/src/cdp/measure/collectors/geometry.ts +1038 -0
  56. package/src/cdp/measure/collectors/hittest.ts +835 -0
  57. package/src/cdp/measure/collectors/layers.ts +697 -0
  58. package/src/cdp/measure/collectors/media.ts +522 -0
  59. package/src/cdp/measure/collectors/pixels.ts +1584 -0
  60. package/src/cdp/measure/collectors/queries.ts +464 -0
  61. package/src/cdp/measure/collectors/scroll.ts +599 -0
  62. package/src/cdp/measure/collectors/states.ts +992 -0
  63. package/src/cdp/measure/collectors/style-provenance.ts +643 -0
  64. package/src/cdp/measure/collectors/styles.ts +355 -0
  65. package/src/cdp/measure/collectors/text.ts +713 -0
  66. package/src/cdp/measure/diff.ts +470 -0
  67. package/src/cdp/measure/explain.ts +498 -0
  68. package/src/cdp/measure/map-ax.ts +633 -0
  69. package/src/cdp/measure/map-focus.ts +188 -0
  70. package/src/cdp/measure/map-layers.ts +177 -0
  71. package/src/cdp/measure/map-scroll.ts +317 -0
  72. package/src/cdp/measure/redaction.ts +51 -0
  73. package/src/cdp/measure/settle.ts +1051 -0
  74. package/src/cdp/measure/snapshot.ts +390 -0
  75. package/src/cdp/measure/sweep.ts +330 -0
  76. package/src/cdp/measure/types.ts +220 -0
  77. package/src/cdp/measure/v2/collectors.ts +19 -0
  78. package/src/cdp/measure/v2/orchestrator.ts +46 -0
  79. package/src/cdp/measure/v2/snap-selection.ts +33 -0
  80. package/src/cdp/measure/v2/target-lock.ts +15 -0
  81. package/src/cdp/measure/v2/target.ts +16 -0
  82. package/src/cdp/motion/jank.ts +433 -0
  83. package/src/cdp/motion/mask.ts +421 -0
  84. package/src/cdp/motion/recorder.ts +764 -0
  85. package/src/cdp/motion/response.ts +356 -0
  86. package/src/cdp/motion/timeline.ts +215 -0
  87. package/src/cdp/record.ts +5 -212
  88. package/src/cdp/recorder-bridge.ts +1478 -105
  89. package/src/cdp/recorder-client.ts +326 -0
  90. package/src/cdp/scope-lock.ts +73 -0
  91. package/src/cdp/screenshot.ts +199 -66
  92. package/src/cdp/targets.ts +21 -1
  93. package/src/cdp/timing.ts +15 -10
  94. package/src/cdp/types.ts +22 -7
  95. package/src/cdp/viewport.ts +21 -0
  96. package/src/cdp.ts +12 -58
  97. package/src/command-help.ts +29 -0
  98. package/src/command-resolution.ts +22 -0
  99. package/src/command-walker.ts +35 -0
  100. package/src/contracts/a11y.ts +33 -0
  101. package/src/contracts/cursor.ts +35 -0
  102. package/src/contracts/index.ts +15 -0
  103. package/src/contracts/motion.ts +19 -0
  104. package/src/contracts/neutral.ts +56 -0
  105. package/src/contracts/primitives.ts +205 -0
  106. package/src/contracts/release.ts +17 -0
  107. package/src/contracts/results.ts +208 -0
  108. package/src/contracts/routes.ts +140 -0
  109. package/src/contracts/snapshot.ts +92 -0
  110. package/src/cursor/index.ts +13 -0
  111. package/src/cursor/service.ts +310 -0
  112. package/src/errors.ts +71 -0
  113. package/src/har-manager.ts +992 -41
  114. package/src/interact.ts +410 -98
  115. package/src/output/artifact-lifecycle.ts +371 -0
  116. package/src/output/artifact.ts +6 -0
  117. package/src/output/exact-raw.ts +114 -0
  118. package/src/output/render.ts +396 -2
  119. package/src/output/structured.ts +211 -0
  120. package/src/registry.ts +208 -0
  121. package/src/session/artifacts.ts +452 -200
  122. package/src/session/commands.ts +1162 -397
  123. package/src/session/coordinator.ts +251 -0
  124. package/src/session/har-filter.ts +57 -0
  125. package/src/session/log-tailer.ts +653 -0
  126. package/src/session-context.ts +279 -51
  127. package/src/vault/bundle.ts +32 -17
  128. package/src/cdp/commands/exec.ts +0 -105
  129. package/src/cdp/commands/har.ts +0 -128
  130. package/src/cdp/commands/tabs.ts +0 -183
  131. package/src/cdp/commands/traffic.ts +0 -121
  132. package/src/cdp/commands/ui.ts +0 -172
  133. package/src/cdp/execute.ts +0 -113
  134. package/src/cdp/har-output.ts +0 -47
  135. package/src/cdp/usage.ts +0 -79
  136. package/src/tab-session.ts +0 -91
@@ -0,0 +1,326 @@
1
+ /**
2
+ * Client side of the recorder-bridge protocol (`./bridge/protocol.ts`'s
3
+ * `RecorderRequest`/`RecorderResponse` union) — the counterpart to
4
+ * `./bridge/client.ts`'s `sendBridgeRequest` for the plain held bridge, but
5
+ * typed against the recorder-mode wire shapes instead. Same convention: one
6
+ * Unix-socket connection per request, one response line, then the socket
7
+ * closes.
8
+ *
9
+ * This file also owns `RecorderHeldClient` — the `CDPClient`-compatible
10
+ * adapter `../connection.ts` hands to command leaves when a recording is
11
+ * active on the session's tab (see U14's routing decision there). A leaf
12
+ * that receives one behaves exactly as if it had opened its own tab
13
+ * websocket: `.send()` round-trips a CDP call through the recorder's held
14
+ * connection, `.close()` is a no-op (the recorder owns the connection until
15
+ * `motion rec --stop`, not the leaf command that borrowed it), and
16
+ * `.on()`/`.onDisconnect()` are documented no-ops — a routed command's own
17
+ * `ConsoleRecorder`/`HARRecorder` would only ever see zero events through
18
+ * this adapter (nothing pushes unsolicited events back over the
19
+ * one-request-one-response socket), so `../connection.ts` skips wiring them
20
+ * entirely when routed, rather than silently reporting an empty console/HAR
21
+ * summary as if it were real. HTTP evidence for a routed action is NOT lost:
22
+ * the recorder bridge holds the one streaming `HARRecorder` on its held tab
23
+ * connection, appending each completed entry into the owning session's live
24
+ * HAR continuously, and `../connection.ts`'s action wrapper awaits this
25
+ * adapter's `flushHar()` health barrier before claiming success, so traffic
26
+ * completed by action+settle time is durably in the session HAR when the
27
+ * command returns.
28
+ */
29
+
30
+ import * as net from 'net';
31
+ import {
32
+ type RecorderRequest,
33
+ type RecorderResponse,
34
+ type RecStartResponseOk,
35
+ type RecStopResponseOk,
36
+ } from './bridge/protocol.js';
37
+
38
+ let reqCounter = 0;
39
+
40
+ /** Raw one-shot request/response round trip over a recorder bridge's socket. */
41
+ export function sendRecorderRequest(
42
+ socketPath: string,
43
+ req: Omit<RecorderRequest, 'reqId'>,
44
+ ): Promise<RecorderResponse> {
45
+ const timeoutMsField = (req as { timeoutMs?: number }).timeoutMs;
46
+ const wireTimeoutMs = (timeoutMsField ?? 10000) + 5000;
47
+
48
+ return new Promise((resolve, reject) => {
49
+ const reqId = ++reqCounter;
50
+ const socket = net.createConnection(socketPath);
51
+ let buffer = '';
52
+
53
+ const timer = setTimeout(() => {
54
+ socket.destroy();
55
+ reject(new Error(`Recorder request timed out after ${wireTimeoutMs}ms (socket: ${socketPath})`));
56
+ }, wireTimeoutMs);
57
+
58
+ socket.on('connect', () => {
59
+ socket.write(JSON.stringify({ ...req, reqId } as RecorderRequest) + '\n');
60
+ });
61
+
62
+ socket.on('data', (chunk) => {
63
+ buffer += chunk.toString('utf-8');
64
+ const idx = buffer.indexOf('\n');
65
+ if (idx < 0) return;
66
+ clearTimeout(timer);
67
+ const line = buffer.slice(0, idx);
68
+ socket.end();
69
+ try {
70
+ resolve(JSON.parse(line) as RecorderResponse);
71
+ } catch (err) {
72
+ reject(err instanceof Error ? err : new Error(String(err)));
73
+ }
74
+ });
75
+
76
+ socket.on('error', (err) => {
77
+ clearTimeout(timer);
78
+ reject(err);
79
+ });
80
+ });
81
+ }
82
+
83
+ /** Arms the recorder (`rec-start`). `nonce` is the recording's control-socket
84
+ * admission token (see `protocol.ts`'s `RecStartRequest`); every request must
85
+ * carry it. Throws with the recorder's own error message on failure. */
86
+ export async function requestRecStart(socketPath: string, nonce: string): Promise<RecStartResponseOk> {
87
+ const resp = await sendRecorderRequest(socketPath, { type: 'rec-start', nonce });
88
+ if (!resp.ok) throw new Error(`rec-start failed: ${resp.error}`);
89
+ return resp;
90
+ }
91
+
92
+ /**
93
+ * Thrown by `requestRecStop` when the bridge actually answered with an
94
+ * authenticated `ok:false` response — an explicit, terminal recorder-stop
95
+ * failure (fatal HAR drain, or any other authoritative rejection the bridge
96
+ * itself produced), as opposed to a transport/no-response failure (socket
97
+ * connect/timeout error), which `sendRecorderRequest` still rejects with a
98
+ * plain `Error`. Callers that only read `.message` see the exact same
99
+ * `rec-stop failed: ${error}` text as before this type existed; callers that
100
+ * need to distinguish "the bridge answered and refused" from "the bridge
101
+ * never answered" (`../motion/recorder.ts`'s terminal-vs-transport
102
+ * classification) narrow with `instanceof RecStopBridgeFailure` and read
103
+ * `.responseError` for the bridge's raw, unwrapped error string.
104
+ */
105
+ export class RecStopBridgeFailure extends Error {
106
+ readonly responseError: string;
107
+ constructor(responseError: string) {
108
+ super(`rec-stop failed: ${responseError}`);
109
+ this.name = 'RecStopBridgeFailure';
110
+ this.responseError = responseError;
111
+ }
112
+ }
113
+
114
+ /** Finalizes the recorder (`rec-stop`). Throws `RecStopBridgeFailure` (preserving the recorder's own error message) when the bridge answers `ok:false`; a transport/no-response failure still throws a plain `Error`. */
115
+ export async function requestRecStop(socketPath: string, nonce: string): Promise<RecStopResponseOk> {
116
+ const resp = await sendRecorderRequest(socketPath, { type: 'rec-stop', nonce });
117
+ if (!resp.ok) throw new RecStopBridgeFailure(resp.error);
118
+ return resp;
119
+ }
120
+
121
+ /** CDP method names the recorder bridge's `mark` mechanism is meant for —
122
+ * see `../recorder-bridge.ts`'s `handleCdp()`: a `mark`-bearing request is
123
+ * bracketed with two `performance.now()` reads and records a labeled
124
+ * input-landmark entry straight to `events.jsonl` (host-side only — the
125
+ * label never touches the page, see `../timing.ts`'s
126
+ * `withDocumentPerformanceNow`), so it must only be set on the CDP calls that
127
+ * actually dispatch a distinct, landmark-worthy ACTION, not on every
128
+ * incidental call a higher-level helper (`resolveLiveTarget`, `focusAndType`, ...)
129
+ * happens to make while resolving a selector/name (`Accessibility.*`,
130
+ * `DOM.*`, `Runtime.evaluate`, `Page.captureScreenshot`, ...). Every
131
+ * `Input.dispatch*` method (mouse/key/touch) is covered by the prefix;
132
+ * `Input.insertText` is NOT a `dispatch*` method but IS the actual mutator
133
+ * `../../interact.ts`'s `typeText()`/`focusAndType()` issue for `capture
134
+ * page type`, so it is listed explicitly: routed typing needs one input
135
+ * landmark in `events.jsonl` for `motion response` to anchor. `Page.navigate`
136
+ * is likewise the action landmark for `capture page navigate`; the
137
+ * recorder-routed path in `../commands/page/navigate.ts` sends it through
138
+ * this client just like click and type. */
139
+ const MARKABLE_EXACT_METHODS = new Set(['Input.insertText', 'Page.navigate']);
140
+
141
+ function isMarkableActionMethod(method: string, params: Record<string, unknown>): boolean {
142
+ if (MARKABLE_EXACT_METHODS.has(method)) return true;
143
+ // A logical input may require several low-level dispatches. Mark its
144
+ // initiating edge only: e.g. clickResolved emits press + release, which is
145
+ // one landmark rather than two identically-labelled observations.
146
+ if (method === 'Input.dispatchMouseEvent') return params.type === 'mousePressed';
147
+ if (method === 'Input.dispatchKeyEvent') return params.type === 'keyDown' || params.type === 'rawKeyDown';
148
+ if (method === 'Input.dispatchTouchEvent') return params.type === 'touchStart';
149
+ return false;
150
+ }
151
+
152
+ export interface RecorderHeldClientOptions {
153
+ /** The active recording's socket path (`recorderSocketPath(recDir)`). */
154
+ socketPath: string;
155
+ /** The recording's control-socket admission token, read from the persisted
156
+ * recorder handle (`recorder.json.nonce`) — required on every request this
157
+ * adapter sends; the bridge rejects anything without it. */
158
+ nonce: string;
159
+ /** Label attached to every marked (`Input.dispatch*`) call from this
160
+ * client instance — one per routed capture command invocation, e.g.
161
+ * `click:Send` or `type:ax:Message`, matching the labeled input landmark
162
+ * shape in `events.jsonl` without recording typed content. */
163
+ actionLabel: string;
164
+ timeoutMs?: number;
165
+ }
166
+
167
+ /**
168
+ * `CDPClient`-compatible adapter over a live recorder connection. Structural
169
+ * subset (`send`/`on`/`onDisconnect`/`close`) rather than a subclass — see
170
+ * `../connection.ts`'s `connectToActiveRecorder()`, which is the only
171
+ * caller, and which casts the result to `CDPClient` the same documented way
172
+ * `../recorder-bridge.ts`'s `asCDPClient()` does, so every existing command
173
+ * leaf keeps working against the same type it always has.
174
+ */
175
+ export class RecorderHeldClient {
176
+ private readonly socketPath: string;
177
+ private readonly nonce: string;
178
+ private readonly actionLabel: string;
179
+ private readonly defaultTimeoutMs: number;
180
+ private suppressNextMousePressMark = false;
181
+
182
+ constructor(opts: RecorderHeldClientOptions) {
183
+ this.socketPath = opts.socketPath;
184
+ this.nonce = opts.nonce;
185
+ this.actionLabel = opts.actionLabel;
186
+ this.defaultTimeoutMs = opts.timeoutMs ?? 60000;
187
+ }
188
+
189
+ /** Suppresses the focus click's initiating edge so a routed `type --into`
190
+ * has one landmark on its actual text insertion. */
191
+ suppressNextFocusClickMark(): void {
192
+ this.suppressNextMousePressMark = true;
193
+ }
194
+
195
+ async send(
196
+ method: string,
197
+ params: Record<string, unknown> = {},
198
+ timeoutMs?: number,
199
+ _sessionId?: string,
200
+ ): Promise<unknown> {
201
+ const { result } = await this.dispatch(method, params, undefined, timeoutMs);
202
+ return result;
203
+ }
204
+
205
+ /** Marked lane for a mutating call the auto-markable set above doesn't
206
+ * cover — `Runtime.callFunctionOn` in `../../interact.ts`'s
207
+ * `scrollResolved`, whose caller supplies the landmark label explicitly
208
+ * (`LiveClient.sendMarked`). Rides the same bridge `mark` mechanism as
209
+ * every auto-marked call. */
210
+ async sendMarked(method: string, params: Record<string, unknown>, mark: string): Promise<unknown> {
211
+ const resp = await sendRecorderRequest(this.socketPath, {
212
+ type: 'cdp',
213
+ nonce: this.nonce,
214
+ method,
215
+ params,
216
+ mark,
217
+ timeoutMs: this.defaultTimeoutMs,
218
+ });
219
+ if (!resp.ok) throw new Error(`recorder-routed marked CDP call "${method}" failed: ${resp.error}`);
220
+ return resp.result;
221
+ }
222
+
223
+ /**
224
+ * Blocks on the recorder bridge's own event broker for the next
225
+ * occurrence of `eventName`, via a wait-event-ONLY request (`method`
226
+ * omitted — see `RecCdpWaitEventRequest`). This is the real event-wait
227
+ * surface for adapter users, distinct from `.on()` above. `capture cdp
228
+ * --wait-event` is the primary caller (`../commands/cdp.ts`) when no
229
+ * method is also being dispatched.
230
+ */
231
+ async waitEvent(eventName: string, timeoutMs?: number): Promise<unknown> {
232
+ const resp = await sendRecorderRequest(this.socketPath, {
233
+ type: 'cdp',
234
+ nonce: this.nonce,
235
+ waitEvent: eventName,
236
+ timeoutMs: timeoutMs ?? this.defaultTimeoutMs,
237
+ });
238
+ if (!resp.ok) {
239
+ throw new Error(`recorder-routed wait-event "${eventName}" failed: ${resp.error}`);
240
+ }
241
+ return resp.event;
242
+ }
243
+
244
+ /**
245
+ * Dispatches `method` and, when `waitEvent` is given, arms the wait for it
246
+ * in the SAME `sendRecorderRequest` call — one unix-socket connection, one
247
+ * request line carrying `method`, `params`, `mark`, and (optionally)
248
+ * `waitEvent` together. The bridge's own `RecorderSession.handleCdp()`
249
+ * registers the event wait BEFORE dispatching the CDP call within that
250
+ * one request, so this is race-free against a fast action-caused event —
251
+ * unlike `.send()` followed by `.waitEvent()`, which are two SEPARATE
252
+ * connections/requests with no ordering guarantee between them. `.send()`
253
+ * above is just this method with `waitEvent` omitted; every caller that
254
+ * needs to dispatch a method and observe an event it may itself trigger
255
+ * should call this directly instead.
256
+ *
257
+ * When `method` and `waitEvent` are BOTH set, a successful dispatch is
258
+ * preserved even if the paired wait times out: the bridge's `handleCdp`
259
+ * returns the method `result` plus a `waitOutcome` of `'observed'` (the
260
+ * event fired within the deadline, `event` populated) or `'bounded-timeout'`
261
+ * (the event did not fire in time — the method still succeeded). Only a
262
+ * FAILURE of the dispatched method itself yields `ok:false`. A wait-event-
263
+ * only request (`method` omitted) keeps the older semantics: a wait timeout
264
+ * is itself the failure and surfaces as `ok:false`, with no `waitOutcome`.
265
+ */
266
+ async dispatch(
267
+ method: string,
268
+ params: Record<string, unknown> = {},
269
+ waitEvent?: string,
270
+ timeoutMs?: number,
271
+ ): Promise<{ result: unknown; event?: unknown; waitOutcome?: 'observed' | 'bounded-timeout' }> {
272
+ const suppressMousePress = method === 'Input.dispatchMouseEvent' && params.type === 'mousePressed' && this.suppressNextMousePressMark;
273
+ if (suppressMousePress) this.suppressNextMousePressMark = false;
274
+ const mark = !suppressMousePress && isMarkableActionMethod(method, params) ? this.actionLabel : undefined;
275
+ const resp = await sendRecorderRequest(this.socketPath, {
276
+ type: 'cdp',
277
+ nonce: this.nonce,
278
+ method,
279
+ params,
280
+ mark,
281
+ waitEvent,
282
+ timeoutMs: timeoutMs ?? this.defaultTimeoutMs,
283
+ });
284
+ if (!resp.ok) {
285
+ throw new Error(
286
+ `recorder-routed CDP call "${method}"${waitEvent ? ` with wait-event "${waitEvent}"` : ''} failed: ${resp.error}`,
287
+ );
288
+ }
289
+ return { result: resp.result, event: resp.event, waitOutcome: resp.waitOutcome };
290
+ }
291
+
292
+ /**
293
+ * The action-return HAR flush/health barrier (`har-flush` — see
294
+ * `./bridge/protocol.ts`'s `RecHarFlushRequest`). Resolves once every HAR
295
+ * entry/body/append the recorder's streaming collector had completed at
296
+ * request time is durably in the owning session's live HAR and no fatal
297
+ * store error is latched. Non-attributing: success carries no counts and
298
+ * claims nothing about which entries this action caused. Throws when the
299
+ * bridge answers `ok:false` (a latched fatal store failure, or the terminal
300
+ * rec-stop already owns finalization) or on a transport failure.
301
+ */
302
+ async flushHar(): Promise<void> {
303
+ const resp = await sendRecorderRequest(this.socketPath, { type: 'har-flush', nonce: this.nonce });
304
+ if (!resp.ok) throw new Error(`recorder har-flush failed: ${resp.error}`);
305
+ }
306
+
307
+ /** Documented no-op — see this file's header comment. */
308
+ on(_event: string, _handler: (params: unknown) => void): void {
309
+ // Intentionally does nothing: nothing pushes unsolicited events back
310
+ // over the recorder's one-request-one-response socket.
311
+ }
312
+
313
+ /** Documented no-op — the recorder owns the connection's lifetime, not this borrowed handle. */
314
+ onDisconnect(_handler: () => void): void {}
315
+
316
+ /** Documented no-op — closing would tear down the leaf's OWN (nonexistent) socket, never the recorder's held connection. `motion rec --stop` is what ends the recording. */
317
+ close(): void {}
318
+ }
319
+
320
+ /** Type guard used by `../connection.ts`'s `withConnection()` to skip wiring
321
+ * `ConsoleRecorder`/`HARRecorder` (which would otherwise silently report an
322
+ * empty summary — see this file's header) when the command's client is
323
+ * actually a recorder-routed adapter underneath its `CDPClient` cast. */
324
+ export function isRecorderHeldClient(client: unknown): client is RecorderHeldClient {
325
+ return client instanceof RecorderHeldClient;
326
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Cross-caller serialization for target-scoped multi-request CDP state
3
+ * scopes (A2): focus emulation (`page exec`), the device-metrics/viewport
4
+ * override (screenshot capture), and the Accessibility domain (full AX tree
5
+ * reads). Each scope is an enable→work→restore sequence whose intermediate
6
+ * state lives on the connection. A DIRECT connection needs no serialization —
7
+ * its emulation/domain state is scoped to its own websocket session, which
8
+ * closes with the command. The recorder, however, holds ONE persistent tab
9
+ * connection whose state outlives any single command, so two concurrent
10
+ * routed callers could otherwise clear/disable one another's live state
11
+ * (the recorder bridge's `handleCdp` is strictly per-request — it provides
12
+ * no serialization of its own). When the client is the recorder-held
13
+ * adapter, the whole scope therefore runs under the owning session's private
14
+ * cross-process lock (`.<scope>-scope.lock`, the shared `acquirePrivateLock`
15
+ * authority every session lock uses).
16
+ */
17
+ import * as path from 'path';
18
+ import { acquirePrivateLock } from '../session/artifacts.js';
19
+ import { getActiveSession } from '../session-context.js';
20
+ import { captureError } from '../errors.js';
21
+ import { isRecorderHeldClient } from './recorder-client.js';
22
+
23
+ export interface ScopeSerializationDeps {
24
+ isRecorderHeldClient: (client: unknown) => boolean;
25
+ getActiveSession: typeof getActiveSession;
26
+ }
27
+
28
+ let deps: ScopeSerializationDeps = { isRecorderHeldClient, getActiveSession };
29
+
30
+ /** Swap the held-client/session seams for the CDP-stub tests. */
31
+ export function __setScopeSerializationDepsForTest(overrides: Partial<ScopeSerializationDeps>): () => void {
32
+ const previous = deps;
33
+ deps = { ...deps, ...overrides };
34
+ return () => { deps = previous; };
35
+ }
36
+
37
+ /**
38
+ * Runs `fn` — one complete state scope against `client` — serialized under
39
+ * the active session's `.<scope>-scope.lock` when the client is the
40
+ * recorder-held adapter; a direct connection runs it unlocked. `command`
41
+ * names the caller in the missing-session failure. Callers with their own
42
+ * injectable dependency seam (`page exec`) pass `overrides` so one seam
43
+ * controls the whole branch; everyone else uses this module's defaults.
44
+ */
45
+ export async function withScopeSerialization<T>(
46
+ client: unknown,
47
+ scope: string,
48
+ command: string,
49
+ fn: () => Promise<T>,
50
+ overrides?: ScopeSerializationDeps,
51
+ ): Promise<T> {
52
+ const d = overrides ?? deps;
53
+ if (!d.isRecorderHeldClient(client)) {
54
+ return fn();
55
+ }
56
+ const session = d.getActiveSession();
57
+ if (!session) {
58
+ throw captureError(
59
+ 'internal',
60
+ 'recorder_session_missing',
61
+ `${command} was routed through a recorder-held connection but no active session exists to serialize its ${scope} scope.`,
62
+ );
63
+ }
64
+ const lock = await acquirePrivateLock(path.join(session.dir, `.${scope}-scope.lock`), {
65
+ acquireTimeoutMs: 120_000,
66
+ leaseMs: 60_000,
67
+ });
68
+ try {
69
+ return await fn();
70
+ } finally {
71
+ lock.release();
72
+ }
73
+ }