@crouton-kit/capture 1.4.2 → 1.4.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 (110) hide show
  1. package/README.md +178 -0
  2. package/bin/capture +31278 -7312
  3. package/package.json +3 -1
  4. package/src/capture.ts +21 -18
  5. package/src/cdp/args.ts +269 -49
  6. package/src/cdp/bridge/server.ts +6 -5
  7. package/src/cdp/bridge/spawn.ts +41 -97
  8. package/src/cdp/commands/bridge-serve.ts +11 -21
  9. package/src/cdp/commands/cdp.ts +44 -22
  10. package/src/cdp/commands/heap/census.ts +61 -0
  11. package/src/cdp/commands/heap/common.ts +77 -0
  12. package/src/cdp/commands/heap/diff.ts +92 -0
  13. package/src/cdp/commands/heap/index.ts +38 -0
  14. package/src/cdp/commands/heap/objects.ts +42 -0
  15. package/src/cdp/commands/heap/retainers.ts +52 -0
  16. package/src/cdp/commands/heap/snapshot.ts +94 -0
  17. package/src/cdp/commands/lib.ts +9 -17
  18. package/src/cdp/commands/lighthouse.ts +414 -0
  19. package/src/cdp/commands/measure/census.ts +2 -1
  20. package/src/cdp/commands/measure/check.ts +70 -16
  21. package/src/cdp/commands/measure/diff.ts +1 -2
  22. package/src/cdp/commands/measure/explain.ts +44 -7
  23. package/src/cdp/commands/measure/index.ts +24 -32
  24. package/src/cdp/commands/measure/map-ax.ts +2 -1
  25. package/src/cdp/commands/measure/map-focus.ts +2 -1
  26. package/src/cdp/commands/measure/map-layers.ts +5 -3
  27. package/src/cdp/commands/measure/map-scroll.ts +2 -1
  28. package/src/cdp/commands/measure/snap.ts +5 -4
  29. package/src/cdp/commands/measure/sweep.ts +16 -11
  30. package/src/cdp/commands/measure/text.ts +147 -0
  31. package/src/cdp/commands/motion/index.ts +10 -18
  32. package/src/cdp/commands/motion/jank.ts +13 -22
  33. package/src/cdp/commands/motion/mask.ts +10 -9
  34. package/src/cdp/commands/motion/rec.ts +228 -26
  35. package/src/cdp/commands/motion/timeline.ts +1 -1
  36. package/src/cdp/commands/page/click.ts +15 -5
  37. package/src/cdp/commands/page/elements.ts +43 -11
  38. package/src/cdp/commands/page/exec.ts +53 -18
  39. package/src/cdp/commands/page/index.ts +20 -20
  40. package/src/cdp/commands/page/inspect.ts +338 -0
  41. package/src/cdp/commands/page/navigate.ts +83 -38
  42. package/src/cdp/commands/page/repeat.ts +176 -0
  43. package/src/cdp/commands/page/scroll.ts +10 -5
  44. package/src/cdp/commands/page/shot.ts +66 -31
  45. package/src/cdp/commands/page/type.ts +6 -2
  46. package/src/cdp/commands/perf/index.ts +32 -0
  47. package/src/cdp/commands/perf/insights.ts +214 -0
  48. package/src/cdp/commands/perf/trace.ts +235 -0
  49. package/src/cdp/commands/perf/vitals.ts +76 -0
  50. package/src/cdp/commands/session/collectors.ts +54 -0
  51. package/src/cdp/commands/stub.ts +10 -0
  52. package/src/cdp/commands/tab/close.ts +2 -2
  53. package/src/cdp/commands/tab/index.ts +16 -23
  54. package/src/cdp/commands/tab/launch.ts +1 -1
  55. package/src/cdp/commands/tab/mock/index.ts +24 -0
  56. package/src/cdp/commands/tab/mock/lifecycle.ts +88 -0
  57. package/src/cdp/commands/tab/mock/start.ts +65 -0
  58. package/src/cdp/commands/tab/mock/stop.ts +39 -0
  59. package/src/cdp/commands/tab/network.ts +22 -13
  60. package/src/cdp/commands/tab/reset.ts +11 -12
  61. package/src/cdp/connection.ts +19 -39
  62. package/src/cdp/detect.ts +27 -7
  63. package/src/cdp/dispatch.ts +25 -4
  64. package/src/cdp/domains.ts +2 -2
  65. package/src/cdp/har-recorder.ts +0 -1
  66. package/src/cdp/heap-snapshot.ts +647 -0
  67. package/src/cdp/host/client.ts +22 -0
  68. package/src/cdp/host/collector.ts +74 -0
  69. package/src/cdp/host/collectors/heap.ts +79 -0
  70. package/src/cdp/host/collectors/intercept.ts +247 -0
  71. package/src/cdp/{recorder-bridge.ts → host/collectors/motion.ts} +388 -473
  72. package/src/cdp/host/collectors/trace.ts +108 -0
  73. package/src/cdp/host/core.ts +217 -0
  74. package/src/cdp/host/handle.ts +47 -0
  75. package/src/cdp/host/held-client.ts +91 -0
  76. package/src/cdp/host/kinds.ts +97 -0
  77. package/src/cdp/host/lifecycle.ts +123 -0
  78. package/src/cdp/host/server.ts +88 -0
  79. package/src/cdp/measure/check.ts +181 -16
  80. package/src/cdp/measure/collectors/hittest.ts +16 -10
  81. package/src/cdp/measure/collectors/scroll.ts +15 -7
  82. package/src/cdp/measure/collectors/style-provenance.ts +11 -0
  83. package/src/cdp/measure/collectors/styles.ts +4 -4
  84. package/src/cdp/measure/explain.ts +14 -2
  85. package/src/cdp/measure/map-layers.ts +52 -8
  86. package/src/cdp/measure/snapshot.ts +5 -3
  87. package/src/cdp/measure/sweep.ts +29 -6
  88. package/src/cdp/measure/text.ts +227 -0
  89. package/src/cdp/motion/jank.ts +29 -83
  90. package/src/cdp/motion/mask.ts +18 -9
  91. package/src/cdp/motion/recorder.ts +86 -731
  92. package/src/cdp/scope-lock.ts +9 -9
  93. package/src/cdp/screenshot.ts +51 -21
  94. package/src/cdp/timing.ts +5 -5
  95. package/src/cdp/trace-analysis.ts +320 -0
  96. package/src/cdp/types.ts +31 -1
  97. package/src/cdp.ts +4 -6
  98. package/src/har-manager.ts +2 -4
  99. package/src/interact.ts +50 -23
  100. package/src/output/artifact.ts +49 -5
  101. package/src/output/render.ts +60 -9
  102. package/src/output/selection.ts +6 -0
  103. package/src/session/artifacts.ts +122 -12
  104. package/src/session/commands.ts +348 -113
  105. package/src/session/coordinator.ts +0 -93
  106. package/src/session/har-redact.ts +29 -23
  107. package/src/session-context.ts +13 -9
  108. package/src/cdp/bridge/protocol.ts +0 -224
  109. package/src/cdp/recorder-client.ts +0 -326
  110. package/src/cdp/tab-lock.ts +0 -132
