@crouton-kit/capture 1.3.8 → 1.3.9

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
@@ -1,27 +1,152 @@
1
1
  import { type ParsedArgs } from '../../types.js';
2
- import { emitResult, fact, type RenderableResult } from '../../../output/render.js';
2
+ import {
3
+ ArtifactResolutionError,
4
+ resolveRecRef,
5
+ } from '../../../output/artifact.js';
6
+ import {
7
+ MotionTimelineSelectionError,
8
+ analyzeMotionTimeline,
9
+ readTimelineMeta,
10
+ } from '../../motion/timeline.js';
11
+ import {
12
+ emitResult,
13
+ fact,
14
+ formatArtifactList,
15
+ line,
16
+ lineList,
17
+ text,
18
+ type FactLine,
19
+ type RenderableResult,
20
+ } from '../../../output/render.js';
3
21
 
4
- const USAGE = `Usage: capture motion timeline <rec> --element <sel> [--prop <prop>]
22
+ const USAGE = `capture motion timeline <rec> --element <sel> — per-frame geometry/scroll/property timeline for one element across a finalized recording
5
23
 
6
- Per-frame geometry/scroll/property values for one element across a
7
- finalized recording.
8
-
9
- Options:
10
- --element <sel> Element selector to track (required)
11
- --prop <prop> Additional CSS/computed property to sample per frame
12
-
13
- Not yet implemented.`;
24
+ input:
25
+ <rec> recording id in the active session or an absolute recording path (required; the recording must be finalized)
26
+ --element <sel> element selector to track (required): a tag, #id, .class, or their simple combination; descendant/child/sibling combinators, pseudo-classes, and attribute selectors are not supported — the recording retains no DOM tree, so the selector is matched against sampled bounding boxes only
27
+ --prop <prop> report one sampled geometry, scroll, or recorded property per frame
28
+ output: <timeline …> — per-frame bounding-box geometry, sampled scroll offsets, and optional sampled property values; the recorder samples bounding boxes, not DOM quads, and frame-derived timestamps carry ±1-frame uncertainty; --json mirrors
29
+ effects: read-only — reads the finalized recording artifact, never drives the browser`;
14
30
 
