@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,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
+ }
@@ -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 new Error('Use --target <tabId> or --url <pattern> to target a tab. Run "capture list" to see available tabs.');
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.target
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 new Error(
37
- `No tab found for ${parsed.target ? 'target' : 'URL pattern'} "${query}". Run "capture list" to see available tabs.`,
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 new Error('Tab has no WebSocket debugger URL');
227
+ throw captureError('precondition', 'target_unavailable', 'Tab has no WebSocket debugger URL');
43
228
  }
44
229
 
45
- // Lazy-populate targetId in active session if not yet set
46
- const activeSession = getActiveSession();
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 = new CDPClient(tab.webSocketDebuggerUrl);
57
- await client.waitReady();
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
- return { client, tab };
265
+ interface Collectors {
266
+ routed: boolean;
267
+ consoleRecorder?: ConsoleRecorder;
268
+ harRecorder?: HARRecorder;
60
269
  }
61
270
 
62
- export async function withConnection<T>(
63
- parsed: ParsedArgs,
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
- const consoleRecorder = new ConsoleRecorder(client);
70
- await consoleRecorder.start();
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 ID exists before starting recording
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
- console.error(
78
- `ERROR: No HAR recording found for --har "${parsed.har}". Run 'har create' first.`,
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
- // Wait for network activity triggered by the action, then append to HAR
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
- const har = await harRecorder.finish();
96
- if (har.log.entries.length > 0) {
97
- appendToHar(parsed.har, har.log.entries);
98
- console.error(
99
- ` [har:${parsed.har}] +${har.log.entries.length} entries`,
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
- printConsoleSummary(consoleRecorder.finish());
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
  }