@@ -0,0 +1,235 @@
1
+ import * as crypto from 'node:crypto';
2
+ import * as path from 'node:path';
3
+ import { type ParsedArgs } from '../../types.js';
4
+ import { detectCdpPort } from '../../detect.js';
5
+ import { openTab } from '../../targets.js';
6
+ import { collectorHostSocketPath, startCollectorHost } from '../../bridge/spawn.js';
7
+ import { sendHostRequest, type HostResponse } from '../../host/client.js';
8
+ import { scanCollectorHost, type CollectorHostHandle } from '../../host/handle.js';
9
+ import { stopAndReapCollectorHostAtSessionStop } from '../../host/lifecycle.js';
10
+ import { resolveTraceRef } from '../../../output/artifact.js';
11
+ import { emitResult, fact, formatArtifactList, type RenderableResult } from '../../../output/render.js';
12
+ import { CAPTURE_ROOT, ensurePrivateDir, readPrivateFile, registerArtifactRoot } from '../../../session/artifacts.js';
13
+ import { getActiveSession } from '../../../session-context.js';
14
+ import { withSessionLifecycle } from '../../../session/coordinator.js';
15
+ import { driveOneShotAction } from '../motion/rec.js';
16
+
17
+ const HELP = `capture perf trace [url] [--do <action>] [--duration <seconds>] | --start | --stop — record a Chrome performance trace
18
+
19
+ input:
20
+ [url] navigate to this URL and trace the load until it settles; without a URL the active session tab is traced in place (mutually exclusive with --start/--stop)
21
+ --do <action> trace across one action on the current page (same action grammar as \`motion rec --do\`), for an interaction trace INP can be read from
22
+ --duration <seconds> stop tracing this long after the action; default 3
23
+ --artifact-dir <path> root for a sessionless one-shot trace bundle
24
+ --start open a trace window and return; requires an active session, and the trace stays live across intervening commands until --stop
25
+ --stop close the live trace window and finalize its artifact; the session's one live trace is selected without an id. If a one-shot trace process is interrupted after it starts, the session collector host keeps this trace live; use --stop to finalize it (or session collectors to read its id/state)
26
+ output: <trace …> — the finalized trace artifact, its recorded window, event count, and completion state; --json mirrors
27
+ effects: drives the browser and records; a sessionless trace writes one bundle under --artifact-dir, then spawns or joins the session's collector host, which holds the tab connection until the trace is stopped. Claims \`tracing\`, which is browser-global: refused while \`motion rec\` or another trace is live anywhere in the browser, and the refusal names the claim and the collector holding it.`;
28
+
29
+ interface TraceSummary { events: number; windowMs: number; navigations: number; categories: string; }
30
+ interface TraceArtifact { id: string; dir: string; completion: string; reason?: string; summary: TraceSummary; }
31
+
32
+ function commandError(parsed: ParsedArgs, status: string, message: string): void {
33
+ const held = /^Claim "([^"]+)" is held by (collector|reservation) (.+)\.$/.exec(message);
34
+ const result: RenderableResult = held
35
+ ? {
36
+ tag: 'error',
37
+ attrs: { command: 'perf trace', code: 'claim_held', claim: held[1], holder: `${held[2]} ${held[3]}` },
38
+ summary: fact`received: perf trace\nexpected: no live collector holding the ${held[1]} claim\nholder: ${held[2]} ${held[3]}`,
39
+ followUp: fact`Run \`capture session collectors\` to see the live roster, stop the holder, then re-issue.`,
40
+ }
41
+ : { tag: 'error', attrs: { command: 'perf trace', status }, summary: fact`${message}` };
42
+ emitResult(result, { json: parsed.json });
43
+ process.exitCode = 1;
44
+ }
45
+
46
+ function summaryFromMeta(dir: string, id: string): TraceArtifact {
47
+ const resolved = resolveTraceRef(dir);
48
+ const meta = JSON.parse(readPrivateFile(path.join(dir, 'meta.json')).toString('utf8')) as Record<string, unknown>;
49
+ const summary = (meta.summary && typeof meta.summary === 'object' ? meta.summary : meta) as Record<string, unknown>;
50
+ return {
51
+ id,
52
+ dir,
53
+ completion: resolved.completion,
54
+ ...(resolved.reason ? { reason: resolved.reason } : {}),
55
+ summary: {
56
+ events: typeof summary.events === 'number' ? summary.events : 0,
57
+ windowMs: typeof summary.windowMs === 'number' ? summary.windowMs : 0,
58
+ navigations: typeof summary.navigations === 'number' ? summary.navigations : 0,
59
+ categories: typeof summary.categories === 'string' ? summary.categories : 'devtools-default',
60
+ },
61
+ };
62
+ }
63
+
64
+ function emitTrace(parsed: ParsedArgs, artifact: TraceArtifact): void {
65
+ const result: RenderableResult = {
66
+ tag: 'trace',
67
+ attestation: { kind: 'trace', id: artifact.id, path: artifact.dir },
68
+ attrs: {
69
+ completion: artifact.completion,
70
+ events: artifact.summary.events,
71
+ 'window-ms': artifact.summary.windowMs,
72
+ navigations: artifact.summary.navigations,
73
+ categories: artifact.summary.categories,
74
+ ...(artifact.reason ? { reason: artifact.reason } : {}),
75
+ },
76
+ summary: fact`Trace events are Chrome's own Tracing output at the DevTools default category set. The recording window is bounded by this command's start and stop and contains nothing from before it.`,
77
+ artifacts: formatArtifactList([{ name: 'trace.json' }]),
78
+ ...(artifact.reason ? { sections: [fact`Completion reason: ${artifact.reason}`] } : {}),
79
+ };
80
+ emitResult(result, { json: parsed.json });
81
+ }
82
+
83
+ async function ensureHost(sessionDir: string, port: number, targetId: string): Promise<CollectorHostHandle> {
84
+ let scanned = scanCollectorHost(sessionDir);
85
+ if (scanned.classification === 'unknown' || scanned.classification === 'malformed') throw new Error(`collector host is ${scanned.classification}`);
86
+ if (scanned.classification === 'dead') {
87
+ const reaped = await stopAndReapCollectorHostAtSessionStop(sessionDir);
88
+ if (reaped.status === 'terminal') throw new Error(reaped.error);
89
+ scanned = scanCollectorHost(sessionDir);
90
+ }
91
+ if (scanned.classification === 'absent') {
92
+ await startCollectorHost(collectorHostSocketPath(sessionDir), port, targetId, sessionDir);
93
+ scanned = scanCollectorHost(sessionDir);
94
+ }
95
+ if (scanned.classification !== 'live' || !scanned.handle) throw new Error('collector host did not publish a live handle');
96
+ if (scanned.handle.targetId !== targetId) throw new Error('collector host is bound to a different target; stop the active collector before tracing this tab');
97
+ return scanned.handle;
98
+ }
99
+
100
+ async function startTrace(host: CollectorHostHandle): Promise<{ id: string; dir: string }> {
101
+ const response = await sendHostRequest(host.socketPath, { type: 'collector-start', nonce: host.nonce, kind: 'trace', config: {} }, 30_000);
102
+ if (!response.ok || !response.collector || typeof response.collector !== 'object') throw new Error(response.error ?? 'collector host refused to start trace');
103
+ const collector = response.collector as { id?: unknown; dir?: unknown };
104
+ if (typeof collector.id !== 'string' || typeof collector.dir !== 'string') throw new Error('collector host returned a malformed trace collector');
105
+ return { id: collector.id, dir: collector.dir };
106
+ }
107
+
108
+ async function stopTrace(host: CollectorHostHandle, id: string, dir: string): Promise<TraceArtifact> {
109
+ const response = await sendHostRequest(host.socketPath, { type: 'collector-stop', nonce: host.nonce, id }, 45_000);
110
+ if (!response.ok) throw new Error(response.error ?? 'collector host refused to stop trace');
111
+ return summaryFromMeta(dir, id);
112
+ }
113
+
114
+ function hostCdp(host: CollectorHostHandle) {
115
+ return async (method: string, params: Record<string, unknown> = {}, annotation?: string): Promise<Record<string, unknown>> => {
116
+ const response = await sendHostRequest(host.socketPath, { type: 'cdp', nonce: host.nonce, method, params, ...(annotation ? { annotation } : {}) }, 30_000);
117
+ if (!response.ok) throw new Error(response.error ?? `host CDP call ${method} failed`);
118
+ const { reqId: _reqId, ok: _ok, type: _type, error: _error, ...result } = response;
119
+ return result;
120
+ };
121
+ }
122
+
123
+ async function waitForDocument(host: CollectorHostHandle): Promise<void> {
124
+ const cdp = hostCdp(host);
125
+ const deadline = Date.now() + 10_000;
126
+ while (Date.now() < deadline) {
127
+ const response = await cdp('Runtime.evaluate', { expression: 'document.readyState', returnByValue: true });
128
+ const state = (response.result as { value?: unknown } | undefined)?.value;
129
+ if (state === 'complete') return;
130
+ await new Promise(resolve => setTimeout(resolve, 50));
131
+ }
132
+ throw new Error('timed out waiting for the traced document to finish loading');
133
+ }
134
+
135
+ async function drive(host: CollectorHostHandle, url: string | undefined, action: string | undefined, durationMs: number): Promise<void> {
136
+ const cdp = hostCdp(host);
137
+ if (url) {
138
+ await cdp('Page.enable');
139
+ await cdp('Page.navigate', { url });
140
+ await waitForDocument(host);
141
+ }
142
+ if (action) {
143
+ await driveOneShotAction({
144
+ async handleCdp(request: { method: string; params?: Record<string, unknown>; mark?: string }) {
145
+ return { result: await cdp(request.method, request.params ?? {}, request.mark) };
146
+ },
147
+ }, action);
148
+ }
149
+ await new Promise(resolve => setTimeout(resolve, durationMs));
150
+ }
151
+
152
+ function oneShotDir(artifactDir?: string): string {
153
+ const dir = path.join(artifactDir === undefined ? CAPTURE_ROOT : registerArtifactRoot(artifactDir), `oneshot-${Date.now().toString(36)}-${crypto.randomBytes(4).toString('hex')}`);
154
+ ensurePrivateDir(path.join(dir, 'perf', 'traces'));
155
+ return dir;
156
+ }
157
+
158
+ async function handleStart(parsed: ParsedArgs): Promise<void> {
159
+ const session = getActiveSession();
160
+ if (!session?.targetId || session.port === undefined || session.port === null) return commandError(parsed, 'no_active_session', 'perf trace --start requires an active session with a target and CDP port.');
161
+ try {
162
+ const started = await withSessionLifecycle(session.dir, async () => startTrace(await ensureHost(session.dir, session.port!, session.targetId!)));
163
+ emitResult({
164
+ tag: 'trace',
165
+ attestation: { kind: 'trace', id: started.id, path: started.dir },
166
+ attrs: { completion: 'live', categories: 'devtools-default' },
167
+ summary: fact`Trace window is live on the session tab. It contains events after this command until \`capture perf trace --stop\`.`,
168
+ }, { json: parsed.json });
169
+ } catch (error) { commandError(parsed, 'start_failed', error instanceof Error ? error.message : String(error)); }
170
+ }
171
+
172
+ async function handleStop(parsed: ParsedArgs): Promise<void> {
173
+ const session = getActiveSession();
174
+ if (!session) return commandError(parsed, 'no_active_session', 'perf trace --stop requires an active session.');
175
+ try {
176
+ const artifact = await withSessionLifecycle(session.dir, async () => {
177
+ const scanned = scanCollectorHost(session.dir);
178
+ if (scanned.classification !== 'live' || !scanned.handle) throw new Error(`no live collector host (${scanned.classification})`);
179
+ const trace = scanned.handle.collectors.find(collector => collector.kind === 'trace');
180
+ if (!trace) throw new Error('no live trace collector exists in this session');
181
+ return stopTrace(scanned.handle, trace.id, trace.dir);
182
+ });
183
+ emitTrace(parsed, artifact);
184
+ } catch (error) { commandError(parsed, 'stop_failed', error instanceof Error ? error.message : String(error)); }
185
+ }
186
+
187
+ async function handleOneShot(parsed: ParsedArgs): Promise<void> {
188
+ const active = getActiveSession();
189
+ const url = parsed.positional[0];
190
+ const durationMs = parsed.duration ?? 3_000;
191
+ if (!Number.isFinite(durationMs) || durationMs < 0) return commandError(parsed, 'invalid_duration', '--duration must be a non-negative number of seconds.');
192
+ if (!active?.targetId && !url) return commandError(parsed, 'no_session_target', 'perf trace without a URL requires an active session tab.');
193
+ let host: CollectorHostHandle | undefined;
194
+ let started: { id: string; dir: string } | undefined;
195
+ try {
196
+ const port = active?.port ?? parsed.port ?? await detectCdpPort();
197
+ const sessionDir = active?.dir ?? oneShotDir(parsed.artifactDir);
198
+ const targetId = active?.targetId ?? (await openTab(port, 'about:blank')).id;
199
+ const run = async (): Promise<TraceArtifact> => {
200
+ host = await ensureHost(sessionDir, port, targetId);
201
+ started = await startTrace(host);
202
+ await drive(host, url, parsed.do, durationMs);
203
+ return stopTrace(host, started.id, started.dir);
204
+ };
205
+ const artifact = active ? await withSessionLifecycle(sessionDir, run) : await run();
206
+ emitTrace(parsed, artifact);
207
+ } catch (error) {
208
+ const detail = error instanceof Error ? error.message : String(error);
209
+ // An active session owns the host durably, so a later --stop can observe
210
+ // and finalize its trace. A sessionless one-shot has no such recovery
211
+ // surface; stop its trace before reporting the failed command.
212
+ if (host && started && !active) {
213
+ try {
214
+ await stopTrace(host, started.id, started.dir);
215
+ } catch (cleanupError) {
216
+ const cleanupDetail = cleanupError instanceof Error ? cleanupError.message : String(cleanupError);
217
+ commandError(parsed, 'trace_failed', `${detail}. Trace cleanup also failed: ${cleanupDetail}`);
218
+ return;
219
+ }
220
+ }
221
+ const recovery = host && started && active
222
+ ? `${detail}. Trace ${started.id} remains live at ${started.dir}; finalize it with \`capture perf trace --stop\` (or inspect \`capture session collectors\`).`
223
+ : detail;
224
+ commandError(parsed, 'trace_failed', recovery);
225
+ }
226
+ }
227
+
228
+ export async function cmdPerfTrace(parsed: ParsedArgs): Promise<void> {
229
+ if (parsed.help) { console.log(HELP); return; }
230
+ if (parsed.start) return handleStart(parsed);
231
+ if (parsed.stop) return handleStop(parsed);
232
+ return handleOneShot(parsed);
233
+ }
234
+
235
+ export { HELP as PERF_TRACE_HELP, resolveTraceRef };
@@ -0,0 +1,76 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import { type ParsedArgs } from '../../types.js';
4
+ import { analyzeChromeTrace, type MetricProvenance, type TraceInsightSet } from '../../trace-analysis.js';
5
+ import { resolveTraceRef } from '../../../output/artifact.js';
6
+ import { emitResult, fact, lineList, text, type FactLine, type RenderableResult } from '../../../output/render.js';
7
+
8
+ const HELP = `capture perf vitals <trace> — Core Web Vitals measured from a recorded trace
9
+
10
+ input:
11
+ <trace> trace id in the active session or an absolute trace path (required; the trace must be finalized)
12
+ output: <vitals …> — LCP with its ttfb/load-delay/load-duration/render-delay subparts and element attribution, INP with its input-delay/main-thread-handling/presentation breakdown, and CLS with its session-window clusters and per-cluster culprits, one set per navigation in the trace; a metric with no occurrence in the recording reports not-observed, never 0, and a subpart the engine did not attribute is omitted rather than shown as 0; --json mirrors
13
+ effects: read-only — reads the finalized trace artifact, never drives the browser`;
14
+
15
+ function error(parsed: ParsedArgs, status: string, message: string): void {
16
+ emitResult({ tag: 'error', attrs: { command: 'perf vitals', status }, summary: fact`${message}` }, { json: parsed.json });
17
+ process.exitCode = 1;
18
+ }
19
+
20
+ function provenanceLines(provenance: MetricProvenance): FactLine[] {
21
+ return provenance.limitations.map(limit => fact`${limit}`);
22
+ }
23
+
24
+ function lcpLine(set: TraceInsightSet): FactLine[] {
25
+ const value = set.metrics.lcp;
26
+ const scope = fact`LCP is the largest contentful paint observed in this recording window, not a real-user field percentile.`;
27
+ if (value.status === 'not-observed') return [fact`LCP: not-observed (${value.reason}).`, scope, ...provenanceLines(value.provenance)];
28
+ const parts = Object.entries(value.value.subpartsMs).map(([name, duration]) => `${name}=${duration}ms`).join(', ');
29
+ const attribution = value.attribution ? `; attribution ${[value.attribution.element, value.attribution.nodeId === undefined ? undefined : `node ${value.attribution.nodeId}`, value.attribution.resourceUrl].filter(Boolean).join(', ')}` : '';
30
+ return [fact`LCP: ${value.value.durationMs}ms${parts ? `; subparts ${parts}` : ''}${attribution}.`, scope, ...provenanceLines(value.provenance)];
31
+ }
32
+
33
+ function inpLine(set: TraceInsightSet): FactLine[] {
34
+ const value = set.metrics.inp;
35
+ const scope = fact`INP is the single longest interaction in this window, not a distribution across interactions.`;
36
+ if (value.status === 'not-observed') return [fact`INP: not-observed (${value.reason}).`, scope, ...provenanceLines(value.provenance)];
37
+ const attribution = value.attribution?.targetNodeId === undefined ? '' : `; target node ${value.attribution.targetNodeId}`;
38
+ return [fact`INP: ${value.value.durationMs}ms; input-delay=${value.value.inputDelayMs}ms, main-thread-handling=${value.value.mainThreadHandlingMs}ms, presentation=${value.value.presentationDelayMs}ms; interaction ${value.value.interactionType} id ${value.value.interactionId}${attribution}.`, scope, ...provenanceLines(value.provenance)];
39
+ }
40
+
41
+ function clsLine(set: TraceInsightSet): FactLine[] {
42
+ const value = set.metrics.cls;
43
+ const scope = fact`CLS is the largest session-window cluster inside this window; a shorter recording measures less shift than a longer one.`;
44
+ if (value.status === 'not-observed') return [fact`CLS: not-observed (${value.reason}).`, scope, ...provenanceLines(value.provenance)];
45
+ const clusters = value.value.clusters.map((cluster, index) => `cluster ${index + 1}: value=${cluster.value}, shifts=${cluster.shiftCount}${cluster.culprits.length ? `, culprits=${JSON.stringify(cluster.culprits)}` : ''}`);
46
+ return [fact`CLS: ${value.value.value}; ${clusters.join('; ')}.`, scope, ...provenanceLines(value.provenance)];
47
+ }
48
+
49
+ function section(set: TraceInsightSet): FactLine {
50
+ return lineList([fact`Insight set ${set.id} for ${set.url}`, ...lcpLine(set), ...inpLine(set), ...clsLine(set)]);
51
+ }
52
+
53
+ function jsonSet(set: TraceInsightSet): Record<string, unknown> {
54
+ return { set: set.id, url: set.url, lcp: set.metrics.lcp, inp: set.metrics.inp, cls: set.metrics.cls };
55
+ }
56
+
57
+ export async function cmdPerfVitals(parsed: ParsedArgs): Promise<void> {
58
+ if (parsed.help) { console.log(HELP); return; }
59
+ const input = parsed.positional[0];
60
+ if (!input) return error(parsed, 'invalid_target', 'perf vitals requires one finalized trace id or absolute trace path.');
61
+ try {
62
+ const trace = resolveTraceRef(input);
63
+ const raw = JSON.parse(fs.readFileSync(path.join(trace.dir, 'trace.json'), 'utf8')) as { traceEvents?: object[] };
64
+ if (!Array.isArray(raw.traceEvents)) throw new Error('trace.json is not a Chrome trace with a traceEvents array');
65
+ const analysis = await analyzeChromeTrace({ traceEvents: raw.traceEvents });
66
+ const result: RenderableResult = {
67
+ tag: 'vitals',
68
+ attestation: { kind: 'trace', id: trace.id, path: trace.dir },
69
+ attrs: { completion: trace.completion, ...(trace.reason ? { reason: trace.reason } : {}), 'insight-sets': analysis.insightSets.length, engine: analysis.source.engine, origin: 'lab' },
70
+ summary: fact`Core Web Vitals are trace-derived lab measurements.`,
71
+ sections: analysis.insightSets.length ? analysis.insightSets.map(section) : [text`No DevTools insight set was produced for this trace.`],
72
+ jsonSections: analysis.insightSets.map(jsonSet),
73
+ };
74
+ emitResult(result, { json: parsed.json });
75
+ } catch (cause) { error(parsed, 'artifact_unavailable', cause instanceof Error ? cause.message : String(cause)); }
76
+ }
@@ -0,0 +1,54 @@
1
+ import * as path from 'node:path';
2
+ import { type ParsedArgs } from '../../types.js';
3
+ import { getActiveSession, readSessionState } from '../../../session-context.js';
4
+ import { CAPTURE_ROOT } from '../../../session/artifacts.js';
5
+ import { scanCollectorHost } from '../../host/handle.js';
6
+ import { emitResult, fact, lineList, type FactLine } from '../../../output/render.js';
7
+
8
+ const HELP = `capture session collectors — what is collecting in the active session right now
9
+
10
+ input:
11
+ (none) reads the active session's collector host; pass --session <id> to read a named live session
12
+ output: <collectors …> — one row per live collector with its kind, id, artifact directory, held claims, and start time, plus the host's process state; an absent host is a completed no-collector observation; --json mirrors
13
+ effects: read-only — reads the session's collector-host handle, never drives the browser`;
14
+
15
+ function error(parsed: ParsedArgs, code: string, message: string): void {
16
+ emitResult({ tag: 'error', attrs: { command: 'session collectors', code }, summary: fact`${message}` }, { json: parsed.json });
17
+ process.exitCode = 1;
18
+ }
19
+
20
+ export function cmdSessionCollectors(parsed: ParsedArgs): void {
21
+ if (parsed.help) { console.log(HELP); return; }
22
+ const session = parsed.session
23
+ ? (() => {
24
+ try { return readSessionState(path.join(CAPTURE_ROOT, parsed.session!)); }
25
+ catch { return null; }
26
+ })()
27
+ : getActiveSession();
28
+ if (!session) return error(parsed, 'no_session', parsed.session ? `No readable live session named "${parsed.session}".` : 'No active capture session.');
29
+
30
+ const scanned = scanCollectorHost(session.dir);
31
+ const rows: FactLine[] = [];
32
+ if (scanned.classification === 'live' && scanned.handle) {
33
+ for (const collector of scanned.handle.collectors) {
34
+ rows.push(fact`${collector.kind} ${collector.id}: ${collector.dir}; claims ${collector.claims.join(', ') || 'none'}; started ${collector.startedAt}`);
35
+ }
36
+ }
37
+ if (rows.length === 0) rows.push(fact`no live collector; host state ${scanned.classification}.`);
38
+
39
+ emitResult({
40
+ tag: 'collectors',
41
+ attrs: {
42
+ session: session.sessionId,
43
+ host: scanned.classification,
44
+ collectors: scanned.handle?.collectors.length ?? 0,
45
+ },
46
+ summary: lineList(rows),
47
+ jsonSections: [{
48
+ session: session.sessionId,
49
+ host: scanned.classification,
50
+ collectors: scanned.handle?.collectors ?? [],
51
+ reservations: scanned.handle?.reservations ?? [],
52
+ }],
53
+ }, { json: parsed.json });
54
+ }
@@ -0,0 +1,10 @@
1
+ import { captureError } from '../../errors.js';
2
+ import { type ParsedArgs } from '../types.js';
3
+
4
+ export function runStub(parsed: ParsedArgs, help: string, command: string): void {
5
+ if (parsed.help) {
6
+ console.log(help);
7
+ return;
8
+ }
9
+ throw captureError('precondition', 'not_implemented', `capture ${command} is not yet implemented.`);
10
+ }
@@ -20,7 +20,7 @@ input:
20
20
  --port <port> CDP endpoint (default: the auto-discovered preferred endpoint)
