@crouton-kit/capture 1.3.8 → 1.3.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/capture +29841 -8539
- package/package.json +6 -3
- package/src/capture.ts +86 -173
- package/src/cdp/a11y.ts +113 -14
- package/src/cdp/args.ts +300 -205
- package/src/cdp/bridge/protocol.ts +93 -6
- package/src/cdp/bridge/server.ts +118 -53
- package/src/cdp/bridge/spawn.ts +53 -6
- package/src/cdp/client.ts +13 -3
- package/src/cdp/commands/bridge-serve.ts +20 -14
- package/src/cdp/commands/cdp.ts +259 -90
- package/src/cdp/commands/gate-guard.ts +43 -0
- package/src/cdp/commands/lib.ts +339 -213
- package/src/cdp/commands/measure/census.ts +76 -20
- package/src/cdp/commands/measure/check.ts +100 -26
- package/src/cdp/commands/measure/diff.ts +109 -21
- package/src/cdp/commands/measure/explain.ts +121 -19
- package/src/cdp/commands/measure/index.ts +31 -39
- package/src/cdp/commands/measure/map-ax.ts +52 -0
- package/src/cdp/commands/measure/map-focus.ts +41 -13
- package/src/cdp/commands/measure/map-layers.ts +49 -15
- package/src/cdp/commands/measure/map-scroll.ts +85 -15
- package/src/cdp/commands/measure/snap.ts +405 -25
- package/src/cdp/commands/measure/sweep.ts +217 -20
- package/src/cdp/commands/motion/index.ts +18 -17
- package/src/cdp/commands/motion/jank.ts +119 -9
- package/src/cdp/commands/motion/mask.ts +81 -12
- package/src/cdp/commands/motion/rec.ts +520 -19
- package/src/cdp/commands/motion/response.ts +65 -15
- package/src/cdp/commands/motion/timeline.ts +139 -14
- package/src/cdp/commands/page/click.ts +194 -0
- package/src/cdp/commands/page/elements.ts +154 -0
- package/src/cdp/commands/page/exec.ts +317 -0
- package/src/cdp/commands/page/index.ts +72 -0
- package/src/cdp/commands/page/navigate.ts +330 -0
- package/src/cdp/commands/page/scroll.ts +107 -0
- package/src/cdp/commands/page/shot.ts +208 -0
- package/src/cdp/commands/page/type.ts +104 -0
- package/src/cdp/commands/tab/index.ts +59 -0
- package/src/cdp/commands/tab/list.ts +167 -0
- package/src/cdp/commands/tab/network.ts +162 -0
- package/src/cdp/commands/tab/open.ts +90 -0
- package/src/cdp/commands/tab/reset.ts +160 -0
- package/src/cdp/connection.ts +368 -37
- package/src/cdp/detect.ts +42 -13
- package/src/cdp/dispatch.ts +74 -28
- package/src/cdp/har-recorder.ts +648 -115
- package/src/cdp/leaf-grammar.ts +99 -0
- package/src/cdp/measure/census.ts +205 -0
- package/src/cdp/measure/check.ts +318 -0
- package/src/cdp/measure/collectors/animation.ts +385 -0
- package/src/cdp/measure/collectors/ax.ts +278 -0
- package/src/cdp/measure/collectors/focus.ts +1041 -0
- package/src/cdp/measure/collectors/forms.ts +905 -0
- package/src/cdp/measure/collectors/geometry.ts +1038 -0
- package/src/cdp/measure/collectors/hittest.ts +835 -0
- package/src/cdp/measure/collectors/layers.ts +697 -0
- package/src/cdp/measure/collectors/media.ts +522 -0
- package/src/cdp/measure/collectors/pixels.ts +1584 -0
- package/src/cdp/measure/collectors/queries.ts +464 -0
- package/src/cdp/measure/collectors/scroll.ts +599 -0
- package/src/cdp/measure/collectors/states.ts +992 -0
- package/src/cdp/measure/collectors/style-provenance.ts +643 -0
- package/src/cdp/measure/collectors/styles.ts +355 -0
- package/src/cdp/measure/collectors/text.ts +713 -0
- package/src/cdp/measure/diff.ts +470 -0
- package/src/cdp/measure/explain.ts +498 -0
- package/src/cdp/measure/map-ax.ts +633 -0
- package/src/cdp/measure/map-focus.ts +188 -0
- package/src/cdp/measure/map-layers.ts +177 -0
- package/src/cdp/measure/map-scroll.ts +317 -0
- package/src/cdp/measure/redaction.ts +51 -0
- package/src/cdp/measure/settle.ts +1051 -0
- package/src/cdp/measure/snapshot.ts +390 -0
- package/src/cdp/measure/sweep.ts +330 -0
- package/src/cdp/measure/types.ts +220 -0
- package/src/cdp/measure/v2/collectors.ts +19 -0
- package/src/cdp/measure/v2/orchestrator.ts +46 -0
- package/src/cdp/measure/v2/snap-selection.ts +33 -0
- package/src/cdp/measure/v2/target-lock.ts +15 -0
- package/src/cdp/measure/v2/target.ts +16 -0
- package/src/cdp/motion/jank.ts +433 -0
- package/src/cdp/motion/mask.ts +421 -0
- package/src/cdp/motion/recorder.ts +764 -0
- package/src/cdp/motion/response.ts +356 -0
- package/src/cdp/motion/timeline.ts +215 -0
- package/src/cdp/record.ts +5 -212
- package/src/cdp/recorder-bridge.ts +1478 -105
- package/src/cdp/recorder-client.ts +326 -0
- package/src/cdp/scope-lock.ts +73 -0
- package/src/cdp/screenshot.ts +199 -66
- package/src/cdp/targets.ts +21 -1
- package/src/cdp/timing.ts +15 -10
- package/src/cdp/types.ts +22 -7
- package/src/cdp/viewport.ts +21 -0
- package/src/cdp.ts +12 -58
- package/src/command-help.ts +29 -0
- package/src/command-resolution.ts +22 -0
- package/src/command-walker.ts +35 -0
- package/src/contracts/a11y.ts +33 -0
- package/src/contracts/cursor.ts +35 -0
- package/src/contracts/index.ts +15 -0
- package/src/contracts/motion.ts +19 -0
- package/src/contracts/neutral.ts +56 -0
- package/src/contracts/primitives.ts +205 -0
- package/src/contracts/release.ts +17 -0
- package/src/contracts/results.ts +208 -0
- package/src/contracts/routes.ts +140 -0
- package/src/contracts/snapshot.ts +92 -0
- package/src/cursor/index.ts +13 -0
- package/src/cursor/service.ts +310 -0
- package/src/errors.ts +71 -0
- package/src/har-manager.ts +992 -41
- package/src/interact.ts +410 -98
- package/src/output/artifact-lifecycle.ts +371 -0
- package/src/output/artifact.ts +6 -0
- package/src/output/exact-raw.ts +114 -0
- package/src/output/render.ts +396 -2
- package/src/output/structured.ts +211 -0
- package/src/registry.ts +208 -0
- package/src/session/artifacts.ts +452 -200
- package/src/session/commands.ts +1162 -397
- package/src/session/coordinator.ts +251 -0
- package/src/session/har-filter.ts +57 -0
- package/src/session/log-tailer.ts +653 -0
- package/src/session-context.ts +279 -51
- package/src/vault/bundle.ts +32 -17
- package/src/cdp/commands/exec.ts +0 -105
- package/src/cdp/commands/har.ts +0 -128
- package/src/cdp/commands/tabs.ts +0 -183
- package/src/cdp/commands/traffic.ts +0 -121
- package/src/cdp/commands/ui.ts +0 -172
- package/src/cdp/execute.ts +0 -113
- package/src/cdp/har-output.ts +0 -47
- package/src/cdp/usage.ts +0 -79
- package/src/tab-session.ts +0 -91
|
@@ -1,27 +1,152 @@
|
|
|
1
1
|
import { type ParsedArgs } from '../../types.js';
|
|
2
|
-
import {
|
|
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 = `
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
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
|
-
|
|
23
|
-
|
|
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.
|
|
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
|
+
}
|