@crouton-kit/capture 1.4.2 → 1.4.11

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 +187 -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
@@ -24,7 +24,7 @@ import { getActiveSession } from '../../../session-context.js';
24
24
  import { createOneshotSession } from '../../../session/commands.js';
25
25
  import { writePrivateFile } from '../../../session/artifacts.js';
26
26
  import { withScopeSerialization } from '../../scope-lock.js';
27
- import { isRecorderHeldClient } from '../../recorder-client.js';
27
+ import { isMotionHeldClient } from '../../host/held-client.js';
28
28
  import { hasImports, bundleExec } from '../../../vault/bundle.js';
29
29
  import {
30
30
  capped,
@@ -43,6 +43,8 @@ import {
43
43
  const GENEROUS_RESULT_CAP = 4000;
44
44
 
45
45
  const DEFAULT_SETTLE_MS = 3000;
46
+ const DEFAULT_EVALUATION_TIMEOUT_MS = 10_000;
47
+ const TERMINATE_TIMEOUT_MS = 2_000;
46
48
 
47
49
  const USAGE = `capture page exec <code> | --file <path> — run arbitrary JavaScript in the tab: a bare expression evaluates directly; a statement body may use top-level return/await (wrapped in an async IIFE); leading static imports bundle the forked vault libs (dev checkout only)
48
50
 
@@ -50,12 +52,14 @@ input:
50
52
  <code> JS source as one argument (exactly one of <code> / --file)
51
53
  --file <path> read the JS source from a file instead of inline
52
54
  --settle <ms> network-settle window after execution so an active session's HAR captures the requests the code triggers (default: ${DEFAULT_SETTLE_MS}; 0 disables); the block reports the measured requested/waited settle
55
+ --timeout <ms> evaluation deadline (default: ${DEFAULT_EVALUATION_TIMEOUT_MS}); on expiry Capture requests Runtime.terminateExecution and reports whether cancellation completed or the renderer remains blocked
53
56
  --target <id> target a tab explicitly (default: the active session tab, else exactly one available page tab; multiple page tabs require --target or --url)
54
57
  --url <pattern> target the first tab whose URL matches <pattern>
58
+ --artifact-dir <path> root for a sessionless oversize-result bundle
55
59
  output:
56
60
  <exec-result result-chars=…> — the JSON-serialized return value inline, escaped and capped at ${GENEROUS_RESULT_CAP} chars; a larger result is also written whole to a private artifact file (the active session's page/ dir, else a one-shot artifact dir) whose absolute path appears in the block. --json mirrors the same block with the value at full fidelity.
57
61
  effects:
58
- runs the code in the page with full DOM/JS access — whatever the code mutates, it mutates; emulates focus on the tab for the duration of the call (disabled again before the command returns); writes the oversize-result artifact file when the inline cap is exceeded`;
62
+ runs the code in the page with full DOM/JS access — whatever the code mutates, it mutates; emulates focus on the tab for the duration of the call (disabled again before the command returns); writes the oversize-result artifact file under --artifact-dir when sessionless and the inline cap is exceeded`;
59
63
 
60
64
  // ---------------------------------------------------------------------------
61
65
  // Test-injectable dependency seam (repo CDP-stub pattern — see
@@ -66,10 +70,10 @@ export interface PageExecDeps {
66
70
  withPageAction: typeof withPageAction;
67
71
  getActiveSession: typeof getActiveSession;
68
72
  createOneshotSession: typeof createOneshotSession;
69
- isRecorderHeldClient: typeof isRecorderHeldClient;
73
+ isMotionHeldClient: typeof isMotionHeldClient;
70
74
  }
71
75
 
72
- let deps: PageExecDeps = { withPageAction, getActiveSession, createOneshotSession, isRecorderHeldClient };
76
+ let deps: PageExecDeps = { withPageAction, getActiveSession, createOneshotSession, isMotionHeldClient };
73
77
 
74
78
  /** Swap the connection/session seams for the CDP-stub tests. */
75
79
  export function __setPageExecDepsForTest(overrides: Partial<PageExecDeps>): () => void {
@@ -91,12 +95,12 @@ function emitExecError(parsed: ParsedArgs, code: string, summary: FactLine, foll
91
95
  * the active session's `page/` dir when a session exists, else into a fresh
92
96
  * one-shot artifact dir. Returns the absolute file path.
93
97
  */
94
- function spillWholeResult(payload: string): string {
98
+ function spillWholeResult(payload: string, artifactDir?: string): string {
95
99
  const name = `exec-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}.json`;
96
100
  const session = deps.getActiveSession();
97
101
  const filePath = session
98
102
  ? path.join(session.dir, 'page', name)
99
- : path.join(deps.createOneshotSession('page').artifactsDir, name);
103
+ : path.join(deps.createOneshotSession('page', artifactDir).artifactsDir, name);
100
104
  writePrivateFile(filePath, payload);
101
105
  return filePath;
102
106
  }
@@ -110,7 +114,19 @@ interface EvalResult {
110
114
  * subset of `CDPClient` both a direct connection and the recorder-held
111
115
  * adapter satisfy. */
112
116
  interface ExecClient {
113
- send(method: string, params?: Record<string, unknown>): Promise<unknown>;
117
+ send(method: string, params?: Record<string, unknown>, timeoutMs?: number): Promise<unknown>;
118
+ }
119
+
120
+ class ExecDeadlineError extends Error {
121
+ constructor(readonly cancellation: 'completed' | 'blocked', timeoutMs: number, detail: string) {
122
+ super(detail || (cancellation === 'completed'
123
+ ? `Evaluation exceeded ${timeoutMs}ms and Runtime.terminateExecution completed.`
124
+ : `Evaluation exceeded ${timeoutMs}ms and Runtime.terminateExecution did not complete.`));
125
+ }
126
+ }
127
+
128
+ function isRequestTimeout(error: unknown): boolean {
129
+ return error instanceof Error && /CDP request timeout \(\d+ms\): Runtime\.evaluate|collector-host CDP call "Runtime\.evaluate" failed: CDP request timeout/.test(error.message);
114
130
  }
115
131
 
116
132
  /**
@@ -121,7 +137,7 @@ interface ExecClient {
121
137
  * failure prevents success: alone it throws a typed cleanup failure; paired
122
138
  * with a primary failure it throws an `AggregateError` preserving both.
123
139
  */
124
- async function focusScopedEvaluate(client: ExecClient, expression: string): Promise<EvalResult> {
140
+ async function focusScopedEvaluate(client: ExecClient, expression: string, evaluationTimeoutMs: number): Promise<EvalResult> {
125
141
  let ownsFocusOverride = false;
126
142
  let primaryFailed = false;
127
143
  let primaryError: unknown;
@@ -130,11 +146,22 @@ async function focusScopedEvaluate(client: ExecClient, expression: string): Prom
130
146
  // by the browser, without foregrounding the tab.
131
147
  ownsFocusOverride = true;
132
148
  await client.send('Emulation.setFocusEmulationEnabled', { enabled: true });
133
- return (await client.send('Runtime.evaluate', {
134
- expression,
135
- awaitPromise: true,
136
- returnByValue: true,
137
- })) as EvalResult;
149
+ try {
150
+ return (await client.send('Runtime.evaluate', {
151
+ expression,
152
+ awaitPromise: true,
153
+ returnByValue: true,
154
+ }, evaluationTimeoutMs)) as EvalResult;
155
+ } catch (error) {
156
+ if (!isRequestTimeout(error)) throw error;
157
+ try {
158
+ await client.send('Runtime.terminateExecution', {}, TERMINATE_TIMEOUT_MS);
159
+ throw new ExecDeadlineError('completed', evaluationTimeoutMs, `Evaluation exceeded ${evaluationTimeoutMs}ms; Runtime.terminateExecution completed.`);
160
+ } catch (terminationError) {
161
+ if (terminationError instanceof ExecDeadlineError) throw terminationError;
162
+ throw new ExecDeadlineError('blocked', evaluationTimeoutMs, `Evaluation exceeded ${evaluationTimeoutMs}ms; Runtime.terminateExecution did not complete within ${TERMINATE_TIMEOUT_MS}ms, so the renderer remains blocked until this target navigates or closes.`);
163
+ }
164
+ }
138
165
  } catch (error) {
139
166
  primaryFailed = true;
140
167
  primaryError = error;
@@ -175,13 +202,13 @@ async function focusScopedEvaluate(client: ExecClient, expression: string): Prom
175
202
  * needs no lock — its emulation state is scoped to its own websocket
176
203
  * session, which closes with the command.
177
204
  */
178
- async function evaluateWithFocusEmulation(client: ExecClient, expression: string): Promise<EvalResult> {
205
+ async function evaluateWithFocusEmulation(client: ExecClient, expression: string, evaluationTimeoutMs: number): Promise<EvalResult> {
179
206
  return withScopeSerialization(
180
207
  client,
181
208
  'focus',
182
209
  'page exec',
183
- () => focusScopedEvaluate(client, expression),
184
- { isRecorderHeldClient: deps.isRecorderHeldClient, getActiveSession: deps.getActiveSession },
210
+ () => focusScopedEvaluate(client, expression, evaluationTimeoutMs),
211
+ { isMotionHeldClient: deps.isMotionHeldClient, getActiveSession: deps.getActiveSession },
185
212
  );
186
213
  }
187
214
 
@@ -237,6 +264,7 @@ export async function cmdPageExec(parsed: ParsedArgs, _args: string[]): Promise<
237
264
  }
238
265
 
239
266
  const settle = parsed.settle ?? DEFAULT_SETTLE_MS;
267
+ const evaluationTimeoutMs = parsed.timeoutMs ?? DEFAULT_EVALUATION_TIMEOUT_MS;
240
268
 
241
269
  // A prebuilt bundle is already a complete IIFE returning the user's
242
270
  // promise; otherwise buildExecExpression wraps statement bodies. Pure
@@ -253,11 +281,18 @@ export async function cmdPageExec(parsed: ParsedArgs, _args: string[]): Promise<
253
281
  const outcome = await deps.withPageAction(
254
282
  { ...parsed, command: 'exec' },
255
283
  { settleMs: settle },
256
- (client) => evaluateWithFocusEmulation(client, expression),
284
+ (client) => evaluateWithFocusEmulation(client, expression, evaluationTimeoutMs),
257
285
  );
258
286
  evalResult = outcome.result;
259
287
  settleFacts = outcome.settle;
260
288
  } catch (err) {
289
+ if (err instanceof ExecDeadlineError) {
290
+ return emitExecError(
291
+ parsed,
292
+ err.cancellation === 'completed' ? 'exec_timeout_cancelled' : 'target_wedged',
293
+ fact`${err.message}`,
294
+ );
295
+ }
261
296
  // A typed failure (e.g. `recorder_unavailable` from strict A2 routing,
262
297
  // `har_missing`, or a lone `focus_cleanup_failed`) crosses the boundary
263
298
  // unrelabeled — only a genuinely untyped error is the exec-specific
@@ -293,7 +328,7 @@ export async function cmdPageExec(parsed: ParsedArgs, _args: string[]): Promise<
293
328
 
294
329
  let spillPath: string | undefined;
295
330
  if (payload.length > GENEROUS_RESULT_CAP) {
296
- spillPath = spillWholeResult(payload);
331
+ spillPath = spillWholeResult(payload, parsed.artifactDir);
297
332
  }
298
333
 
299
334
  const inlineCap = parsed.json ? payload.length : GENEROUS_RESULT_CAP;
@@ -18,31 +18,27 @@ import { cmdPageNavigate } from './navigate.js';
18
18
  import { cmdPageExec } from './exec.js';
19
19
  import { cmdPageShot } from './shot.js';
20
20
  import { cmdPageElements } from './elements.js';
21
+ import { inspectMain } from './inspect.js';
22
+ import { cmdPageRepeat } from './repeat.js';
21
23
 
22
24
  /** Root-help representation of this branch, assembled by `src/capture.ts`. */
23
25
  export const COMMAND_BLOCK = `<command name="page">
24
- verbs against the live session tab — act (click, type, scroll, navigate, exec) and look (shot, elements)
25
- use when driving or inspecting the tab a session opened or adopted; all verbs auto-target the active session tab, --target/--url override
26
- click · type · scroll · navigate · exec · shot · elements — \`capture page -h\`
26
+ verbs against the live session tab — act on it (click, type, scroll, navigate, exec, repeat) and inspect its current state (shot, elements, inspect)
27
+ use when driving or inspecting the current live tab
27
28
  </command>`;
28
29
 
29
- export const PAGE_USAGE = `capture page — verbs against the live session tab: driving, looking, targeting.
30
-
31
- All verbs auto-target the active session tab; --target/--url override. The
32
- driving verbs (click, type, scroll) resolve exactly one element via the
33
- unified target grammar — bare CSS selector (which takes precedence) or exact accessible name when CSS finds none, ax:<name>, axid:<id>,
34
- backend:<id> — and reject an ambiguous target with the candidate list.
35
- During a live composed recording every verb routes through the recorder.
36
-
37
- <subcommand name="click" args="<target> [--no-screenshot] [--settle <ms>]" whenToUse="dispatch a real click on one resolved element"/>
38
- <subcommand name="type" args="<text> [--into <target>] [--no-screenshot] [--settle <ms>]" whenToUse="type text into the focused element or one resolved field"/>
39
- <subcommand name="scroll" args="<target> --to <top|bottom|px> [--no-screenshot]" whenToUse="scroll one resolved container to a position"/>
40
- <subcommand name="navigate" args="<url> [--settle <ms>]" whenToUse="navigate the tab and wait for load + settle"/>
41
- <subcommand name="exec" args="<code> | --file <path>" whenToUse="run arbitrary JS in the tab (expressions, return, await)"/>
42
- <subcommand name="shot" args="[--viewport <WxH>] [--full-page] [--color-scheme <dark|light>] [--crop <x,y,w,h> | --crop-selector <sel> [--pad <px>]] [--zoom <factor>] [--out <path>]" whenToUse="look at the page right now without acting"/>
43
- <subcommand name="elements" args="[--all] [--limit <n>]" whenToUse="list what can be acted on — role, name, backend:<id> per element"/>
44
-
45
- capture page <leaf> -h Per-leaf usage`;
30
+ export const PAGE_USAGE = `<command name="page" description="operations against the live session tab">
31
+ <model>All verbs auto-target the active session tab; --target/--url override. Driving verbs resolve exactly one element via the unified target grammar — bare CSS selector (which takes precedence) or exact accessible name when CSS finds none, ax:<name>, axid:<id>, backend:<id> — and reject an ambiguous target with the candidate list. During a live composed recording every verb routes through the recorder.</model>
32
+ <subcommand name="click" description="real click on one element" whenToUse="Use to activate a control through the page's normal click handling."/>
33
+ <subcommand name="type" description="text entry into an element" whenToUse="Use to enter text into the focused element or a resolved field."/>
34
+ <subcommand name="scroll" description="scroll one container" whenToUse="Use to move a resolved scrollable container to a position."/>
35
+ <subcommand name="navigate" description="navigate the tab" whenToUse="Use to load a URL in the current tab and wait for it to settle."/>
36
+ <subcommand name="exec" description="arbitrary JavaScript in the tab" whenToUse="Use when no named page command exposes the needed page operation."/>
37
+ <subcommand name="shot" description="current-page screenshot" whenToUse="Use to inspect the rendered page without interacting with it."/>
38
+ <subcommand name="elements" description="actionable page elements" whenToUse="Use to discover targets for page interactions."/>
39
+ <subcommand name="inspect" description="live-tab factual inventories" whenToUse="Use to read listeners, cardinality, frame lifecycle, or Resource Timing facts from the current tab."/>
40
+ <subcommand name="repeat" description="repeat a declarative click/type sequence" whenToUse="Use to drive the same bounded interaction sequence over multiple iterations and retain per-iteration completion facts."/>
41
+ </command>`;
46
42
 
47
43
  export async function pageMain(parsed: ParsedArgs, args: string[]): Promise<void> {
48
44
  const leaf = parsed.positional[0];
@@ -63,6 +59,10 @@ export async function pageMain(parsed: ParsedArgs, args: string[]): Promise<void
63
59
  return cmdPageShot(rest, args);
64
60
  case 'elements':
65
61
  return cmdPageElements(rest, args);
62
+ case 'inspect':
63
+ return inspectMain(rest, args);
64
+ case 'repeat':
65
+ return cmdPageRepeat(rest, args);
66
66
  case undefined:
67
67
  console.log(PAGE_USAGE);
68
68
  return;
@@ -0,0 +1,338 @@
1
+ import { type CDPClient } from '../../client.js';
2
+ import { withConnection } from '../../connection.js';
3
+ import { type ParsedArgs } from '../../types.js';
4
+ import { invalidInput } from '../../../errors.js';
5
+ import { resolveLiveTarget, type LiveClient, type ResolutionFailure } from '../../../interact.js';
6
+ import { emitResolutionError } from './click.js';
7
+ import { emitResult, fact, lineList, type FactLine } from '../../../output/render.js';
8
+
9
+ const DEFAULT_LIMIT = 100;
10
+
11
+ const INSPECT_USAGE = `<command name="inspect" description="factual live-tab inventories">
12
+ <model>Each leaf reads the current selected tab without a page-observable write. List leaves filter before --limit and mirror only their bounded final records. This branch does not enumerate scroll containers; \`capture measure map scroll\` owns that measured topology.</model>
13
+ <subcommand name="listeners" description="registered event listeners" whenToUse="Use to read listeners on window, document, or one live-resolved target."/>
14
+ <subcommand name="cardinality" description="DOM and document counts" whenToUse="Use to read Chrome's current DOM/document cardinality counters."/>
15
+ <subcommand name="frames" description="attached and observed-detached frame identities" whenToUse="Use to read the current frame tree and frame lifecycle events observed during a bounded window."/>
16
+ <subcommand name="resources" description="Resource Timing entries" whenToUse="Use to read the current page's Performance Resource Timing view."/>
17
+ </command>`;
18
+
19
+ const LISTENERS_USAGE = `capture page inspect listeners [--scope <window|document|target> --selector <target>] [--event <type>] [--limit <n>] — list registered listeners on one live identity
20
+
21
+ input:
22
+ --scope <kind> listener owner: window (default), document, or target; target requires --selector
23
+ --selector <target> target-scope identity using the live page grammar: bare CSS selector (or exact accessible name when CSS finds none), ax:<name>, axid:<id>, backend:<id>
24
+ --event <type> exact event-type filter; applies before --limit
25
+ --limit <n> maximum listener records rendered and mirrored (positive integer, default ${DEFAULT_LIMIT}); the summary retains the filtered count
26
+ --target <tabId> | --url <pattern> | --port <n> tab targeting; defaults to the active session tab
27
+ output:
28
+ <listeners identity=… count=… displayed=…> — listener event type, capture/passive/once registration facts, handler description, and script URL/line/column provenance when CDP supplies it
29
+ effects:
30
+ read-only CDP collection: enables Debugger to map listener script ids to the script URLs Chrome reports, then reads DOMDebugger listener registrations; no page-observable write`;
31
+
32
+ const CARDINALITY_USAGE = `capture page inspect cardinality — read bounded current DOM/document counts
33
+
34
+ input:
35
+ --target <tabId> | --url <pattern> | --port <n> tab targeting; defaults to the active session tab
36
+ output:
37
+ <cardinality documents=… nodes=… listeners=… elements=… frames=…> — Chrome Memory.getDOMCounters values, the current document element count, and the Performance metric frame count with collection-method provenance
38
+ effects:
39
+ read-only CDP collection through Memory.getDOMCounters, Runtime.evaluate, and Performance.getMetrics; no page-observable write`;
40
+
41
+ const FRAMES_USAGE = `capture page inspect frames [--duration <seconds>] — read attached frame identities and lifecycle events observed in a bounded window
42
+
43
+ input:
44
+ --duration <seconds> observation window after the current frame-tree read (default 0; maximum 60); frame attach, navigate, and detach events received in this window are recorded
45
+ --limit <n> maximum attached/detached frame records rendered and mirrored (positive integer, default ${DEFAULT_LIMIT}); the summary retains attached and detached totals
46
+ --target <tabId> | --url <pattern> | --port <n> tab targeting; defaults to the active session tab
47
+ output:
48
+ <frames attached=… detached=… displayed=… requested-ms=… observed-ms=…> — current attached frame ids, parent ids, and URLs plus identities and URL facts for frames observed detaching during this command's collection window; attached/detached records are bounded by --limit
49
+ effects:
50
+ read-only CDP collection: enables Page, reads Page.getFrameTree, and observes Page frame lifecycle events for the bounded window; no page-observable write`;
51
+
52
+ const RESOURCES_USAGE = `capture page inspect resources [--name <text>] [--limit <n>] — list the current page Resource Timing entries
53
+
54
+ input:
55
+ --name <text> case-insensitive substring filter over entry name URLs; applies before --limit
56
+ --limit <n> maximum entries rendered and mirrored (positive integer, default ${DEFAULT_LIMIT}); the summary retains the filtered count
57
+ --target <tabId> | --url <pattern> | --port <n> tab targeting; defaults to the active session tab
58
+ output:
59
+ <resources count=… displayed=…> — Resource Timing name, initiator type, timing offsets/duration, transfer/body sizes, protocol, and collection provenance
60
+ effects:
61
+ read-only Runtime evaluation of performance.getEntriesByType('resource'); no page-observable write`;
62
+
63
+ interface ScriptParsed { scriptId?: unknown; url?: unknown; }
64
+ interface ListenerRaw {
65
+ type?: unknown;
66
+ useCapture?: unknown;
67
+ passive?: unknown;
68
+ once?: unknown;
69
+ scriptId?: unknown;
70
+ lineNumber?: unknown;
71
+ columnNumber?: unknown;
72
+ handler?: { description?: unknown; className?: unknown };
73
+ }
74
+ interface ListenerRecord {
75
+ readonly event: string;
76
+ readonly capture: boolean;
77
+ readonly passive: boolean;
78
+ readonly once: boolean;
79
+ readonly handler?: string;
80
+ readonly scriptId?: string;
81
+ readonly scriptUrl?: string;
82
+ readonly line?: number;
83
+ readonly column?: number;
84
+ }
85
+
86
+ function asRecord(value: unknown): Record<string, unknown> {
87
+ return value !== null && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
88
+ }
89
+
90
+ function countLimit(parsed: ParsedArgs): number {
91
+ const limit = parsed.limit ?? DEFAULT_LIMIT;
92
+ if (!Number.isSafeInteger(limit) || limit < 1) throw invalidInput(`received: --limit ${String(parsed.limit)}; expected: a positive safe integer.`, 'invalid_flag');
93
+ return limit;
94
+ }
95
+
96
+ async function listenerObjectId(client: LiveClient, parsed: ParsedArgs): Promise<{ identity: string; objectId: string } | { failure: ResolutionFailure }> {
97
+ const scope = parsed.scope ?? 'window';
98
+ if (scope === 'target') {
99
+ const resolved = await resolveLiveTarget(client, parsed.selector!);
100
+ if (!resolved.ok) return { failure: resolved };
101
+ const response = asRecord(await client.send('DOM.resolveNode', { backendNodeId: resolved.backendNodeId }));
102
+ const object = asRecord(response.object);
103
+ const objectId = typeof object.objectId === 'string' ? object.objectId : undefined;
104
+ if (!objectId) throw new Error('DOM.resolveNode returned no object id for the live listener target.');
105
+ return { identity: `backend:${resolved.backendNodeId}`, objectId };
106
+ }
107
+ const expression = scope === 'window' ? 'window' : 'document';
108
+ const response = asRecord(await client.send('Runtime.evaluate', { expression, returnByValue: false }));
109
+ const result = asRecord(response.result);
110
+ const objectId = typeof result.objectId === 'string' ? result.objectId : undefined;
111
+ if (!objectId) throw new Error(`Runtime.evaluate returned no object id for ${scope}.`);
112
+ return { identity: scope, objectId };
113
+ }
114
+
115
+ async function cmdListeners(parsed: ParsedArgs): Promise<void> {
116
+ if (parsed.help) return void console.log(LISTENERS_USAGE);
117
+ const limit = countLimit(parsed);
118
+ const outcome = await withConnection(parsed, async (client) => {
119
+ const scriptUrls = new Map<string, string>();
120
+ const parsedHandler = (params: unknown) => {
121
+ const event = params as ScriptParsed;
122
+ if (typeof event.scriptId === 'string' && typeof event.url === 'string') scriptUrls.set(event.scriptId, event.url);
123
+ };
124
+ client.on('Debugger.scriptParsed', parsedHandler);
125
+ try {
126
+ await client.send('Debugger.enable');
127
+ const target = await listenerObjectId(client as unknown as LiveClient, parsed);
128
+ if ('failure' in target) return target;
129
+ try {
130
+ const response = asRecord(await client.send('DOMDebugger.getEventListeners', { objectId: target.objectId, depth: 1, pierce: true }));
131
+ const listeners = Array.isArray(response.listeners) ? response.listeners as ListenerRaw[] : [];
132
+ const records = listeners.flatMap((listener): ListenerRecord[] => {
133
+ if (typeof listener.type !== 'string') return [];
134
+ if (parsed.event !== undefined && listener.type !== parsed.event) return [];
135
+ const scriptId = typeof listener.scriptId === 'string' ? listener.scriptId : undefined;
136
+ return [{
137
+ event: listener.type,
138
+ capture: listener.useCapture === true,
139
+ passive: listener.passive === true,
140
+ once: listener.once === true,
141
+ handler: typeof listener.handler?.description === 'string' ? listener.handler.description : typeof listener.handler?.className === 'string' ? listener.handler.className : undefined,
142
+ scriptId,
143
+ scriptUrl: scriptId === undefined ? undefined : scriptUrls.get(scriptId),
144
+ line: typeof listener.lineNumber === 'number' ? listener.lineNumber : undefined,
145
+ column: typeof listener.columnNumber === 'number' ? listener.columnNumber : undefined,
146
+ }];
147
+ });
148
+ return { identity: target.identity, records };
149
+ } finally {
150
+ await client.send('Runtime.releaseObject', { objectId: target.objectId });
151
+ }
152
+ } finally {
153
+ client.off('Debugger.scriptParsed', parsedHandler);
154
+ }
155
+ }, { settle: 0 });
156
+
157
+ if ('failure' in outcome) return emitResolutionError(parsed, 'page inspect listeners', outcome.failure);
158
+ const shown = outcome.records.slice(0, limit);
159
+ const rows: FactLine[] = shown.map(record => fact`${record.event} capture=${String(record.capture)} passive=${String(record.passive)} once=${String(record.once)}${record.handler === undefined ? '' : ` handler=${record.handler}`}${record.scriptUrl === undefined ? record.scriptId === undefined ? '' : ` script-id=${record.scriptId}` : ` script=${record.scriptUrl}:${record.line ?? 0}:${record.column ?? 0}`}`);
160
+ const sections: FactLine[] = rows.length === 0 ? [] : [lineList(rows)];
161
+ if (shown.length < outcome.records.length) sections.push(fact`listeners-truncated: listing capped at ${shown.length} of ${outcome.records.length} filtered listeners (--limit).`);
162
+ emitResult({
163
+ tag: 'listeners',
164
+ attrs: { identity: outcome.identity, count: outcome.records.length, displayed: shown.length, event: parsed.event },
165
+ summary: fact`${outcome.records.length} registered listener(s) collected from ${outcome.identity}; Debugger.scriptParsed maps script ids to URLs when Chrome supplied those events.`,
166
+ sections,
167
+ jsonSections: { listeners: shown },
168
+ }, { json: parsed.json });
169
+ }
170
+
171
+ async function cmdCardinality(parsed: ParsedArgs): Promise<void> {
172
+ if (parsed.help) return void console.log(CARDINALITY_USAGE);
173
+ const result = await withConnection(parsed, async (client) => {
174
+ const counters = asRecord(await client.send('Memory.getDOMCounters'));
175
+ const document = asRecord(await client.send('Runtime.evaluate', { expression: 'document.querySelectorAll("*").length', returnByValue: true }));
176
+ await client.send('Performance.enable');
177
+ const metricResult = asRecord(await client.send('Performance.getMetrics'));
178
+ const metricItems = Array.isArray(metricResult.metrics) ? metricResult.metrics.map(asRecord) : [];
179
+ const metric = (name: string): number | undefined => {
180
+ const found = metricItems.find(item => item.name === name);
181
+ return typeof found?.value === 'number' ? found.value : undefined;
182
+ };
183
+ return {
184
+ documents: typeof counters.documents === 'number' ? counters.documents : undefined,
185
+ nodes: typeof counters.nodes === 'number' ? counters.nodes : undefined,
186
+ listeners: typeof counters.jsEventListeners === 'number' ? counters.jsEventListeners : undefined,
187
+ elements: typeof asRecord(document.result).value === 'number' ? asRecord(document.result).value as number : undefined,
188
+ frames: metric('Frames'),
189
+ };
190
+ }, { settle: 0 });
191
+ emitResult({
192
+ tag: 'cardinality',
193
+ attrs: result,
194
+ summary: fact`Counts collected now: documents/nodes/listeners from Memory.getDOMCounters; elements from document.querySelectorAll("*").length; frames from Performance.getMetrics.`,
195
+ jsonSections: { collection: { documents: 'Memory.getDOMCounters', nodes: 'Memory.getDOMCounters', listeners: 'Memory.getDOMCounters', elements: 'Runtime.evaluate document.querySelectorAll("*").length', frames: 'Performance.getMetrics Frames' } },
196
+ }, { json: parsed.json });
197
+ }
198
+
199
+ interface FrameRecord { id: string; parentId?: string; url?: string; }
200
+ interface DetachedFrameRecord extends FrameRecord { reason?: string; }
201
+ function flattenFrameTree(frameTree: unknown, out: FrameRecord[]): void {
202
+ const tree = asRecord(frameTree);
203
+ const frame = asRecord(tree.frame);
204
+ if (typeof frame.id === 'string') out.push({ id: frame.id, parentId: typeof frame.parentId === 'string' ? frame.parentId : undefined, url: typeof frame.url === 'string' ? frame.url : undefined });
205
+ if (Array.isArray(tree.childFrames)) for (const child of tree.childFrames) flattenFrameTree(child, out);
206
+ }
207
+ function sleep(ms: number): Promise<void> { return new Promise(resolve => setTimeout(resolve, ms)); }
208
+
209
+ async function cmdFrames(parsed: ParsedArgs): Promise<void> {
210
+ if (parsed.help) return void console.log(FRAMES_USAGE);
211
+ const requestedMs = parsed.duration ?? 0;
212
+ const limit = countLimit(parsed);
213
+ const result = await withConnection(parsed, async (client) => {
214
+ const known = new Map<string, FrameRecord>();
215
+ const detached: DetachedFrameRecord[] = [];
216
+ const attachedHandler = (params: unknown) => {
217
+ const event = asRecord(params);
218
+ if (typeof event.frameId === 'string') known.set(event.frameId, { id: event.frameId, parentId: typeof event.parentFrameId === 'string' ? event.parentFrameId : undefined });
219
+ };
220
+ const navigatedHandler = (params: unknown) => {
221
+ const frame = asRecord(asRecord(params).frame);
222
+ if (typeof frame.id === 'string') known.set(frame.id, { id: frame.id, parentId: typeof frame.parentId === 'string' ? frame.parentId : undefined, url: typeof frame.url === 'string' ? frame.url : undefined });
223
+ };
224
+ const detachedHandler = (params: unknown) => {
225
+ const event = asRecord(params);
226
+ if (typeof event.frameId !== 'string') return;
227
+ const prior = known.get(event.frameId) ?? { id: event.frameId };
228
+ detached.push({ ...prior, reason: typeof event.reason === 'string' ? event.reason : undefined });
229
+ known.delete(event.frameId);
230
+ };
231
+ client.on('Page.frameAttached', attachedHandler);
232
+ client.on('Page.frameNavigated', navigatedHandler);
233
+ client.on('Page.frameDetached', detachedHandler);
234
+ try {
235
+ await client.send('Page.enable');
236
+ const tree = asRecord(await client.send('Page.getFrameTree'));
237
+ const attached: FrameRecord[] = [];
238
+ flattenFrameTree(tree.frameTree, attached);
239
+ for (const frame of attached) known.set(frame.id, frame);
240
+ const observationStarted = performance.now();
241
+ if (requestedMs > 0) await sleep(requestedMs);
242
+ const current: FrameRecord[] = [];
243
+ flattenFrameTree(asRecord(await client.send('Page.getFrameTree')).frameTree, current);
244
+ const observedMs = performance.now() - observationStarted;
245
+ return { attached: current, detached, observedMs };
246
+ } finally {
247
+ client.off('Page.frameAttached', attachedHandler);
248
+ client.off('Page.frameNavigated', navigatedHandler);
249
+ client.off('Page.frameDetached', detachedHandler);
250
+ }
251
+ }, { settle: 0 });
252
+ const records = [
253
+ ...result.attached.map(frame => ({ state: 'attached' as const, ...frame })),
254
+ ...result.detached.map(frame => ({ state: 'detached' as const, ...frame })),
255
+ ];
256
+ const shown = records.slice(0, limit);
257
+ const rows = shown.map(frame => frame.state === 'attached'
258
+ ? fact`attached frame:${frame.id}${frame.parentId === undefined ? '' : ` parent:${frame.parentId}`}${frame.url === undefined ? '' : ` url=${frame.url}`}`
259
+ : fact`detached frame:${frame.id}${frame.parentId === undefined ? '' : ` parent:${frame.parentId}`}${frame.url === undefined ? '' : ` url=${frame.url}`}${frame.reason === undefined ? '' : ` reason=${frame.reason}`}`);
260
+ const sections: FactLine[] = rows.length ? [lineList(rows)] : [];
261
+ if (shown.length < records.length) sections.push(fact`frames-truncated: listing capped at ${shown.length} of ${records.length} attached/detached frame records (--limit).`);
262
+ emitResult({
263
+ tag: 'frames',
264
+ attrs: { attached: result.attached.length, detached: result.detached.length, displayed: shown.length, 'requested-ms': requestedMs, 'observed-ms': result.observedMs },
265
+ summary: fact`${result.attached.length} frame(s) are attached in the final Page.getFrameTree; ${result.detached.length} detach event(s) were recorded after Page.enable during a requested ${requestedMs}ms window measured as ${result.observedMs.toFixed(1)}ms.`,
266
+ sections,
267
+ jsonSections: { frames: shown, collection: { requestedMs, observedMs: result.observedMs } },
268
+ }, { json: parsed.json });
269
+ }
270
+
271
+ interface ResourceRecord {
272
+ readonly name: string;
273
+ readonly initiatorType?: string;
274
+ readonly startTime?: number;
275
+ readonly duration?: number;
276
+ readonly responseEnd?: number;
277
+ readonly transferSize?: number;
278
+ readonly encodedBodySize?: number;
279
+ readonly decodedBodySize?: number;
280
+ readonly nextHopProtocol?: string;
281
+ readonly renderBlockingStatus?: string;
282
+ }
283
+
284
+ async function cmdResources(parsed: ParsedArgs): Promise<void> {
285
+ if (parsed.help) return void console.log(RESOURCES_USAGE);
286
+ const limit = countLimit(parsed);
287
+ const records = await withConnection(parsed, async (client) => {
288
+ const response = asRecord(await client.send('Runtime.evaluate', {
289
+ expression: 'performance.getEntriesByType("resource").map(({name,initiatorType,startTime,duration,responseEnd,transferSize,encodedBodySize,decodedBodySize,nextHopProtocol,renderBlockingStatus})=>({name,initiatorType,startTime,duration,responseEnd,transferSize,encodedBodySize,decodedBodySize,nextHopProtocol,renderBlockingStatus}))',
290
+ returnByValue: true,
291
+ }));
292
+ const value = asRecord(response.result).value;
293
+ if (!Array.isArray(value)) throw new Error('performance.getEntriesByType("resource") did not return an array.');
294
+ return value.flatMap((entry): ResourceRecord[] => {
295
+ const record = asRecord(entry);
296
+ if (typeof record.name !== 'string') return [];
297
+ return [{
298
+ name: record.name,
299
+ initiatorType: typeof record.initiatorType === 'string' ? record.initiatorType : undefined,
300
+ startTime: typeof record.startTime === 'number' ? record.startTime : undefined,
301
+ duration: typeof record.duration === 'number' ? record.duration : undefined,
302
+ responseEnd: typeof record.responseEnd === 'number' ? record.responseEnd : undefined,
303
+ transferSize: typeof record.transferSize === 'number' ? record.transferSize : undefined,
304
+ encodedBodySize: typeof record.encodedBodySize === 'number' ? record.encodedBodySize : undefined,
305
+ decodedBodySize: typeof record.decodedBodySize === 'number' ? record.decodedBodySize : undefined,
306
+ nextHopProtocol: typeof record.nextHopProtocol === 'string' ? record.nextHopProtocol : undefined,
307
+ renderBlockingStatus: typeof record.renderBlockingStatus === 'string' ? record.renderBlockingStatus : undefined,
308
+ }];
309
+ });
310
+ }, { settle: 0 });
311
+ const filtered = parsed.name === undefined ? records : records.filter(record => record.name.toLowerCase().includes(parsed.name!.toLowerCase()));
312
+ const shown = filtered.slice(0, limit);
313
+ const sections: FactLine[] = shown.length === 0 ? [] : [lineList(shown.map(record => fact`${record.name} initiator=${record.initiatorType ?? '(unknown)'} start=${record.startTime ?? '(unknown)'}ms duration=${record.duration ?? '(unknown)'}ms response-end=${record.responseEnd ?? '(unknown)'}ms transfer=${record.transferSize ?? '(unknown)'}B encoded=${record.encodedBodySize ?? '(unknown)'}B decoded=${record.decodedBodySize ?? '(unknown)'}B protocol=${record.nextHopProtocol ?? '(unknown)'} render-blocking=${record.renderBlockingStatus ?? '(unknown)'}`))];
314
+ if (shown.length < filtered.length) sections.push(fact`resources-truncated: listing capped at ${shown.length} of ${filtered.length} filtered entries (--limit).`);
315
+ emitResult({
316
+ tag: 'resources',
317
+ attrs: { count: filtered.length, displayed: shown.length, 'name-filter': parsed.name },
318
+ summary: fact`${filtered.length} current Performance Resource Timing entr${filtered.length === 1 ? 'y' : 'ies'} collected at command time.`,
319
+ sections,
320
+ jsonSections: { entries: shown, collection: 'performance.getEntriesByType("resource")' },
321
+ }, { json: parsed.json });
322
+ }
323
+
324
+ export async function inspectMain(parsed: ParsedArgs, _args: string[]): Promise<void> {
325
+ const leaf = parsed.positional[0];
326
+ const rest = { ...parsed, positional: parsed.positional.slice(1) };
327
+ switch (leaf) {
328
+ case 'listeners': return cmdListeners(rest);
329
+ case 'cardinality': return cmdCardinality(rest);
330
+ case 'frames': return cmdFrames(rest);
331
+ case 'resources': return cmdResources(rest);
332
+ case undefined:
333
+ console.log(INSPECT_USAGE);
334
+ return;
335
+ default:
336
+ throw invalidInput(`Unknown page inspect leaf: ${leaf}.`, 'unknown_command');
337
+ }
338
+ }