21
21
 
22
22
  output: <tab-closed port=… target=…> — the closed tab's full target id and last observed URL.
23
- effects: closes exactly one page tab. Refuses unless any active session's background-tab ownership is known, and never closes that session's tab; reset or stop the session first.`;
23
+ effects: closes exactly one page tab. Refuses unless any active session's background-tab ownership is known, and never closes that session's tab; stop the session first.`;
24
24
 
25
25
  export function buildTabClosedResult(tab: CDPTarget, port: number): RenderableResult {
26
26
  return {
@@ -82,7 +82,7 @@ export async function cmdTabClose(parsed: ParsedArgs, _args: string[]): Promise<
82
82
  throw captureError(
83
83
  'precondition',
84
84
  'active_session_target',
85
- `target ${resolved.id} is the active session ${active.sessionId}'s tab; capture tab close never leaves an active session pointing at a closed target. Use capture tab reset <url> to replace it or capture session stop ${active.sessionId} first.`,
85
+ `target ${resolved.id} is the active session ${active.sessionId}'s tab; capture tab close never leaves an active session pointing at a closed target. Stop the session first: capture session stop ${active.sessionId}.`,
86
86
  );
87
87
  }
88
88
 
@@ -17,34 +17,25 @@ import { cmdTabOpen } from './open.js';
17
17
  import { cmdTabClose } from './close.js';
18
18
  import { cmdTabReset } from './reset.js';
19
19
  import { cmdTabNetwork } from './network.js';
20
+ import { tabMockMain } from './mock/index.js';
20
21
 
21
22
  /** Root-help representation of this branch, assembled by `src/capture.ts`. */
22
23
  export const COMMAND_BLOCK = `<command name="tab">
23
- browser and tab plumbing — starting/stopping a browser capture owns, endpoint/tab discovery, tab lifecycle, connection-level network emulation
24
- use when nothing is running yet (\`tab launch\` starts a browser capture owns and reaps), when finding a CDP endpoint or tab, opening/closing/replacing a tab, or toggling connectivity; \`tab list\` is the probe for a running browser
25
- launch · quit · list · open · close · reset · network — \`capture tab -h\`
24
+ browser and tab plumbing — the existence of a browser capture owns, endpoint/tab discovery, tab lifecycle, and the conditions the tab's network runs under (connectivity, mocked responses)
25
+ use when starting, finding, opening, closing, or resetting a browser tab, or changing its network conditions
26
26
  </command>`;
27
27
 
28
- export const TAB_USAGE = `capture tab — browser and tab plumbing: browser lifecycle, discovery, tab lifecycle, network emulation.
29
-
30
- \`tab list\` with no --port performs full endpoint discovery and is the probe
31
- for whether a CDP-enabled browser is running at all; when nothing is running,
32
- \`tab launch\` starts one capture owns — never hand-roll a detached browser,
33
- since nothing reaps that. Capture signals only browsers it launched itself
34
- (verified by process-birth identity): an endpoint you reach with --port stays
35
- untouched. \`tab reset\` replaces a stuck tab with a fresh one; under an active
36
- session it refuses while a recording is active (stop it first), reaps dead
37
- recorder handles, and updates the session's {target, port} pair together.
38
-
39
- <subcommand name="launch" args="[--url <url>] [--port <port>] [--headed]" whenToUse="start a browser capture owns (and reaps) when none is running"/>
40
- <subcommand name="quit" args="[<port>] [--all]" whenToUse="stop a browser capture launched; never touches a browser capture did not start"/>
41
- <subcommand name="list" args="[--port <port>]" whenToUse="discover CDP endpoints and the tabs open on them"/>
42
- <subcommand name="open" args="<url> [--new] [--port <port>]" whenToUse="open a URL and get its tab id"/>
43
- <subcommand name="close" args="<target> [--port <port>]" whenToUse="close one explicitly identified background tab"/>
44
- <subcommand name="reset" args="<url> [--port <port>]" whenToUse="abandon a stuck tab and open a fresh one (refuses while a recording is active; reaps dead recorder handles; updates the active session's {target, port} together)"/>
45
- <subcommand name="network" args="<offline|online>" whenToUse="toggle connection-level network emulation for a tab"/>
46
-
47
- capture tab <leaf> -h Per-leaf usage`;
28
+ export const TAB_USAGE = `<command name="tab" description="browser and tab lifecycle">
29
+ <model>Browser discovery and lifecycle, tab lifecycle, and network conditions divide these children. Capture owns and reaps only browsers it started itself; endpoints selected with --port stay untouched.</model>
30
+ <subcommand name="launch" description="start a Capture-owned browser" whenToUse="Use when no CDP-enabled browser is running."/>
31
+ <subcommand name="quit" description="stop a Capture-owned browser" whenToUse="Use to stop a browser that Capture launched."/>
32
+ <subcommand name="list" description="CDP endpoints and open tabs" whenToUse="Use to find a browser endpoint or tab before targeting it."/>
33
+ <subcommand name="open" description="open a URL in a tab" whenToUse="Use to navigate the current tab or create a tab for a URL."/>
34
+ <subcommand name="close" description="close one tab" whenToUse="Use to close an explicitly identified background tab."/>
35
+ <subcommand name="reset" description="replace a tab" whenToUse="Use to discard a stuck tab and update the active session to a fresh one."/>
36
+ <subcommand name="network" description="tab network emulation" whenToUse="Use to change online connectivity for a tab held by an active session."/>
37
+ <subcommand name="mock" description="network response mocking" whenToUse="Use to intercept the tab's requests and answer them from an ordered rule document."/>
38
+ </command>`;
48
39
 
49
40
  export async function tabMain(parsed: ParsedArgs, args: string[]): Promise<void> {
50
41
  const leaf = parsed.positional[0];
@@ -65,6 +56,8 @@ export async function tabMain(parsed: ParsedArgs, args: string[]): Promise<void>
65
56
  return cmdTabReset(rest, args);
66
57
  case 'network':
67
58
  return cmdTabNetwork(rest, args);
59
+ case 'mock':
60
+ return tabMockMain(rest, args);
68
61
  case undefined:
69
62
  console.log(TAB_USAGE);
70
63
  return;
@@ -44,7 +44,7 @@ export function buildBrowserLaunchedResult(
44
44
  fact` (pid ${browser.pid}) is answering CDP.`,
45
45
  ),
46
46
  sections,
47
- followUp: line(text`capture tab quit `, data(browser.port)),
47
+ followUp: line(text`capture session start --port `, data(browser.port)),
48
48
  };
49
49
  }
