@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,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `capture tab reset <url> [--port <port>]` — abandon a stuck/unresponsive
|
|
3
|
+
* tab by opening a fresh one at the URL, and repoint the active session's
|
|
4
|
+
* target at it so subsequent session-targeted commands drive the new tab.
|
|
5
|
+
*
|
|
6
|
+
* Under an active session this is a session-lifecycle transition: it admits
|
|
7
|
+
* as a session operation, runs under the session's `.lifecycle.lock`, and
|
|
8
|
+
* REFUSES while a recording is live (a recorder is bound to the old target;
|
|
9
|
+
* reset never rebinds a live recorder). Reset only clears a dangling recorder
|
|
10
|
+
* pointer; dead recorders are left for the next stop/start to finalize (their
|
|
11
|
+
* frames are preserved). The session's `{targetId, port}` pair is published
|
|
12
|
+
* atomically under the lock.
|
|
13
|
+
*
|
|
14
|
+
* Emits a `<tab-reset>` block; page-derived strings flow through `data()`
|
|
15
|
+
* (I-9), and "no active session to update" is stated as a fact rather than
|
|
16
|
+
* silently skipped (I-5).
|
|
17
|
+
*/
|
|
18
|
+
import { detectCdpPort } from '../../detect.js';
|
|
19
|
+
import { openTab } from '../../targets.js';
|
|
20
|
+
import { CDPClient } from '../../client.js';
|
|
21
|
+
import { getActiveSession, updateSessionState } from '../../../session-context.js';
|
|
22
|
+
import {
|
|
23
|
+
admitSessionOperation,
|
|
24
|
+
withSessionLifecycle,
|
|
25
|
+
scanRecorderHandles,
|
|
26
|
+
clearDanglingRecorderPointer,
|
|
27
|
+
type LifecycleSeams,
|
|
28
|
+
} from '../../../session/coordinator.js';
|
|
29
|
+
import { captureError, invalidInput } from '../../../errors.js';
|
|
30
|
+
import { type CDPTarget, type ParsedArgs } from '../../types.js';
|
|
31
|
+
import {
|
|
32
|
+
data,
|
|
33
|
+
emitResult,
|
|
34
|
+
line,
|
|
35
|
+
text,
|
|
36
|
+
type RenderableResult,
|
|
37
|
+
} from '../../../output/render.js';
|
|
38
|
+
|
|
39
|
+
const USAGE = `capture tab reset — abandon a stuck tab and open a fresh one at the URL.
|
|
40
|
+
|
|
41
|
+
input:
|
|
42
|
+
<url> required — the URL the fresh tab opens at
|
|
43
|
+
--port <port> CDP endpoint (default: the active session's port, else the auto-discovered preferred endpoint)
|
|
44
|
+
|
|
45
|
+
output: <tab-reset port=… target=…> — the fresh tab's target id and url, plus whether the active session's target was repointed at it.
|
|
46
|
+
effects: opens a new background browser tab (the old tab is left behind, not closed) and waits up to 10s for its load event. Under an active session it refuses while a recording is live (stop it first: \`capture motion rec --stop\`), clears a dangling recorder pointer (dead recorders are left for the next stop/start to finalize; their frames are preserved), and updates the session's {target, port} pair together.`;
|
|
47
|
+
|
|
48
|
+
/** Pure `<tab-reset>` result builder — exported for tests. */
|
|
49
|
+
export function buildTabResetResult(
|
|
50
|
+
tab: CDPTarget,
|
|
51
|
+
port: number,
|
|
52
|
+
sessionUpdated: boolean,
|
|
53
|
+
): RenderableResult {
|
|
54
|
+
return {
|
|
55
|
+
tag: 'tab-reset',
|
|
56
|
+
attrs: { port, target: tab.id },
|
|
57
|
+
summary: line(text`fresh tab `, data(tab.id.slice(0, 8)), text` opened at `, data(tab.url, 300), text`.`),
|
|
58
|
+
sections: [
|
|
59
|
+
sessionUpdated
|
|
60
|
+
? text`active session target updated: session-targeted commands now drive the fresh tab.`
|
|
61
|
+
: text`no active session; no session target to update.`,
|
|
62
|
+
],
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function errorMessage(err: unknown): string {
|
|
67
|
+
return err instanceof Error ? err.message : String(err);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface TabResetDeps {
|
|
71
|
+
openTab: typeof openTab;
|
|
72
|
+
detectCdpPort: typeof detectCdpPort;
|
|
73
|
+
createClient: (webSocketDebuggerUrl: string) => CDPClient;
|
|
74
|
+
lifecycle: LifecycleSeams;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
let deps: TabResetDeps = {
|
|
78
|
+
openTab,
|
|
79
|
+
detectCdpPort,
|
|
80
|
+
createClient: (webSocketDebuggerUrl) => new CDPClient(webSocketDebuggerUrl),
|
|
81
|
+
lifecycle: {},
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export function __setTabResetDepsForTest(overrides: Partial<TabResetDeps>): () => void {
|
|
85
|
+
const previous = deps;
|
|
86
|
+
deps = { ...deps, ...overrides };
|
|
87
|
+
return () => { deps = previous; };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function openTabOrThrow(port: number, url: string): Promise<CDPTarget> {
|
|
91
|
+
try {
|
|
92
|
+
return await deps.openTab(port, url);
|
|
93
|
+
} catch (err) {
|
|
94
|
+
throw captureError('world', 'open_failed', `received: \`${url}\`; opening a fresh tab on port ${port} failed: ${errorMessage(err)}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Wait (bounded, 10s) for the fresh tab's load event before repointing anything at it. */
|
|
99
|
+
async function boundedLoadWait(tab: CDPTarget): Promise<void> {
|
|
100
|
+
if (!tab.webSocketDebuggerUrl) return;
|
|
101
|
+
const client = deps.createClient(tab.webSocketDebuggerUrl);
|
|
102
|
+
try {
|
|
103
|
+
await client.waitReady();
|
|
104
|
+
await client.send('Page.enable');
|
|
105
|
+
await new Promise<void>((resolve) => {
|
|
106
|
+
const timer = setTimeout(() => resolve(), 10000);
|
|
107
|
+
client.on('Page.loadEventFired', () => {
|
|
108
|
+
clearTimeout(timer);
|
|
109
|
+
resolve();
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
} finally {
|
|
113
|
+
client.close();
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function cmdTabReset(parsed: ParsedArgs, _args: string[]): Promise<void> {
|
|
118
|
+
if (parsed.help) {
|
|
119
|
+
console.log(USAGE);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const url = parsed.positional[0];
|
|
124
|
+
if (!url) throw invalidInput('received: no URL; expected: capture tab reset <url> [--port <port>].', 'missing_argument');
|
|
125
|
+
|
|
126
|
+
const session = getActiveSession();
|
|
127
|
+
if (!session) {
|
|
128
|
+
let port: number;
|
|
129
|
+
try {
|
|
130
|
+
port = parsed.port ?? (await deps.detectCdpPort());
|
|
131
|
+
} catch {
|
|
132
|
+
throw captureError('world', 'no_cdp_endpoint', 'received: no --port, and no CDP endpoint was discovered on localhost; expected: a running CDP-enabled browser (or an explicit --port <port>).');
|
|
133
|
+
}
|
|
134
|
+
const tab = await openTabOrThrow(port, url);
|
|
135
|
+
await boundedLoadWait(tab);
|
|
136
|
+
emitResult(buildTabResetResult(tab, port, false), { json: parsed.json });
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const op = await admitSessionOperation(session.dir);
|
|
141
|
+
try {
|
|
142
|
+
const result = await withSessionLifecycle(session.dir, async () => {
|
|
143
|
+
const fresh = getActiveSession();
|
|
144
|
+
if (!fresh || fresh.dir !== session.dir) throw captureError('precondition', 'session_unavailable', 'The active capture session is no longer available.');
|
|
145
|
+
const scan = scanRecorderHandles(session.dir, deps.lifecycle);
|
|
146
|
+
if (scan.some(h => h.classification === 'unknown')) throw captureError('world', 'recorder_liveness_unknown', 'Cannot determine recorder liveness; refusing to reset.');
|
|
147
|
+
if (scan.some(h => h.classification === 'malformed')) throw captureError('precondition', 'recorder_unavailable', 'A malformed recorder handle exists on this session; resolve it before resetting.');
|
|
148
|
+
if (scan.some(h => h.classification === 'live')) throw captureError('precondition', 'recorder_active', 'A recording is active on this session; stop it first: `capture motion rec --stop`. Reset never rebinds a live recorder.');
|
|
149
|
+
await clearDanglingRecorderPointer(session.dir, scan);
|
|
150
|
+
const port = fresh.port ?? parsed.port ?? (await deps.detectCdpPort());
|
|
151
|
+
const tab = await openTabOrThrow(port, url);
|
|
152
|
+
await boundedLoadWait(tab);
|
|
153
|
+
await updateSessionState(session.dir, { targetId: tab.id, port });
|
|
154
|
+
return buildTabResetResult(tab, port, true);
|
|
155
|
+
}, deps.lifecycle);
|
|
156
|
+
emitResult(result, { json: parsed.json });
|
|
157
|
+
} finally {
|
|
158
|
+
await op.release();
|
|
159
|
+
}
|
|
160
|
+
}
|
package/src/cdp/connection.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as fs from 'fs';
|
|
2
|
+
import { performance } from 'node:perf_hooks';
|
|
2
3
|
import { CDPClient } from './client.js';
|
|
3
4
|
import { findTabByIdAcrossEndpoints, findTabByUrlAcrossEndpoints } from './targets.js';
|
|
4
5
|
import { type CDPTarget, type ParsedArgs } from './types.js';
|
|
@@ -8,7 +9,10 @@ import {
|
|
|
8
9
|
harFilePath,
|
|
9
10
|
appendToHarRecording as appendToHar,
|
|
10
11
|
} from '../har-manager.js';
|
|
11
|
-
import { getActiveSession, updateActiveSession } from '../session-context.js';
|
|
12
|
+
import { getActiveSession, updateActiveSession, type ActiveSessionState } from '../session-context.js';
|
|
13
|
+
import { RecorderHeldClient, isRecorderHeldClient } from './recorder-client.js';
|
|
14
|
+
import { recDirFor, readRecorderJson } from './motion/recorder.js';
|
|
15
|
+
import { captureError, invalidInput } from '../errors.js';
|
|
12
16
|
|
|
13
17
|
function getPortFromWebSocketDebuggerUrl(url?: string): number | null {
|
|
14
18
|
if (!url) return null;
|
|
@@ -19,91 +23,418 @@ function getPortFromWebSocketDebuggerUrl(url?: string): number | null {
|
|
|
19
23
|
}
|
|
20
24
|
}
|
|
21
25
|
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Injectable seams — the external effects `connectForCommand`/`withConnection`/
|
|
28
|
+
// `withPageAction` depend on, gathered into one module-level object so the
|
|
29
|
+
// deterministic tests (`test/connection-settle-har.test.ts`) can drive the
|
|
30
|
+
// full lifecycle against fake clocks, a spy metadata writer, and a stub CDP
|
|
31
|
+
// client without any wall-time waits or real sockets. Production uses the
|
|
32
|
+
// defaults; only tests call `__setConnectionSeamsForTest`.
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
export interface ConnectionSeams {
|
|
36
|
+
getActiveSession: () => ActiveSessionState | null;
|
|
37
|
+
updateActiveSession: (patch: Partial<ActiveSessionState>) => Promise<ActiveSessionState | null>;
|
|
38
|
+
resolveTab: (parsed: ParsedArgs) => Promise<{ port: number; tab: CDPTarget } | null>;
|
|
39
|
+
createClient: (wsUrl: string) => CDPClient;
|
|
40
|
+
appendHar: typeof appendToHar;
|
|
41
|
+
/** Recorder-routed actions only: the recorder bridge's `har-flush` health
|
|
42
|
+
* barrier (`RecorderHeldClient.flushHar()`) — resolves once every HAR
|
|
43
|
+
* entry/body/append the recorder's streaming collector had completed at
|
|
44
|
+
* call time is durably in the owning session's live HAR; throws on a
|
|
45
|
+
* latched fatal store failure. Never invoked for a non-recorder action. */
|
|
46
|
+
flushRecorderHar: (client: RecorderHeldClient) => Promise<void>;
|
|
47
|
+
/** Monotonic clock (ms) — measured settle facts derive from it, never from wall time. */
|
|
48
|
+
now: () => number;
|
|
49
|
+
sleep: (ms: number) => Promise<void>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function defaultResolveTab(parsed: ParsedArgs): Promise<{ port: number; tab: CDPTarget } | null> {
|
|
53
|
+
return parsed.target
|
|
54
|
+
? findTabByIdAcrossEndpoints(parsed.target, parsed.port)
|
|
55
|
+
: findTabByUrlAcrossEndpoints(parsed.url!, parsed.port);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
let seams: ConnectionSeams = {
|
|
59
|
+
getActiveSession: () => getActiveSession(),
|
|
60
|
+
updateActiveSession,
|
|
61
|
+
resolveTab: defaultResolveTab,
|
|
62
|
+
createClient: (wsUrl) => new CDPClient(wsUrl),
|
|
63
|
+
appendHar: appendToHar,
|
|
64
|
+
flushRecorderHar: (client) => client.flushHar(),
|
|
65
|
+
now: () => performance.now(),
|
|
66
|
+
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export function __setConnectionSeamsForTest(overrides: Partial<ConnectionSeams>): () => void {
|
|
70
|
+
const previous = seams;
|
|
71
|
+
seams = { ...seams, ...overrides };
|
|
72
|
+
return () => { seams = previous; };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* CDP network emulation belongs to a connection. Reissue the active
|
|
77
|
+
* session's requested state whenever a command opens a fresh connection to
|
|
78
|
+
* that session's target so `network offline` remains true until `online`.
|
|
79
|
+
*/
|
|
80
|
+
export async function applyActiveSessionNetworkConditions(
|
|
81
|
+
client: Pick<CDPClient, 'send'>,
|
|
82
|
+
session: ActiveSessionState | null,
|
|
83
|
+
targetId: string,
|
|
84
|
+
): Promise<void> {
|
|
85
|
+
if (!session || session.targetId !== targetId || session.networkOffline === undefined) return;
|
|
86
|
+
const offline = session.networkOffline;
|
|
87
|
+
await client.send('Network.enable');
|
|
88
|
+
await client.send('Network.emulateNetworkConditions', {
|
|
89
|
+
offline,
|
|
90
|
+
latency: offline ? -1 : 0,
|
|
91
|
+
downloadThroughput: offline ? 0 : -1,
|
|
92
|
+
uploadThroughput: offline ? 0 : -1,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Builds a short, human-readable label for this command invocation, used to
|
|
98
|
+
* tag every marked (`Input.dispatch*`) CDP call the recorder-held adapter
|
|
99
|
+
* makes on its behalf — e.g. `click:Send`, `type:another message`,
|
|
100
|
+
* `navigate:https://...` — matching the design's "labeled input landmark"
|
|
101
|
+
* shape in `events.jsonl`. Derived generically from the command name plus
|
|
102
|
+
* its primary target rather than per-leaf, so this stays a `connection.ts`
|
|
103
|
+
* concern instead of every intervening command having to supply one.
|
|
104
|
+
*/
|
|
105
|
+
function deriveActionLabel(parsed: ParsedArgs): string {
|
|
106
|
+
// The label names the action's TARGET. For `type` the target is the
|
|
107
|
+
// `--into` field (or 'focused element'), not the typed text — the payload
|
|
108
|
+
// is preserved in the command's own result and evidence, while the label
|
|
109
|
+
// lands in events.jsonl as the input-landmark record identifying where
|
|
110
|
+
// input landed (it never touches the page — see `../timing.ts`'s
|
|
111
|
+
// `withDocumentPerformanceNow`).
|
|
112
|
+
if (parsed.command === 'type') {
|
|
113
|
+
return `type:${parsed.into ?? 'focused element'}`;
|
|
114
|
+
}
|
|
115
|
+
const target = parsed.positional[0] ?? parsed.into ?? parsed.url ?? '';
|
|
116
|
+
return target ? `${parsed.command}:${target}` : parsed.command;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Does this command MUST route through the session's active composed
|
|
121
|
+
* recording (`motion rec --start` ... `--stop`)? True when the session has a
|
|
122
|
+
* live recording AND the caller did not explicitly divert to a parallel tab
|
|
123
|
+
* (`--url`) or a distinct target (`--target` naming a tab other than the
|
|
124
|
+
* recorder's own). When true, `connectToActiveRecorder` is authoritative and
|
|
125
|
+
* NEVER falls back to a fresh direct connection — a missing/malformed/
|
|
126
|
+
* wrong-state recorder handle is a structured `recorder_unavailable`, not a
|
|
127
|
+
* silent direct-CDP substitution (A2).
|
|
128
|
+
*/
|
|
129
|
+
function shouldRouteToRecorder(session: ActiveSessionState, parsed: ParsedArgs): boolean {
|
|
130
|
+
if (!session.activeRecId) return false;
|
|
131
|
+
if (parsed.url) return false;
|
|
132
|
+
if (parsed.target && parsed.target !== session.targetId) return false;
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Connects a must-route command through the ACTIVE recording's held socket.
|
|
138
|
+
* The caller (`connectForCommand`) has already decided this command must
|
|
139
|
+
* route (`shouldRouteToRecorder`), so any failure to obtain a usable
|
|
140
|
+
* recorder handle is surfaced as `recorder_unavailable` and the command is
|
|
141
|
+
* refused — it is NEVER answered by a direct CDP connection that would
|
|
142
|
+
* silently escape the recording.
|
|
143
|
+
*/
|
|
144
|
+
function connectToActiveRecorder(
|
|
145
|
+
session: ActiveSessionState,
|
|
146
|
+
parsed: ParsedArgs,
|
|
147
|
+
): { client: CDPClient; tab: CDPTarget } {
|
|
148
|
+
const recId = session.activeRecId!;
|
|
149
|
+
const recDir = recDirFor(session.dir, recId);
|
|
150
|
+
const rj = readRecorderJson(recDir);
|
|
151
|
+
|
|
152
|
+
if (!rj) {
|
|
153
|
+
throw captureError(
|
|
154
|
+
'precondition',
|
|
155
|
+
'recorder_unavailable',
|
|
156
|
+
`The active session claims recording "${recId}" but its live-recorder handle (recorder.json) is missing — it was already finalized or reaped. Recover with: capture motion rec --stop.`,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
if (
|
|
160
|
+
typeof rj.socketPath !== 'string' || rj.socketPath.length === 0
|
|
161
|
+
|| typeof rj.targetId !== 'string' || rj.targetId.length === 0
|
|
162
|
+
|| typeof rj.nonce !== 'string' || !/^[0-9a-f]{64}$/.test(rj.nonce)
|
|
163
|
+
) {
|
|
164
|
+
throw captureError(
|
|
165
|
+
'precondition',
|
|
166
|
+
'recorder_unavailable',
|
|
167
|
+
`The active session's recorder handle for "${recId}" is malformed (missing or invalid socketPath/targetId/nonce). Recover with: capture motion rec --stop.`,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
if (rj.state !== 'recording') {
|
|
171
|
+
throw captureError(
|
|
172
|
+
'precondition',
|
|
173
|
+
'recorder_unavailable',
|
|
174
|
+
`The active session's recorder "${recId}" is not currently recording (state: ${rj.state}) — it is finalized or mid-teardown. Recover with: capture motion rec --stop.`,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const actionLabel = deriveActionLabel(parsed);
|
|
179
|
+
const client = new RecorderHeldClient({ socketPath: rj.socketPath, nonce: rj.nonce, actionLabel });
|
|
180
|
+
const tab: CDPTarget = {
|
|
181
|
+
id: rj.targetId,
|
|
182
|
+
title: '',
|
|
183
|
+
url: rj.url ?? '',
|
|
184
|
+
type: 'page',
|
|
185
|
+
webSocketDebuggerUrl: undefined,
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
console.error(`Routing via active recorder ${recId} (recorder-held tab connection, action "${actionLabel}")`);
|
|
189
|
+
|
|
190
|
+
// Documented cast — RecorderHeldClient only ever needs to satisfy the
|
|
191
|
+
// structural send/on/onDisconnect/close surface every command leaf calls;
|
|
192
|
+
// see recorder-client.ts's own header for why this mirrors
|
|
193
|
+
// recorder-bridge.ts's `asCDPClient()`.
|
|
194
|
+
return { client: client as unknown as CDPClient, tab };
|
|
195
|
+
}
|
|
196
|
+
|
|
22
197
|
export async function connectForCommand(
|
|
23
198
|
parsed: ParsedArgs,
|
|
24
199
|
): Promise<{ client: CDPClient; tab: CDPTarget }> {
|
|
200
|
+
const activeSession = seams.getActiveSession();
|
|
201
|
+
if (activeSession && shouldRouteToRecorder(activeSession, parsed)) {
|
|
202
|
+
const routed = connectToActiveRecorder(activeSession, parsed);
|
|
203
|
+
// The recorder bridge owns its own persistent target connection, so
|
|
204
|
+
// the persisted offline/online state must be reissued here too — the
|
|
205
|
+
// ephemeral connection that ran `network offline` has since closed.
|
|
206
|
+
await applyActiveSessionNetworkConditions(routed.client, activeSession, routed.tab.id);
|
|
207
|
+
return routed;
|
|
208
|
+
}
|
|
209
|
+
|
|
25
210
|
if (!parsed.target && !parsed.url) {
|
|
26
|
-
throw
|
|
211
|
+
throw invalidInput('Use --target <tabId> or --url <pattern> to target a tab. Run "capture tab list" to see available tabs.', 'missing_target');
|
|
27
212
|
}
|
|
28
213
|
|
|
29
|
-
const resolved = parsed
|
|
30
|
-
? await findTabByIdAcrossEndpoints(parsed.target, parsed.port)
|
|
31
|
-
: await findTabByUrlAcrossEndpoints(parsed.url!, parsed.port);
|
|
214
|
+
const resolved = await seams.resolveTab(parsed);
|
|
32
215
|
const tab = resolved?.tab ?? null;
|
|
33
216
|
|
|
34
217
|
if (!tab) {
|
|
35
218
|
const query = parsed.target ?? parsed.url;
|
|
36
|
-
throw
|
|
37
|
-
|
|
219
|
+
throw captureError(
|
|
220
|
+
'precondition',
|
|
221
|
+
'target_unavailable',
|
|
222
|
+
`No tab found for ${parsed.target ? 'target' : 'URL pattern'} "${query}". Run "capture tab list" to see available tabs.`,
|
|
38
223
|
);
|
|
39
224
|
}
|
|
40
225
|
|
|
41
226
|
if (!tab.webSocketDebuggerUrl) {
|
|
42
|
-
throw
|
|
227
|
+
throw captureError('precondition', 'target_unavailable', 'Tab has no WebSocket debugger URL');
|
|
43
228
|
}
|
|
44
229
|
|
|
45
|
-
//
|
|
46
|
-
|
|
230
|
+
// Derive the endpoint port once, from the authoritative resolution result
|
|
231
|
+
// (falling back to the debugger URL) — never from ambient env, which
|
|
232
|
+
// endpoint precedence already resolved before this leaf ran.
|
|
233
|
+
const port = resolved?.port ?? getPortFromWebSocketDebuggerUrl(tab.webSocketDebuggerUrl);
|
|
234
|
+
|
|
235
|
+
// Lazy target establishment: publish `{targetId, port}` as one atomic pair
|
|
236
|
+
// through U03's metadata helper so a subsequent command finds both together.
|
|
47
237
|
if (activeSession && !activeSession.targetId) {
|
|
48
|
-
updateActiveSession({ targetId: tab.id });
|
|
238
|
+
await seams.updateActiveSession({ targetId: tab.id, port: port ?? null });
|
|
49
239
|
}
|
|
50
240
|
|
|
51
|
-
const port = resolved?.port ?? getPortFromWebSocketDebuggerUrl(tab.webSocketDebuggerUrl);
|
|
52
241
|
console.error(
|
|
53
242
|
`Using target ${tab.id.slice(0, 8)}${port ? ` on port ${port}` : ''}${tab.url ? ` (${tab.url})` : ''}`,
|
|
54
243
|
);
|
|
55
244
|
|
|
56
|
-
const client =
|
|
57
|
-
|
|
245
|
+
const client = seams.createClient(tab.webSocketDebuggerUrl);
|
|
246
|
+
try {
|
|
247
|
+
await client.waitReady();
|
|
248
|
+
// Reissue the session's persisted offline/online state on this fresh
|
|
249
|
+
// connection — network emulation belongs to a connection, not the tab.
|
|
250
|
+
await applyActiveSessionNetworkConditions(client, seams.getActiveSession(), tab.id);
|
|
251
|
+
return { client, tab };
|
|
252
|
+
} catch (err) {
|
|
253
|
+
client.close();
|
|
254
|
+
throw err;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ---------------------------------------------------------------------------
|
|
259
|
+
// Shared collector plumbing — the console/local-HAR capture both the
|
|
260
|
+
// observational wrapper (`withConnection`) and the action wrapper
|
|
261
|
+
// (`withPageAction`) start and finish. Extracted so there is exactly one
|
|
262
|
+
// implementation of each concern (no duplicate wrapper logic).
|
|
263
|
+
// ---------------------------------------------------------------------------
|
|
58
264
|
|
|
59
|
-
|
|
265
|
+
interface Collectors {
|
|
266
|
+
routed: boolean;
|
|
267
|
+
consoleRecorder?: ConsoleRecorder;
|
|
268
|
+
harRecorder?: HARRecorder;
|
|
60
269
|
}
|
|
61
270
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
fn: (client: CDPClient, tab: CDPTarget) => Promise<T>,
|
|
65
|
-
opts: { settle?: number } = {},
|
|
66
|
-
): Promise<T> {
|
|
67
|
-
const { client, tab } = await connectForCommand(parsed);
|
|
271
|
+
async function startCollectors(parsed: ParsedArgs, client: CDPClient): Promise<Collectors> {
|
|
272
|
+
const routed = isRecorderHeldClient(client);
|
|
68
273
|
|
|
69
|
-
|
|
70
|
-
|
|
274
|
+
let consoleRecorder: ConsoleRecorder | undefined;
|
|
275
|
+
if (!routed) {
|
|
276
|
+
consoleRecorder = new ConsoleRecorder(client);
|
|
277
|
+
await consoleRecorder.start();
|
|
278
|
+
}
|
|
71
279
|
|
|
72
280
|
let harRecorder: HARRecorder | undefined;
|
|
73
|
-
if (parsed.har) {
|
|
74
|
-
// Validate HAR
|
|
281
|
+
if (parsed.har && !routed) {
|
|
282
|
+
// Validate the local HAR recording file exists before starting capture —
|
|
283
|
+
// a missing file is a structured precondition failure the root boundary
|
|
284
|
+
// renders, never a process-wide exit.
|
|
75
285
|
const harPath = harFilePath(parsed.har);
|
|
76
286
|
if (!fs.existsSync(harPath)) {
|
|
77
|
-
|
|
78
|
-
|
|
287
|
+
throw captureError(
|
|
288
|
+
'precondition',
|
|
289
|
+
'har_missing',
|
|
290
|
+
`The active session's HAR recording file ("${parsed.har}") is missing on disk — traffic cannot be appended.`,
|
|
79
291
|
);
|
|
80
|
-
process.exit(1);
|
|
81
292
|
}
|
|
82
293
|
harRecorder = new HARRecorder(client);
|
|
83
294
|
await harRecorder.start();
|
|
84
295
|
}
|
|
85
296
|
|
|
297
|
+
if (routed) {
|
|
298
|
+
console.error(
|
|
299
|
+
' [recorder] local console/HAR collectors skipped while routed — traffic streams into the session HAR via the recording\'s held connection.',
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return { routed, consoleRecorder, harRecorder };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Finishes and appends this action's own local HAR, when it owns one. */
|
|
307
|
+
async function finishLocalHar(parsed: ParsedArgs, collectors: Collectors): Promise<void> {
|
|
308
|
+
if (!collectors.harRecorder || !parsed.har) return;
|
|
309
|
+
const har = await collectors.harRecorder.finish();
|
|
310
|
+
const batch = { entries: har.log.entries, incompleteLifecycles: har.incompleteLifecycles };
|
|
311
|
+
if (batch.entries.length > 0 || batch.incompleteLifecycles.length > 0) {
|
|
312
|
+
await seams.appendHar(parsed.har, batch);
|
|
313
|
+
console.error(
|
|
314
|
+
` [har:${parsed.har}] +${batch.entries.length} entries +${batch.incompleteLifecycles.length} incomplete`,
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function finishConsole(collectors: Collectors): void {
|
|
320
|
+
if (collectors.consoleRecorder) {
|
|
321
|
+
printConsoleSummary(collectors.consoleRecorder.finish());
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Observational connection wrapper — used by the read-only commands
|
|
327
|
+
* (`page elements`/`page shot`, `measure snap`/`sweep`, `tab network`). These
|
|
328
|
+
* never settle on their own account (each passes `{ settle: 0 }`); the only
|
|
329
|
+
* network wait here is the HAR-branch drain when a caller explicitly holds a
|
|
330
|
+
* local HAR recording open across the observation.
|
|
331
|
+
*/
|
|
332
|
+
export async function withConnection<T>(
|
|
333
|
+
parsed: ParsedArgs,
|
|
334
|
+
fn: (client: CDPClient, tab: CDPTarget) => Promise<T>,
|
|
335
|
+
opts: { settle?: number } = {},
|
|
336
|
+
): Promise<T> {
|
|
337
|
+
const { client, tab } = await connectForCommand(parsed);
|
|
338
|
+
const collectors = await startCollectors(parsed, client);
|
|
339
|
+
|
|
86
340
|
try {
|
|
87
341
|
const result = await fn(client, tab);
|
|
88
342
|
|
|
89
|
-
|
|
90
|
-
if (harRecorder && parsed.har) {
|
|
343
|
+
if (collectors.harRecorder && parsed.har) {
|
|
91
344
|
const settle = opts.settle !== undefined ? opts.settle : 3000;
|
|
92
345
|
if (settle > 0) {
|
|
93
346
|
await new Promise((r) => setTimeout(r, settle));
|
|
94
347
|
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
348
|
+
await finishLocalHar(parsed, collectors);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
finishConsole(collectors);
|
|
352
|
+
return result;
|
|
353
|
+
} finally {
|
|
354
|
+
client.close();
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// ---------------------------------------------------------------------------
|
|
359
|
+
// Action lifecycle wrapper — the ONE settle/HAR lifecycle, used solely by the
|
|
360
|
+
// four mutating page verbs (`click`, `type`, `scroll`, `exec`).
|
|
361
|
+
// ---------------------------------------------------------------------------
|
|
362
|
+
|
|
363
|
+
/** Measured settle provenance returned to the leaf — requested is what the
|
|
364
|
+
* caller asked for, waited is what the injected monotonic clock actually
|
|
365
|
+
* observed elapsing, completed marks that the settle ran to completion. */
|
|
366
|
+
export interface SettleFacts {
|
|
367
|
+
requestedMs: number;
|
|
368
|
+
waitedMs: number;
|
|
369
|
+
completed: boolean;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Runs one mutating page action with its settle/HAR lifecycle. Success
|
|
374
|
+
* ordering is exact and tested: connect → start collectors → action callback
|
|
375
|
+
* → unconditional injected settle → local HAR finish/append (when this action
|
|
376
|
+
* owns local HAR) → console summary → return.
|
|
377
|
+
*
|
|
378
|
+
* The settle is measured, not echoed: `requestedMs` is the caller's window,
|
|
379
|
+
* `waitedMs` is what the injected `now()`/`sleep()` observed. A callback
|
|
380
|
+
* rejection propagates WITHOUT any settle, HAR finish, flush, or settle claim
|
|
381
|
+
* — the action failed, so there is no settle to report. Zero settle skips the
|
|
382
|
+
* sleep but still reports a measured (`0`) wait, keeping the fact ordered and
|
|
383
|
+
* truthful.
|
|
384
|
+
*
|
|
385
|
+
* Recorder-routed actions add one step after the settle: the recorder
|
|
386
|
+
* bridge's non-attributing `har-flush` health barrier, awaited BEFORE success
|
|
387
|
+
* output so traffic completed by action+settle time is durably in the session
|
|
388
|
+
* HAR when the command returns. A flush failure fails the command with the
|
|
389
|
+
* distinct `recorder_har_flush_failed` code — the page action itself had
|
|
390
|
+
* already succeeded, and the error says so, preserving the primary-action-
|
|
391
|
+
* failure vs fatal-HAR-failure distinction.
|
|
392
|
+
*/
|
|
393
|
+
export async function withPageAction<T>(
|
|
394
|
+
parsed: ParsedArgs,
|
|
395
|
+
opts: { settleMs: number },
|
|
396
|
+
fn: (client: CDPClient, tab: CDPTarget) => Promise<T>,
|
|
397
|
+
): Promise<{ result: T; settle: SettleFacts }> {
|
|
398
|
+
const { client, tab } = await connectForCommand(parsed);
|
|
399
|
+
const collectors = await startCollectors(parsed, client);
|
|
400
|
+
|
|
401
|
+
try {
|
|
402
|
+
// Action callback first — a rejection here claims no settle.
|
|
403
|
+
const result = await fn(client, tab);
|
|
404
|
+
|
|
405
|
+
// Unconditional, measured settle.
|
|
406
|
+
const requestedMs = opts.settleMs;
|
|
407
|
+
const t0 = seams.now();
|
|
408
|
+
if (requestedMs > 0) {
|
|
409
|
+
await seams.sleep(requestedMs);
|
|
410
|
+
}
|
|
411
|
+
const waitedMs = seams.now() - t0;
|
|
412
|
+
const settle: SettleFacts = { requestedMs, waitedMs, completed: true };
|
|
413
|
+
|
|
414
|
+
// Recorder-routed action: await the recorder's har-flush health barrier
|
|
415
|
+
// AFTER the settle window, so its traffic is durably in the session HAR
|
|
416
|
+
// before this command claims success. Non-recorder actions never send
|
|
417
|
+
// this request.
|
|
418
|
+
if (collectors.routed) {
|
|
419
|
+
try {
|
|
420
|
+
await seams.flushRecorderHar(client as unknown as RecorderHeldClient);
|
|
421
|
+
} catch (err) {
|
|
422
|
+
throw captureError(
|
|
423
|
+
'artifact',
|
|
424
|
+
'recorder_har_flush_failed',
|
|
425
|
+
`The page action completed, but the active recording's HAR flush failed — completed network evidence for this action is not verifiably appended to the session HAR: ${err instanceof Error ? err.message : String(err)}`,
|
|
426
|
+
err,
|
|
100
427
|
);
|
|
101
428
|
}
|
|
102
429
|
}
|
|
103
430
|
|
|
104
|
-
|
|
431
|
+
// Local HAR drain happens AFTER settle so the settle window's traffic is
|
|
432
|
+
// captured (only when this action owns a local HAR recording; a routed
|
|
433
|
+
// action owns none — its traffic lands in the recording's own stream).
|
|
434
|
+
await finishLocalHar(parsed, collectors);
|
|
435
|
+
finishConsole(collectors);
|
|
105
436
|
|
|
106
|
-
return result;
|
|
437
|
+
return { result, settle };
|
|
107
438
|
} finally {
|
|
108
439
|
client.close();
|
|
109
440
|
}
|