15
31
  export async function cmdMotionTimeline(parsed: ParsedArgs, _args: string[]): Promise<void> {
16
32
  if (parsed.help) {
17
33
  console.log(USAGE);
18
- process.exit(0);
34
+ return;
35
+ }
36
+
37
+ const recArg = parsed.positional[0];
38
+ if (!recArg || parsed.positional.length !== 1 || !parsed.element?.trim()) {
39
+ return emitCommandError(
40
+ parsed,
41
+ 'invalid_input',
42
+ 'Expected exactly one recording id/path and a non-empty --element selector.',
43
+ 'capture motion timeline <rec> --element <sel> [--prop <prop>]',
44
+ );
45
+ }
46
+
47
+ try {
48
+ const ref = resolveRecRef(recArg);
49
+ const meta = readTimelineMeta(ref);
50
+ const timeline = analyzeMotionTimeline(ref, parsed.element, parsed.prop);
51
+ if (parsed.prop && !timeline.propertyAvailable) {
52
+ return emitCommandError(
53
+ parsed,
54
+ 'property_unavailable',
55
+ `The recording has no sampled value for property ${JSON.stringify(parsed.prop)} on the selected element. rects.jsonl records bounding boxes and only includes scroll/property values when the recorder sampled them.`,
56
+ `Re-run without --prop for bounding-box geometry, or record with a substrate that samples ${JSON.stringify(parsed.prop)}.`,
57
+ ref,
58
+ meta.state,
59
+ );
60
+ }
61
+
62
+ const rows = timeline.points.map((point) => formatPoint(point, parsed.prop));
63
+ const result: RenderableResult = {
64
+ tag: 'timeline',
65
+ attestation: {
66
+ kind: 'recording',
67
+ id: ref.id,
68
+ path: ref.dir,
69
+ note: timeline.timingDomain === 'screencast-relative'
70
+ ? text`Times are milliseconds from the first screencast frame; each is frame-derived (±1 frame), not an exact performance.now() timestamp.`
71
+ : text`Screencast timestamps were unavailable; rows are ordered by sampled frame and have frame-derived timing uncertainty.`,
72
+ },
73
+ attrs: {
74
+ state: meta.state,
75
+ frames: timeline.frameCount,
76
+ samples: timeline.points.length,
77
+ element: parsed.element,
78
+ prop: parsed.prop,
79
+ 'timestamp-uncertainty': '±1 frame',
80
+ geometry: 'bounding-box',
81
+ },
82
+ summary: fact`${timeline.points.length} sampled frame(s) matched selector ${parsed.element}; identity continuity uses ${timeline.selectionMethod}. ${timeline.eventsRead} event record(s) were read with the recording.`,
83
+ artifacts: formatArtifactList([
84
+ { name: 'rects.jsonl', note: 'per-frame sampled bounding boxes and optional scroll/property values' },
85
+ { name: 'events.jsonl', note: `${timeline.eventsRead} recording event records read` },
86
+ { name: 'markers.json', note: 'clock baseline provenance' },
87
+ { name: 'meta.json', note: `recording state ${meta.state}` },
88
+ ]),
89
+ sections: rows.length ? [lineList(rows)] : [text`The selected element had no complete bounding-box samples.`],
90
+ followUp: text`Use \`capture motion jank <rec>\` for dropped-frame and observer-event facts, or \`capture motion response <rec>\` for input-to-settle timing facts.`,
91
+ };
92
+ emitResult(result, { json: parsed.json });
93
+ } catch (err) {
94
+ if (err instanceof MotionTimelineSelectionError) {
95
+ return emitCommandError(
96
+ parsed,
97
+ 'element_not_found',
98
+ err.message,
99
+ 'Pass a tag, #id, .class, or simple tag/id/class combination that appears in rects.jsonl.',
100
+ );
101
+ }
102
+ if (err instanceof ArtifactResolutionError) {
103
+ return emitCommandError(parsed, 'artifact_unavailable', err.message, err.creatingCommand ?? 'Create a finalized recording, then re-run this query.');
104
+ }
105
+ return emitCommandError(parsed, 'timeline_failed', err instanceof Error ? err.message : String(err), 'Inspect the finalized recording artifacts and re-run the query.');
19
106
  }
107
+ }
108
+
109
+ function formatPoint(
110
+ point: ReturnType<typeof analyzeMotionTimeline>['points'][number],
111
+ requestedProp?: string,
112
+ ): FactLine {
113
+ const time = point.timeMs === null ? fact`frame=${point.frame}` : fact`t=${point.timeMs.toFixed(1)}ms frame=${point.frame}`;
114
+ const geometry = fact`x=${point.x} y=${point.y} w=${point.width} h=${point.height}`;
115
+ const scroll: FactLine[] = [];
116
+ if (point.scrollTop !== undefined) scroll.push(fact`scrollTop=${point.scrollTop}`);
117
+ if (point.scrollLeft !== undefined) scroll.push(fact`scrollLeft=${point.scrollLeft}`);
118
+ const prop = point.property
119
+ ? fact`${point.property.name}=${String(point.property.value)}`
120
+ : requestedProp
121
+ ? text`property=not-sampled`
122
+ : text``;
123
+ return line(time, text` · `, geometry, ...(scroll.length ? [text` · `, line(...joinWithSeparator(scroll))] : []), text` · `, prop, text` · timestamp uncertainty ±1 frame`);
124
+ }
125
+
126
+ function joinWithSeparator(parts: readonly FactLine[]): FactLine[] {
127
+ const out: FactLine[] = [];
128
+ parts.forEach((part, index) => {
129
+ if (index) out.push(text` `);
130
+ out.push(part);
131
+ });
132
+ return out;
133
+ }
134
+
135
+ function emitCommandError(
136
+ parsed: ParsedArgs,
137
+ status: string,
138
+ message: string,
139
+ recovery: string,
140
+ ref?: { id: string; dir: string },
141
+ state?: string,
142
+ ): void {
20
143
  const result: RenderableResult = {
21
144
  tag: 'error',
22
- attrs: { command: 'motion timeline', status: 'not_implemented' },
23
- summary: fact`\`motion timeline\` is not implemented yet.`,
145
+ ...(ref ? { attestation: { kind: 'recording' as const, id: ref.id, path: ref.dir } } : {}),
146
+ attrs: { command: 'motion timeline', status, state },
147
+ summary: fact`${message}`,
148
+ sections: [fact`Recovery: ${recovery}`],
24
149
  };
25
150
  emitResult(result, { json: parsed.json });
26
- process.exit(1);
151
+ process.exitCode = 1;
27
152
  }
