@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.
- package/bin/capture +29841 -8539
- package/package.json +6 -3
- package/src/capture.ts +86 -173
- package/src/cdp/a11y.ts +113 -14
- package/src/cdp/args.ts +300 -205
- package/src/cdp/bridge/protocol.ts +93 -6
- package/src/cdp/bridge/server.ts +118 -53
- package/src/cdp/bridge/spawn.ts +53 -6
- package/src/cdp/client.ts +13 -3
- package/src/cdp/commands/bridge-serve.ts +20 -14
- package/src/cdp/commands/cdp.ts +259 -90
- package/src/cdp/commands/gate-guard.ts +43 -0
- package/src/cdp/commands/lib.ts +339 -213
- package/src/cdp/commands/measure/census.ts +76 -20
- package/src/cdp/commands/measure/check.ts +100 -26
- package/src/cdp/commands/measure/diff.ts +109 -21
- package/src/cdp/commands/measure/explain.ts +121 -19
- package/src/cdp/commands/measure/index.ts +31 -39
- package/src/cdp/commands/measure/map-ax.ts +52 -0
- package/src/cdp/commands/measure/map-focus.ts +41 -13
- package/src/cdp/commands/measure/map-layers.ts +49 -15
- package/src/cdp/commands/measure/map-scroll.ts +85 -15
- package/src/cdp/commands/measure/snap.ts +405 -25
- package/src/cdp/commands/measure/sweep.ts +217 -20
- package/src/cdp/commands/motion/index.ts +18 -17
- package/src/cdp/commands/motion/jank.ts +119 -9
- package/src/cdp/commands/motion/mask.ts +81 -12
- package/src/cdp/commands/motion/rec.ts +520 -19
- package/src/cdp/commands/motion/response.ts +65 -15
- package/src/cdp/commands/motion/timeline.ts +139 -14
- package/src/cdp/commands/page/click.ts +194 -0
- package/src/cdp/commands/page/elements.ts +154 -0
- package/src/cdp/commands/page/exec.ts +317 -0
- package/src/cdp/commands/page/index.ts +72 -0
- package/src/cdp/commands/page/navigate.ts +330 -0
- package/src/cdp/commands/page/scroll.ts +107 -0
- package/src/cdp/commands/page/shot.ts +208 -0
- package/src/cdp/commands/page/type.ts +104 -0
- package/src/cdp/commands/tab/index.ts +59 -0
- package/src/cdp/commands/tab/list.ts +167 -0
- package/src/cdp/commands/tab/network.ts +162 -0
- package/src/cdp/commands/tab/open.ts +90 -0
- package/src/cdp/commands/tab/reset.ts +160 -0
- package/src/cdp/connection.ts +368 -37
- package/src/cdp/detect.ts +42 -13
- package/src/cdp/dispatch.ts +74 -28
- package/src/cdp/har-recorder.ts +648 -115
- package/src/cdp/leaf-grammar.ts +99 -0
- package/src/cdp/measure/census.ts +205 -0
- package/src/cdp/measure/check.ts +318 -0
- package/src/cdp/measure/collectors/animation.ts +385 -0
- package/src/cdp/measure/collectors/ax.ts +278 -0
- package/src/cdp/measure/collectors/focus.ts +1041 -0
- package/src/cdp/measure/collectors/forms.ts +905 -0
- package/src/cdp/measure/collectors/geometry.ts +1038 -0
- package/src/cdp/measure/collectors/hittest.ts +835 -0
- package/src/cdp/measure/collectors/layers.ts +697 -0
- package/src/cdp/measure/collectors/media.ts +522 -0
- package/src/cdp/measure/collectors/pixels.ts +1584 -0
- package/src/cdp/measure/collectors/queries.ts +464 -0
- package/src/cdp/measure/collectors/scroll.ts +599 -0
- package/src/cdp/measure/collectors/states.ts +992 -0
- package/src/cdp/measure/collectors/style-provenance.ts +643 -0
- package/src/cdp/measure/collectors/styles.ts +355 -0
- package/src/cdp/measure/collectors/text.ts +713 -0
- package/src/cdp/measure/diff.ts +470 -0
- package/src/cdp/measure/explain.ts +498 -0
- package/src/cdp/measure/map-ax.ts +633 -0
- package/src/cdp/measure/map-focus.ts +188 -0
- package/src/cdp/measure/map-layers.ts +177 -0
- package/src/cdp/measure/map-scroll.ts +317 -0
- package/src/cdp/measure/redaction.ts +51 -0
- package/src/cdp/measure/settle.ts +1051 -0
- package/src/cdp/measure/snapshot.ts +390 -0
- package/src/cdp/measure/sweep.ts +330 -0
- package/src/cdp/measure/types.ts +220 -0
- package/src/cdp/measure/v2/collectors.ts +19 -0
- package/src/cdp/measure/v2/orchestrator.ts +46 -0
- package/src/cdp/measure/v2/snap-selection.ts +33 -0
- package/src/cdp/measure/v2/target-lock.ts +15 -0
- package/src/cdp/measure/v2/target.ts +16 -0
- package/src/cdp/motion/jank.ts +433 -0
- package/src/cdp/motion/mask.ts +421 -0
- package/src/cdp/motion/recorder.ts +764 -0
- package/src/cdp/motion/response.ts +356 -0
- package/src/cdp/motion/timeline.ts +215 -0
- package/src/cdp/record.ts +5 -212
- package/src/cdp/recorder-bridge.ts +1478 -105
- package/src/cdp/recorder-client.ts +326 -0
- package/src/cdp/scope-lock.ts +73 -0
- package/src/cdp/screenshot.ts +199 -66
- package/src/cdp/targets.ts +21 -1
- package/src/cdp/timing.ts +15 -10
- package/src/cdp/types.ts +22 -7
- package/src/cdp/viewport.ts +21 -0
- package/src/cdp.ts +12 -58
- package/src/command-help.ts +29 -0
- package/src/command-resolution.ts +22 -0
- package/src/command-walker.ts +35 -0
- package/src/contracts/a11y.ts +33 -0
- package/src/contracts/cursor.ts +35 -0
- package/src/contracts/index.ts +15 -0
- package/src/contracts/motion.ts +19 -0
- package/src/contracts/neutral.ts +56 -0
- package/src/contracts/primitives.ts +205 -0
- package/src/contracts/release.ts +17 -0
- package/src/contracts/results.ts +208 -0
- package/src/contracts/routes.ts +140 -0
- package/src/contracts/snapshot.ts +92 -0
- package/src/cursor/index.ts +13 -0
- package/src/cursor/service.ts +310 -0
- package/src/errors.ts +71 -0
- package/src/har-manager.ts +992 -41
- package/src/interact.ts +410 -98
- package/src/output/artifact-lifecycle.ts +371 -0
- package/src/output/artifact.ts +6 -0
- package/src/output/exact-raw.ts +114 -0
- package/src/output/render.ts +396 -2
- package/src/output/structured.ts +211 -0
- package/src/registry.ts +208 -0
- package/src/session/artifacts.ts +452 -200
- package/src/session/commands.ts +1162 -397
- package/src/session/coordinator.ts +251 -0
- package/src/session/har-filter.ts +57 -0
- package/src/session/log-tailer.ts +653 -0
- package/src/session-context.ts +279 -51
- package/src/vault/bundle.ts +32 -17
- package/src/cdp/commands/exec.ts +0 -105
- package/src/cdp/commands/har.ts +0 -128
- package/src/cdp/commands/tabs.ts +0 -183
- package/src/cdp/commands/traffic.ts +0 -121
- package/src/cdp/commands/ui.ts +0 -172
- package/src/cdp/execute.ts +0 -113
- package/src/cdp/har-output.ts +0 -47
- package/src/cdp/usage.ts +0 -79
- 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
|
+
}
|