50
50
 
@@ -0,0 +1,24 @@
1
+ import { invalidInput } from '../../../../errors.js';
2
+ import { type ParsedArgs } from '../../../types.js';
3
+ import { cmdTabMockStart } from './start.js';
4
+ import { cmdTabMockStop } from './stop.js';
5
+
6
+ export const MOCK_USAGE = `<command name="mock" description="network response mocking">
7
+ <model>Mocking changes what the tab's requests do and records what it did; it is not a measurement. Rules are an ordered document, first match wins, replaced only by stopping and starting again.</model>
8
+ <subcommand name="start" description="install a mock rule document on the tab" whenToUse="Use before the navigation or interaction whose requests you want answered from rules."/>
9
+ <subcommand name="stop" description="remove the mock rules and finalize their record" whenToUse="Use to return the tab to real network behavior and get the record of what was mocked."/>
10
+ </command>`;
11
+
12
+ export async function tabMockMain(parsed: ParsedArgs, _args: string[]): Promise<void> {
13
+ const leaf = parsed.positional[0];
14
+ const rest: ParsedArgs = { ...parsed, positional: parsed.positional.slice(1) };
15
+ switch (leaf) {
16
+ case 'start': return cmdTabMockStart(rest);
17
+ case 'stop': return cmdTabMockStop(rest);
18
+ case undefined:
19
+ console.log(MOCK_USAGE);
20
+ return;
21
+ default:
22
+ throw invalidInput(`Unknown tab mock leaf: ${leaf}.`, 'unknown_command');
23
+ }
24
+ }
@@ -0,0 +1,88 @@
1
+ import { captureError } from '../../../../errors.js';
2
+ import { admitSessionOperation, withSessionLifecycle } from '../../../../session/coordinator.js';
3
+ import { getActiveSession } from '../../../../session-context.js';
4
+ import { collectorHostSocketPath, startCollectorHost } from '../../../bridge/spawn.js';
5
+ import { sendHostRequest } from '../../../host/client.js';
6
+ import { scanCollectorHost, type CollectorHostHandle } from '../../../host/handle.js';
7
+ import { stopAndReapCollectorHostAtSessionStop } from '../../../host/lifecycle.js';
8
+
9
+ interface LiveMock { id: string; dir: string; startedAt: string; }
10
+ export interface StartedMock extends LiveMock { rules: number; targetId: string; }
11
+ export interface StoppedMock extends LiveMock { completion: string; rules: number; paused: number; matched: number; releasedUnmatched: number; ruleMatches: number[]; }
12
+
13
+ function activeSession(): NonNullable<ReturnType<typeof getActiveSession>> {
14
+ const session = getActiveSession();
15
+ if (!session) throw captureError('precondition', 'no_active_session', 'capture tab mock requires an active session with a target; start one with `capture session start --url <url>`.');
16
+ if (!session.targetId) throw captureError('precondition', 'session_target_missing', `session ${session.sessionId} has no target to mock; start it with a URL or reset its tab first.`);
17
+ return session;
18
+ }
19
+
20
+ function claimHeld(handle: CollectorHostHandle): never {
21
+ const holder = handle.collectors.find(collector => collector.claims.includes('fetch-interception'));
22
+ if (holder) throw captureError('precondition', 'claim_held', `received: capture tab mock start\nexpected: no live collector holding the \`fetch-interception\` claim\nholder: ${holder.kind} collector ${holder.id}, started ${holder.startedAt}\nNext: Run \`capture session collectors\` to see the live roster, stop the holder, then re-issue.`);
23
+ throw captureError('precondition', 'claim_held', 'received: capture tab mock start\nexpected: no live collector holding the `fetch-interception` claim\nNext: Run `capture session collectors` to see the live roster, stop the holder, then re-issue.');
24
+ }
25
+
26
+ async function hostForStart(sessionDir: string, targetId: string, port: number): Promise<CollectorHostHandle> {
27
+ let scanned = scanCollectorHost(sessionDir);
28
+ if (scanned.classification === 'unknown' || scanned.classification === 'malformed') throw captureError('precondition', 'collector_host_unavailable', `session collector host is ${scanned.classification}.`);
29
+ if (scanned.classification === 'dead') {
30
+ const reaped = await stopAndReapCollectorHostAtSessionStop(sessionDir);
31
+ if (reaped.status === 'terminal') throw captureError('cleanup', 'collector_host_reap_failed', reaped.error);
32
+ scanned = scanCollectorHost(sessionDir);
33
+ }
34
+ if (scanned.classification === 'absent') {
35
+ await startCollectorHost(collectorHostSocketPath(sessionDir), port, targetId, sessionDir);
36
+ scanned = scanCollectorHost(sessionDir);
37
+ }
38
+ if (scanned.classification !== 'live' || !scanned.handle) throw captureError('world', 'collector_host_unavailable', 'collector host did not publish a live handle.');
39
+ if (scanned.handle.targetId !== targetId) throw captureError('precondition', 'target_mismatch', 'collector host is bound to a different session target; stop the session before changing tabs.');
40
+ return scanned.handle;
41
+ }
42
+
43
+ export async function startMock(rulesPath: string, rules: number): Promise<StartedMock> {
44
+ const session = activeSession();
45
+ const operation = await admitSessionOperation(session.dir);
46
+ try {
47
+ return await withSessionLifecycle(session.dir, async () => {
48
+ const current = activeSession();
49
+ if (current.dir !== session.dir) throw captureError('precondition', 'session_changed', 'the active session changed while mock startup was waiting for its lifecycle lock.');
50
+ if (!current.port) throw captureError('precondition', 'session_port_missing', 'active session has no CDP port for its target.');
51
+ const host = await hostForStart(current.dir, current.targetId!, current.port);
52
+ if (host.collectors.some(collector => collector.claims.includes('fetch-interception'))) claimHeld(host);
53
+ const response = await sendHostRequest(host.socketPath, { type: 'collector-start', nonce: host.nonce, kind: 'intercept', config: { rulesPath } }, 30_000);
54
+ if (!response.ok || !response.collector || typeof response.collector !== 'object') {
55
+ if ((response.error ?? '').includes('fetch-interception')) claimHeld(host);
56
+ throw captureError('world', 'mock_start_failed', response.error ?? 'collector host refused to start mock');
57
+ }
58
+ const collector = response.collector as { id?: unknown; dir?: unknown };
59
+ if (typeof collector.id !== 'string' || typeof collector.dir !== 'string') throw captureError('internal', 'malformed_collector_response', 'collector host returned a malformed mock collector.');
60
+ return { id: collector.id, dir: collector.dir, startedAt: new Date().toISOString(), rules, targetId: current.targetId! };
61
+ });
62
+ } finally {
63
+ await operation.release();
64
+ }
65
+ }
66
+
67
+ export async function stopMock(): Promise<StoppedMock> {
68
+ const session = activeSession();
69
+ const operation = await admitSessionOperation(session.dir);
70
+ try {
71
+ return await withSessionLifecycle(session.dir, async () => {
72
+ const current = activeSession();
73
+ if (current.dir !== session.dir) throw captureError('precondition', 'session_changed', 'the active session changed while mock shutdown was waiting for its lifecycle lock.');
74
+ const scanned = scanCollectorHost(current.dir);
75
+ if (scanned.classification !== 'live' || !scanned.handle) throw captureError('precondition', 'no_live_mock', `no live mock exists in this session (${scanned.classification}). Start one with \`capture tab mock start --rules <path>\`.`);
76
+ const row = scanned.handle.collectors.find(collector => collector.kind === 'intercept');
77
+ if (!row) throw captureError('precondition', 'no_live_mock', 'no live mock exists in this session. Start one with `capture tab mock start --rules <path>`.');
78
+ const response = await sendHostRequest(scanned.handle.socketPath, { type: 'collector-stop', nonce: scanned.handle.nonce, id: row.id }, 30_000);
79
+ if (!response.ok) throw captureError('world', 'mock_stop_failed', response.error ?? 'collector host refused to stop mock');
80
+ const summary = response.summary && typeof response.summary === 'object' && !Array.isArray(response.summary) ? response.summary as Record<string, unknown> : {};
81
+ const number = (name: string): number => typeof summary[name] === 'number' ? summary[name] as number : 0;
82
+ const ruleMatches = Array.isArray(summary.ruleMatches) && summary.ruleMatches.every(value => typeof value === 'number') ? summary.ruleMatches as number[] : [];
83
+ return { id: row.id, dir: row.dir, startedAt: row.startedAt, completion: typeof response.completion === 'string' ? response.completion : 'partial', rules: number('rules'), paused: number('paused'), matched: number('matched'), releasedUnmatched: number('releasedUnmatched'), ruleMatches };
84
+ });
85
+ } finally {
86
+ await operation.release();
87
+ }
88
+ }