@@ -0,0 +1,194 @@
1
+ /**
2
+ * `page click <target>` — dispatch one real click on exactly one live-resolved
3
+ * element (design D3).
4
+ *
5
+ * This file also owns the small substrate the three input-dispatching page
6
+ * verbs (`click`, `type`, `scroll`) share: the test-injectable dependency
7
+ * seam, the active-session-keyed settle defaults (Resolved 2), and the
8
+ * structured rendering of `resolveLiveTarget` failures (candidate list +
9
+ * `backend:<id>` recovery, `text:` prefix rejection). `type.ts` and
10
+ * `scroll.ts` import these rather than duplicating them.
11
+ */
12
+ import { type ParsedArgs } from '../../types.js';
13
+ import { withPageAction } from '../../connection.js';
14
+ import { autoScreenshot } from '../../screenshot.js';
15
+ import { getActiveSession } from '../../../session-context.js';
16
+ import {
17
+ resolveLiveTarget,
18
+ clickResolved,
19
+ type LiveClient,
20
+ type ResolutionFailure,
21
+ } from '../../../interact.js';
22
+ import {
23
+ emitResult,
24
+ fact,
25
+ line,
26
+ lineList,
27
+ text,
28
+ data,
29
+ type FactLine,
30
+ } from '../../../output/render.js';
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Shared substrate for the three input-dispatching page verbs
34
+ // ---------------------------------------------------------------------------
35
+
36
+ export interface PageInputDeps {
37
+ withPageAction: typeof withPageAction;
38
+ getActiveSession: typeof getActiveSession;
39
+ autoScreenshot: typeof autoScreenshot;
40
+ }
41
+
42
+ let deps: PageInputDeps = { withPageAction, getActiveSession, autoScreenshot };
43
+
44
+ /** Swap the connection/session/screenshot seams for the CDP-stub tests. */
45
+ export function __setPageInputDepsForTest(overrides: Partial<PageInputDeps>): () => void {
46
+ const previous = deps;
47
+ deps = { ...deps, ...overrides };
48
+ return () => { deps = previous; };
49
+ }
50
+
51
+ export function pageInputDeps(): PageInputDeps {
52
+ return deps;
53
+ }
54
+
55
+ /**
56
+ * The verb's settle window: `--settle <ms>` (including 0) wins outright;
57
+ * otherwise the default is keyed off active-session presence (the in-session
58
+ * bump exists so the session HAR captures the network the action triggers).
59
+ */
60
+ export function effectiveSettle(
61
+ parsed: ParsedArgs,
62
+ defaults: { standalone: number; session: number },
63
+ ): number {
64
+ if (parsed.settle !== undefined) return parsed.settle;
65
+ return deps.getActiveSession() !== null ? defaults.session : defaults.standalone;
66
+ }
67
+
68
+ /** Structured `<error>` for a plain invalid-invocation case (exit 1). */
69
+ export function emitInvalidInput(parsed: ParsedArgs, command: string, summary: FactLine, followUp?: FactLine): void {
70
+ emitResult(
71
+ { tag: 'error', attrs: { command, code: 'invalid_input' }, summary, followUp },
72
+ { json: parsed.json },
73
+ );
74
+ process.exitCode = 1;
75
+ }
76
+
77
+ /**
78
+ * Structured `<error>` for a live-resolution failure (exit 1): zero/many
79
+ * matches carry the candidate list (role, name, `backend:<id>`) as the
80
+ * recovery payload; a `text:` target names the accepted prefixes.
81
+ */
82
+ export function emitResolutionError(parsed: ParsedArgs, command: string, failure: ResolutionFailure): void {
83
+ if (failure.code === 'unsupported-prefix') {
84
+ emitResult(
85
+ {
86
+ tag: 'error',
87
+ attrs: { command, code: 'unsupported_prefix' },
88
+ summary: fact`received: target \`${failure.input}\` — the \`text:\` prefix is not accepted by live driving verbs; expected one of: bare CSS selector, ax:<name>, axid:<id>, backend:<id>.`,
89
+ followUp: fact`Retry \`${command}\` with \`ax:<name>\` (accessible-name substring) or a CSS selector; \`text:\` matching exists only on the query leaves.`,
90
+ },
91
+ { json: parsed.json },
92
+ );
93
+ process.exitCode = 1;
94
+ return;
95
+ }
96
+
97
+ const code = failure.code === 'no-match' ? 'no_match' : 'ambiguous_target';
98
+ const candidateRows: FactLine[] = failure.candidates.map((c) =>
99
+ line(data(c.role ?? 'unknown'), text` "`, data(c.name ?? ''), text`" — backend:`, data(c.backendNodeId)),
100
+ );
101
+ const sections: FactLine[] = [];
102
+ if (candidateRows.length > 0) {
103
+ const shown =
104
+ failure.matchCount > failure.candidates.length
105
+ ? fact`candidates (first ${failure.candidates.length} of ${failure.matchCount}):`
106
+ : text`candidates:`;
107
+ sections.push(lineList([shown, ...candidateRows]));
108
+ }
109
+ emitResult(
110
+ {
111
+ tag: 'error',
112
+ attrs: { command, code },
113
+ summary: fact`received: target \`${failure.input}\` matched ${failure.matchCount} live elements; expected exactly one.`,
114
+ sections,
115
+ followUp:
116
+ failure.code === 'no-match'
117
+ ? text`Run \`capture page elements\` to list live targets with their backend:<id> keys.`
118
+ : fact`Retry \`${command}\` with \`backend:<id>\` from the candidate list.`,
119
+ },
120
+ { json: parsed.json },
121
+ );
122
+ process.exitCode = 1;
123
+ }
124
+
125
+ // ---------------------------------------------------------------------------
126
+ // page click
127
+ // ---------------------------------------------------------------------------
128
+
129
+ const USAGE = `capture page click <target> — dispatch one real click on exactly one resolved element
130
+
131
+ input:
132
+ <target> resolved against the LIVE page: bare CSS selector, ax:<name> (case-insensitive substring over accessible names), axid:<id>, backend:<id>. text: is not accepted. Exactly one match required — zero or many matches is a structured error listing candidates with backend:<id> retry keys.
133
+ --settle <ms> network-settle window applied after the click (default: 1000; 2500 with an active session; 0 disables)
134
+ --no-screenshot skip the auto-screenshot
135
+ output:
136
+ <clicked backend-node-id=… role=… name=…> — resolved identity, dispatched coordinates, the measured settle (requested/waited), screenshot artifact path; --json mirrors the same fields
137
+ effects:
138
+ scrolls the target into view, then dispatches a real mouse press/release at its center; writes one screenshot into the active session's shots/ sequence unless --no-screenshot`;
139
+
140
+ export async function cmdPageClick(parsed: ParsedArgs, _args: string[]): Promise<void> {
141
+ if (parsed.help) {
142
+ console.log(USAGE);
143
+ return;
144
+ }
145
+ const target = parsed.positional[0];
146
+ if (!target || parsed.positional.length !== 1) {
147
+ return emitInvalidInput(
148
+ parsed,
149
+ 'page click',
150
+ fact`received: ${parsed.positional.length} positional arguments; expected exactly one target (CSS selector, ax:<name>, axid:<id>, or backend:<id>).`,
151
+ );
152
+ }
153
+
154
+ const settle = effectiveSettle(parsed, { standalone: 1000, session: 2500 });
155
+ // connection.ts derives the recorder landmark label from parsed.command,
156
+ // which the router leaves as the branch token 'page' — restore the verb so
157
+ // a routed click's landmark stays `click:<target>`.
158
+ const { result: outcome, settle: settleFacts } = await deps.withPageAction(
159
+ { ...parsed, command: 'click' },
160
+ { settleMs: settle },
161
+ async (client) => {
162
+ const live = client as unknown as LiveClient;
163
+ const resolved = await resolveLiveTarget(live, target);
164
+ if (!resolved.ok) return { failure: resolved } as const;
165
+ const dispatch = await clickResolved(live, resolved);
166
+ const screenshot = await deps.autoScreenshot(client, 'click', target, parsed.noScreenshot);
167
+ return { dispatch, screenshot } as const;
168
+ },
169
+ );
170
+
171
+ if ('failure' in outcome) {
172
+ return emitResolutionError(parsed, 'page click', outcome.failure);
173
+ }
174
+
175
+ const { dispatch, screenshot } = outcome;
176
+ const rows: FactLine[] = [
177
+ fact`clicked ${dispatch.role ?? 'unknown'} "${dispatch.name ?? ''}" (backend:${dispatch.backendNodeId}) at x=${dispatch.x} y=${dispatch.y}`,
178
+ fact`settle: requested ${settleFacts.requestedMs}ms, waited ${settleFacts.waitedMs}ms`,
179
+ ];
180
+ if (screenshot) rows.push(fact`screenshot: ${screenshot}`);
181
+
182
+ emitResult(
183
+ {
184
+ tag: 'clicked',
185
+ attrs: {
186
+ 'backend-node-id': dispatch.backendNodeId,
187
+ role: dispatch.role ?? undefined,
188
+ name: dispatch.name ?? undefined,
189
+ },
190
+ summary: lineList(rows),
191
+ },
192
+ { json: parsed.json },
193
+ );
194
+ }
@@ -0,0 +1,154 @@
1
+ /**
2
+ * `page elements` — the live targeting navigator (the D1 split's first
3
+ * half): what can be acted on in the live tab right now, selection-shaped
4
+ * for the next `page` call.
5
+ *
6
+ * Default lists interactive elements only; every record carries the three
7
+ * discriminators the next `page click`/`type` needs — role, accessible
8
+ * name, `backendNodeId` (I-3) — straight from the live
9
+ * `Accessibility.getFullAXTree` fetch. `--all` widens to the full exposed
10
+ * tree. There is no computed-CSS-selector field: `backend:<id>` is the
11
+ * canonical retry/targeting key (D1).
12
+ *
13
+ * Read-only: the AX fetch is CDP-side — no page-observable writes (I-1/
14
+ * I-2). During a live composed recording the connection routes through the
15
+ * recorder unmarked (no input landmark — this leaf dispatches no input).
16
+ * A truncated list states the total as an explicit `elements-truncated`
17
+ * fact (I-5); "0 interactive elements" is itself the measurement (I-8).
18
+ */
19
+ import { type ParsedArgs } from '../../types.js';
20
+ import { invalidInput } from '../../../errors.js';
21
+ import { withConnection } from '../../connection.js';
22
+ import { INTERACTIVE_ROLES, readFullAXTree } from '../../a11y.js';
23
+ import {
24
+ emitResult,
25
+ fact,
26
+ text,
27
+ lineList,
28
+ type FactLine,
29
+ type RenderableResult,
30
+ } from '../../../output/render.js';
31
+
32
+ /** `--limit` default (Resolved 4): keeps the list selection-shaped on dense
33
+ * pages while covering normal pages untruncated. */
34
+ export const DEFAULT_LIMIT = 100;
35
+
36
+ const USAGE = `capture page elements — list what can be acted on in the live tab: role, accessible name, backend:<id> per element.
37
+
38
+ Input:
39
+ --all full exposed accessibility tree instead of interactive elements only
40
+ --limit <n> max elements listed (positive integer, default ${DEFAULT_LIMIT}; a capped list states the total as an elements-truncated fact)
41
+ --target <tabId> | --url <pattern> | --port <n> tab targeting; defaults to the active session tab
42
+ --json mirror the result as JSON
43
+
44
+ Output:
45
+ <elements scope="interactive|all" count=<total>> — one row per element: role "name" backend:<id>
46
+
47
+ Effects: read-only — the accessibility fetch is CDP-side, no page-observable writes; routes unmarked through an active composed recording.`;
48
+
49
+ /** The minimal CDP surface this leaf reads through — `CDPClient` and the
50
+ * recorder-held adapter both satisfy it structurally. */
51
+ export interface ElementsClient {
52
+ send(method: string, params?: Record<string, unknown>): Promise<unknown>;
53
+ }
54
+
55
+ /** One listed element. `backendNodeId` is always present on the default
56
+ * (interactive) records — the drivability discriminator — and `null` only
57
+ * for `--all` tree nodes the browser exposes without a DOM node. */
58
+ export interface ElementRecord {
59
+ readonly role: string;
60
+ readonly name: string;
61
+ readonly backendNodeId: number | null;
62
+ }
63
+
64
+ /**
65
+ * Fetches the live AX tree and flattens it to element records in document
66
+ * order. Default keeps interactive roles with a `backendNodeId` (the
67
+ * actionable set); `all` keeps every non-ignored node with a role. Pure
68
+ * CDP reads — no page-observable calls.
69
+ */
70
+ export async function collectElements(
71
+ client: ElementsClient,
72
+ opts: { all?: boolean } = {},
73
+ ): Promise<ElementRecord[]> {
74
+ const nodes = await readFullAXTree(client);
75
+
76
+ const records: ElementRecord[] = [];
77
+ for (const node of nodes) {
78
+ if (node.ignored) continue;
79
+ const role = node.role?.value ?? '';
80
+ if (!role) continue;
81
+ if (!opts.all) {
82
+ if (!INTERACTIVE_ROLES.has(role)) continue;
83
+ if (node.backendDOMNodeId === undefined) continue;
84
+ }
85
+ records.push({
86
+ role,
87
+ name: node.name?.value ?? '',
88
+ backendNodeId: node.backendDOMNodeId ?? null,
89
+ });
90
+ }
91
+ return records;
92
+ }
93
+
94
+ /**
95
+ * Assembles the `<elements>` result. Every page-derived value (role, name)
96
+ * flows through the `fact` interpolation lane — escaped and capped (I-9).
97
+ * Truncation is an explicit fact carrying the total count (I-5); an empty
98
+ * list renders count 0 with no advice (I-8).
99
+ */
100
+ export function buildElementsResult(
101
+ records: readonly ElementRecord[],
102
+ opts: { all: boolean; limit: number },
103
+ ): RenderableResult {
104
+ const total = records.length;
105
+ const shown = records.slice(0, opts.limit);
106
+
107
+ const rows = shown.map((r) =>
108
+ r.backendNodeId !== null
109
+ ? fact`${r.role} "${r.name}" backend:${r.backendNodeId}`
110
+ : fact`${r.role} "${r.name}"`,
111
+ );
112
+
113
+ const sections: FactLine[] = [];
114
+ if (rows.length > 0) sections.push(lineList(rows));
115
+ if (total > shown.length) {
116
+ sections.push(fact`elements-truncated: listing capped at ${shown.length} of ${total} elements (--limit)`);
117
+ }
118
+
119
+ return {
120
+ tag: 'elements',
121
+ attrs: { scope: opts.all ? 'all' : 'interactive', count: total },
122
+ summary: opts.all
123
+ ? fact`${total} elements in the exposed accessibility tree`
124
+ : fact`${total} interactive elements`,
125
+ sections,
126
+ followUp: text`capture page click <target> · capture measure map ax <url|snap>`,
127
+ };
128
+ }
129
+
130
+ export async function cmdPageElements(parsed: ParsedArgs, _args: string[]): Promise<void> {
131
+ if (parsed.help) {
132
+ console.log(USAGE);
133
+ return;
134
+ }
135
+
136
+ // Positional cardinality (exactly 0) is enforced by `validateCliInvocation`
137
+ // before dispatch reaches this leaf; this guard covers direct programmatic
138
+ // callers only. Failures cross the boundary as typed CaptureErrors —
139
+ // capture.ts is the sole renderer/exit-status owner.
140
+ const limit = parsed.limit ?? DEFAULT_LIMIT;
141
+ if (!Number.isInteger(limit) || limit < 1) {
142
+ throw invalidInput(`received: --limit ${String(parsed.limit)}; expected: a positive integer.`, 'invalid_flag');
143
+ }
144
+
145
+ const records = await withConnection(
146
+ parsed,
147
+ (client) => collectElements(client, { all: parsed.all }),
148
+ { settle: 0 },
149
+ );
150
+
151
+ emitResult(buildElementsResult(records, { all: Boolean(parsed.all), limit }), {
152
+ json: parsed.json,
153
+ });
154
+ }