@crouton-kit/capture 1.4.1 → 1.4.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +178 -0
- package/bin/capture +31299 -7322
- package/package.json +3 -1
- package/src/capture.ts +21 -18
- package/src/cdp/args.ts +269 -49
- package/src/cdp/bridge/server.ts +6 -5
- package/src/cdp/bridge/spawn.ts +41 -97
- package/src/cdp/commands/bridge-serve.ts +11 -21
- package/src/cdp/commands/cdp.ts +44 -22
- package/src/cdp/commands/heap/census.ts +61 -0
- package/src/cdp/commands/heap/common.ts +77 -0
- package/src/cdp/commands/heap/diff.ts +92 -0
- package/src/cdp/commands/heap/index.ts +38 -0
- package/src/cdp/commands/heap/objects.ts +42 -0
- package/src/cdp/commands/heap/retainers.ts +52 -0
- package/src/cdp/commands/heap/snapshot.ts +94 -0
- package/src/cdp/commands/lib.ts +9 -17
- package/src/cdp/commands/lighthouse.ts +414 -0
- package/src/cdp/commands/measure/census.ts +2 -1
- package/src/cdp/commands/measure/check.ts +70 -16
- package/src/cdp/commands/measure/diff.ts +1 -2
- package/src/cdp/commands/measure/explain.ts +44 -7
- package/src/cdp/commands/measure/index.ts +24 -32
- package/src/cdp/commands/measure/map-ax.ts +2 -1
- package/src/cdp/commands/measure/map-focus.ts +2 -1
- package/src/cdp/commands/measure/map-layers.ts +5 -3
- package/src/cdp/commands/measure/map-scroll.ts +2 -1
- package/src/cdp/commands/measure/snap.ts +5 -4
- package/src/cdp/commands/measure/sweep.ts +16 -11
- package/src/cdp/commands/measure/text.ts +147 -0
- package/src/cdp/commands/motion/index.ts +10 -18
- package/src/cdp/commands/motion/jank.ts +13 -22
- package/src/cdp/commands/motion/mask.ts +10 -9
- package/src/cdp/commands/motion/rec.ts +230 -28
- package/src/cdp/commands/motion/timeline.ts +1 -1
- package/src/cdp/commands/page/click.ts +18 -8
- package/src/cdp/commands/page/elements.ts +43 -11
- package/src/cdp/commands/page/exec.ts +53 -18
- package/src/cdp/commands/page/index.ts +20 -20
- package/src/cdp/commands/page/inspect.ts +338 -0
- package/src/cdp/commands/page/navigate.ts +83 -38
- package/src/cdp/commands/page/repeat.ts +176 -0
- package/src/cdp/commands/page/scroll.ts +11 -6
- package/src/cdp/commands/page/shot.ts +68 -33
- package/src/cdp/commands/page/type.ts +7 -3
- package/src/cdp/commands/perf/index.ts +32 -0
- package/src/cdp/commands/perf/insights.ts +214 -0
- package/src/cdp/commands/perf/trace.ts +235 -0
- package/src/cdp/commands/perf/vitals.ts +76 -0
- package/src/cdp/commands/session/collectors.ts +54 -0
- package/src/cdp/commands/stub.ts +10 -0
- package/src/cdp/commands/tab/close.ts +2 -2
- package/src/cdp/commands/tab/index.ts +16 -23
- package/src/cdp/commands/tab/launch.ts +1 -1
- package/src/cdp/commands/tab/mock/index.ts +24 -0
- package/src/cdp/commands/tab/mock/lifecycle.ts +88 -0
- package/src/cdp/commands/tab/mock/start.ts +65 -0
- package/src/cdp/commands/tab/mock/stop.ts +39 -0
- package/src/cdp/commands/tab/network.ts +22 -13
- package/src/cdp/commands/tab/reset.ts +11 -12
- package/src/cdp/connection.ts +19 -39
- package/src/cdp/detect.ts +27 -7
- package/src/cdp/dispatch.ts +25 -4
- package/src/cdp/domains.ts +2 -2
- package/src/cdp/har-recorder.ts +0 -1
- package/src/cdp/heap-snapshot.ts +647 -0
- package/src/cdp/host/client.ts +22 -0
- package/src/cdp/host/collector.ts +74 -0
- package/src/cdp/host/collectors/heap.ts +79 -0
- package/src/cdp/host/collectors/intercept.ts +247 -0
- package/src/cdp/{recorder-bridge.ts → host/collectors/motion.ts} +388 -473
- package/src/cdp/host/collectors/trace.ts +108 -0
- package/src/cdp/host/core.ts +217 -0
- package/src/cdp/host/handle.ts +47 -0
- package/src/cdp/host/held-client.ts +91 -0
- package/src/cdp/host/kinds.ts +97 -0
- package/src/cdp/host/lifecycle.ts +123 -0
- package/src/cdp/host/server.ts +88 -0
- package/src/cdp/leaf-grammar.ts +2 -2
- package/src/cdp/measure/check.ts +181 -16
- package/src/cdp/measure/collectors/hittest.ts +16 -10
- package/src/cdp/measure/collectors/scroll.ts +15 -7
- package/src/cdp/measure/collectors/style-provenance.ts +11 -0
- package/src/cdp/measure/collectors/styles.ts +4 -4
- package/src/cdp/measure/explain.ts +14 -2
- package/src/cdp/measure/map-layers.ts +52 -8
- package/src/cdp/measure/snapshot.ts +5 -3
- package/src/cdp/measure/sweep.ts +29 -6
- package/src/cdp/measure/text.ts +227 -0
- package/src/cdp/motion/jank.ts +29 -83
- package/src/cdp/motion/mask.ts +18 -9
- package/src/cdp/motion/recorder.ts +86 -731
- package/src/cdp/scope-lock.ts +9 -9
- package/src/cdp/screenshot.ts +51 -21
- package/src/cdp/timing.ts +5 -5
- package/src/cdp/trace-analysis.ts +320 -0
- package/src/cdp/types.ts +31 -1
- package/src/cdp.ts +4 -6
- package/src/har-manager.ts +2 -4
- package/src/interact.ts +78 -25
- package/src/output/artifact.ts +49 -5
- package/src/output/render.ts +60 -9
- package/src/output/selection.ts +6 -0
- package/src/session/artifacts.ts +122 -12
- package/src/session/commands.ts +348 -113
- package/src/session/coordinator.ts +0 -93
- package/src/session/har-redact.ts +29 -23
- package/src/session-context.ts +13 -9
- package/src/cdp/bridge/protocol.ts +0 -224
- package/src/cdp/recorder-client.ts +0 -326
- package/src/cdp/tab-lock.ts +0 -132
|
@@ -6,7 +6,7 @@ import { type ParsedArgs } from '../../types.js';
|
|
|
6
6
|
import { CDPClient } from '../../client.js';
|
|
7
7
|
import { findTabById, openTab } from '../../targets.js';
|
|
8
8
|
import { detectCdpPort } from '../../detect.js';
|
|
9
|
-
import { RecorderSession } from '../../
|
|
9
|
+
import { RecorderSession } from '../../host/collectors/motion.js';
|
|
10
10
|
import { parseViewport, type Viewport } from '../../viewport.js';
|
|
11
11
|
import {
|
|
12
12
|
emitResult,
|
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
import { getActiveSession } from '../../../session-context.js';
|
|
19
19
|
import { createOneshotSession } from '../../../session/commands.js';
|
|
20
20
|
import { parseDoAction } from '../../leaf-grammar.js';
|
|
21
|
-
import { ensurePrivateDir, writeBinaryPrivate, writeJsonPrivate, writeNdjsonPrivate, type RecMeta } from '../../../session/artifacts.js';
|
|
21
|
+
import { ensurePrivateDir, readPrivateFile, writeBinaryPrivate, writeJsonPrivate, writeNdjsonPrivate, writePrivateFile, type RecMeta } from '../../../session/artifacts.js';
|
|
22
22
|
import {
|
|
23
23
|
startComposedRecorder,
|
|
24
24
|
stopComposedRecorder,
|
|
@@ -34,6 +34,8 @@ import {
|
|
|
34
34
|
type ResolutionFailure,
|
|
35
35
|
} from '../../../interact.js';
|
|
36
36
|
import { CaptureError } from '../../../errors.js';
|
|
37
|
+
import { sendHostRequest } from '../../host/client.js';
|
|
38
|
+
import { scanCollectorHost } from '../../host/handle.js';
|
|
37
39
|
|
|
38
40
|
interface RecCommandDeps {
|
|
39
41
|
detectCdpPort: typeof detectCdpPort;
|
|
@@ -72,10 +74,11 @@ const USAGE = `capture motion rec — record the page over time: composed (whate
|
|
|
72
74
|
input:
|
|
73
75
|
[url] --do <action> one-shot: open <url> (or record the active session tab when <url> is omitted), record one action, finalize
|
|
74
76
|
action click:<target> | scroll:<target>,to=<top|bottom|px>
|
|
75
|
-
target css selector (bare string) | ax:<name> (case-insensitive substring) | axid:<id> | backend:<id>
|
|
77
|
+
target css selector (takes precedence) or exact accessible name when CSS finds none (bare string) | ax:<name> (case-insensitive substring) | axid:<id> | backend:<id>
|
|
76
78
|
must resolve to exactly one live element; text: is not accepted by driving actions
|
|
77
79
|
--duration <seconds> keep recording after the action (default: 0)
|
|
78
80
|
--viewport <WxH> emulate a viewport for the recording window (restored after); exact <positive-safe-int>x<positive-safe-int> grammar with lowercase x and no whitespace
|
|
81
|
+
--artifact-dir <path> root for a sessionless one-shot recording bundle
|
|
79
82
|
--start arm the composed recorder on the active session tab (requires \`capture session start\`)
|
|
80
83
|
--viewport <WxH> as above; restored on --stop
|
|
81
84
|
--stop finalize the composed recording
|
|
@@ -83,9 +86,10 @@ input:
|
|
|
83
86
|
|
|
84
87
|
output:
|
|
85
88
|
<recording> block — frames, fps, duration, state, event-records, video status, artifact list; --json mirrors.
|
|
89
|
+
Caveat: screencast frames are change-driven, not a dropped-frame measurement; use \`capture perf trace\` for frame-timing questions.
|
|
86
90
|
|
|
87
91
|
effects:
|
|
88
|
-
One-shot on a URL opens a new tab and writes a private one-shot artifact dir; with <url> omitted it records the active session tab and writes under the session. Composed writes under the active session. Scripted actions dispatch real input, marked as labeled landmarks in events.jsonl. Video encodes via ffmpeg when available.`;
|
|
92
|
+
One-shot on a URL opens a new tab and writes a private one-shot artifact dir under --artifact-dir when supplied; with <url> omitted it records the active session tab through the session collector host and writes under the session. If that active-session one-shot process is interrupted after recording starts, the collector remains live and \`capture motion rec --stop\` finalizes it. Composed writes under the active session. Scripted actions dispatch real input, marked as labeled landmarks in events.jsonl. Video encodes via ffmpeg when available.`;
|
|
89
93
|
|
|
90
94
|
export async function cmdMotionRec(parsed: ParsedArgs, _args: string[]): Promise<void> {
|
|
91
95
|
if (parsed.help) {
|
|
@@ -132,7 +136,12 @@ async function handleOneShot(parsed: ParsedArgs, viewport: Viewport | undefined)
|
|
|
132
136
|
}
|
|
133
137
|
}
|
|
134
138
|
|
|
135
|
-
|
|
139
|
+
// The active-session form uses the detached collector host so an external
|
|
140
|
+
// timeout cannot take its only recorder down with the CLI process. The
|
|
141
|
+
// host row is then discoverable through session collectors and --stop.
|
|
142
|
+
if (active && !parsedUrl && active.harId) return handleHostedSessionOneShot(parsed, active, viewport);
|
|
143
|
+
|
|
144
|
+
const oneshot = active ? undefined : deps.createOneshotSession('motion', parsed.artifactDir);
|
|
136
145
|
const destination = active ? path.join(active.dir, 'motion', 'recs') : oneshot!.artifactsDir;
|
|
137
146
|
const recId = `rec-${crypto.randomBytes(2).toString('hex')}`;
|
|
138
147
|
const recDir = path.join(destination, recId);
|
|
@@ -140,6 +149,7 @@ async function handleOneShot(parsed: ParsedArgs, viewport: Viewport | undefined)
|
|
|
140
149
|
ensurePrivateDir(path.join(recDir, 'frames'));
|
|
141
150
|
|
|
142
151
|
let client: CDPClient | undefined;
|
|
152
|
+
let originalViewport: ActualViewport | undefined;
|
|
143
153
|
let viewportMayHaveApplied = false;
|
|
144
154
|
let failure: string | null = null;
|
|
145
155
|
let failureStatus = 'oneshot_failed';
|
|
@@ -157,8 +167,12 @@ async function handleOneShot(parsed: ParsedArgs, viewport: Viewport | undefined)
|
|
|
157
167
|
client = deps.createClient(tab.webSocketDebuggerUrl);
|
|
158
168
|
await client.waitReady();
|
|
159
169
|
await client.send('Page.enable');
|
|
160
|
-
|
|
161
|
-
|
|
170
|
+
if (viewport) {
|
|
171
|
+
// Clear is unreliable on adopted headed tabs; restoration must set and verify this measured state.
|
|
172
|
+
originalViewport = await readActualViewport(client);
|
|
173
|
+
viewportMayHaveApplied = true;
|
|
174
|
+
await applyViewportOverride(client, viewport);
|
|
175
|
+
}
|
|
162
176
|
await waitForPageReady(client);
|
|
163
177
|
|
|
164
178
|
const recorder = deps.createRecorderSession({ client, recDir });
|
|
@@ -168,7 +182,7 @@ async function handleOneShot(parsed: ParsedArgs, viewport: Viewport | undefined)
|
|
|
168
182
|
const stopped = await recorder.stop();
|
|
169
183
|
let viewportRestored: boolean | null = null;
|
|
170
184
|
if (viewportMayHaveApplied) {
|
|
171
|
-
viewportRestored = await restoreSessionViewportForClient(client);
|
|
185
|
+
viewportRestored = await restoreSessionViewportForClient(client, originalViewport!);
|
|
172
186
|
viewportMayHaveApplied = false;
|
|
173
187
|
}
|
|
174
188
|
const finalized = finalizeOneShotRecording(recDir, recId, parsedUrl?.toString() ?? tab.url, parsed.do, stopped, deps.encodeVideo, viewportRestored);
|
|
@@ -178,7 +192,7 @@ async function handleOneShot(parsed: ParsedArgs, viewport: Viewport | undefined)
|
|
|
178
192
|
if (err instanceof DoActionError) failureStatus = err.status;
|
|
179
193
|
} finally {
|
|
180
194
|
let restored: boolean | null = null;
|
|
181
|
-
if (client && viewportMayHaveApplied) restored = await restoreSessionViewportForClient(client);
|
|
195
|
+
if (client && viewportMayHaveApplied) restored = await restoreSessionViewportForClient(client, originalViewport!);
|
|
182
196
|
client?.close();
|
|
183
197
|
if (failure !== null) {
|
|
184
198
|
// A failed action is not a completed measurement. Keep the private partial
|
|
@@ -188,6 +202,43 @@ async function handleOneShot(parsed: ParsedArgs, viewport: Viewport | undefined)
|
|
|
188
202
|
}
|
|
189
203
|
}
|
|
190
204
|
|
|
205
|
+
async function handleHostedSessionOneShot(parsed: ParsedArgs, session: NonNullable<ReturnType<typeof getActiveSession>>, viewport: Viewport | undefined): Promise<void> {
|
|
206
|
+
let started: StartRecorderResult | undefined;
|
|
207
|
+
try {
|
|
208
|
+
started = await deps.startComposedRecorder({ sessionDir: session.dir, viewport });
|
|
209
|
+
const scanned = scanCollectorHost(session.dir);
|
|
210
|
+
if (scanned.classification !== 'live' || !scanned.handle) throw new Error(`collector host is ${scanned.classification} after recording start`);
|
|
211
|
+
const host = scanned.handle;
|
|
212
|
+
await driveOneShotAction({
|
|
213
|
+
async handleCdp(request) {
|
|
214
|
+
const response = await sendHostRequest(host.socketPath, {
|
|
215
|
+
type: 'cdp', nonce: host.nonce, method: request.method, params: request.params ?? {}, ...(request.mark ? { annotation: request.mark } : {}),
|
|
216
|
+
}, 30_000);
|
|
217
|
+
if (!response.ok) throw new Error(response.error ?? `collector host rejected ${request.method}`);
|
|
218
|
+
return response as { result: unknown };
|
|
219
|
+
},
|
|
220
|
+
}, parsed.do!);
|
|
221
|
+
if (parsed.duration) await sleep(parsed.duration);
|
|
222
|
+
const stopped = await deps.stopComposedRecorder({ sessionDir: session.dir, recId: started.recId });
|
|
223
|
+
ensureFinalizedInventory(stopped.recDir);
|
|
224
|
+
const video = deps.encodeVideo(stopped.recDir, stopped.durationMs);
|
|
225
|
+
const metaPath = path.join(stopped.recDir, 'meta.json');
|
|
226
|
+
if (fs.existsSync(metaPath)) {
|
|
227
|
+
const meta = JSON.parse(fs.readFileSync(metaPath, 'utf8')) as Record<string, unknown>;
|
|
228
|
+
writeJsonPrivate(metaPath, { ...meta, video });
|
|
229
|
+
}
|
|
230
|
+
(stopped as FinalizedRecording & { video?: VideoEncoding }).video = video;
|
|
231
|
+
emitFinalizedResult(parsed, stopped);
|
|
232
|
+
} catch (err) {
|
|
233
|
+
const status = err instanceof DoActionError ? err.status : 'oneshot_failed';
|
|
234
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
235
|
+
const recovery = started
|
|
236
|
+
? `${message}. Recording ${started.recId} remains live at ${started.recDir}; finalize it with \`capture motion rec --stop\` (or inspect \`capture session collectors\`).`
|
|
237
|
+
: message;
|
|
238
|
+
emitCommandError(parsed, status, recovery);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
191
242
|
/** A one-shot `--do` failure carrying a precise status for the `<error>` block. */
|
|
192
243
|
export class DoActionError extends Error {
|
|
193
244
|
constructor(message: string, readonly status: string) {
|
|
@@ -212,7 +263,7 @@ function recorderLiveClient(recorder: Pick<RecorderSession, 'handleCdp'>): LiveC
|
|
|
212
263
|
function resolutionError(failure: ResolutionFailure): DoActionError {
|
|
213
264
|
if (failure.code === 'unsupported-prefix') {
|
|
214
265
|
return new DoActionError(
|
|
215
|
-
`Unsupported --do target prefix in "${failure.input}": text: is query-leaf-only. Accepted prefixes: bare css selector, ax:<name>, axid:<id>, backend:<id>.`,
|
|
266
|
+
`Unsupported --do target prefix in "${failure.input}": text: is query-leaf-only. Accepted prefixes: bare css selector (or exact accessible name when CSS finds none), ax:<name>, axid:<id>, backend:<id>.`,
|
|
216
267
|
'unsupported_target_prefix',
|
|
217
268
|
);
|
|
218
269
|
}
|
|
@@ -255,9 +306,9 @@ export async function driveOneShotAction(recorder: Pick<RecorderSession, 'handle
|
|
|
255
306
|
await scrollResolved(live, resolved, parsedAction.to, { mark: action });
|
|
256
307
|
}
|
|
257
308
|
|
|
258
|
-
/** Exported for the focused artifact-layout test. This
|
|
259
|
-
*
|
|
260
|
-
*
|
|
309
|
+
/** Exported for the focused artifact-layout test. This one-shot counterpart
|
|
310
|
+
* of the composed host finalizer writes the same finalized inventory without
|
|
311
|
+
* creating a live collector-host handle. */
|
|
261
312
|
export function finalizeOneShotRecording(
|
|
262
313
|
recDir: string,
|
|
263
314
|
recId: string,
|
|
@@ -268,11 +319,11 @@ export function finalizeOneShotRecording(
|
|
|
268
319
|
viewportRestored: boolean | null = null,
|
|
269
320
|
): FinalizedRecording & { action: string; video: VideoEncoding } {
|
|
270
321
|
ensureFinalizedInventory(recDir);
|
|
322
|
+
writeJsonPrivate(path.join(recDir, 'markers.json'), stopped.markers);
|
|
271
323
|
const video = encodeVideo(recDir, stopped.durationMs);
|
|
272
324
|
const fps = stopped.durationMs > 0 ? Math.round((stopped.frameCount / (stopped.durationMs / 1000)) * 10) / 10 : 0;
|
|
273
325
|
const state = stopped.frameCount > 0 ? 'finalized' : 'partial';
|
|
274
|
-
|
|
275
|
-
const meta: RecMeta & { url: string; fps: number; eventCount: number; video: VideoEncoding; viewportRestored: boolean | null; reason?: 'no_frames' } = {
|
|
326
|
+
const meta: RecMeta & { url: string; fps: number; eventCount: number; video: VideoEncoding; viewportRestored: boolean | null; viewportRetained: boolean; reason?: 'no_frames' } = {
|
|
276
327
|
id: recId,
|
|
277
328
|
action,
|
|
278
329
|
frames: stopped.frameCount,
|
|
@@ -284,9 +335,10 @@ export function finalizeOneShotRecording(
|
|
|
284
335
|
eventCount: stopped.eventCount,
|
|
285
336
|
video,
|
|
286
337
|
viewportRestored,
|
|
338
|
+
viewportRetained: false,
|
|
287
339
|
};
|
|
288
340
|
writeJsonPrivate(path.join(recDir, 'meta.json'), meta);
|
|
289
|
-
return { recId, recDir, frames: stopped.frameCount, durationMs: stopped.durationMs, fps, state, eventCount: stopped.eventCount, viewportRestored, action, video };
|
|
341
|
+
return { recId, recDir, frames: stopped.frameCount, durationMs: stopped.durationMs, fps, state, eventCount: stopped.eventCount, viewportRestored, viewportRetained: false, action, video };
|
|
290
342
|
}
|
|
291
343
|
|
|
292
344
|
function ensureFinalizedInventory(recDir: string): void {
|
|
@@ -325,10 +377,36 @@ async function applyViewportOverride(client: Pick<CDPClient, 'send'>, viewport:
|
|
|
325
377
|
return true;
|
|
326
378
|
}
|
|
327
379
|
|
|
328
|
-
|
|
380
|
+
interface ActualViewport {
|
|
381
|
+
width: number;
|
|
382
|
+
height: number;
|
|
383
|
+
deviceScaleFactor: number;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
async function readActualViewport(client: Pick<CDPClient, 'send'>): Promise<ActualViewport> {
|
|
387
|
+
const response = await client.send('Runtime.evaluate', {
|
|
388
|
+
expression: '(() => ({ width: window.innerWidth, height: window.innerHeight, deviceScaleFactor: window.devicePixelRatio }))()',
|
|
389
|
+
returnByValue: true,
|
|
390
|
+
}) as { result?: { value?: Partial<ActualViewport> } };
|
|
391
|
+
const viewport = response.result?.value;
|
|
392
|
+
if (!viewport || ![viewport.width, viewport.height, viewport.deviceScaleFactor].every((value) => typeof value === 'number' && Number.isFinite(value) && value > 0)) {
|
|
393
|
+
throw new Error('Could not read the target’s actual viewport before recording; refusing to apply an override that cannot be verified on restore.');
|
|
394
|
+
}
|
|
395
|
+
return viewport as ActualViewport;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
async function restoreSessionViewportForClient(client: Pick<CDPClient, 'send'>, original: ActualViewport): Promise<boolean> {
|
|
329
399
|
try {
|
|
330
|
-
await client.send('Emulation.
|
|
331
|
-
|
|
400
|
+
await client.send('Emulation.setDeviceMetricsOverride', {
|
|
401
|
+
width: original.width,
|
|
402
|
+
height: original.height,
|
|
403
|
+
deviceScaleFactor: original.deviceScaleFactor,
|
|
404
|
+
mobile: false,
|
|
405
|
+
});
|
|
406
|
+
const restored = await readActualViewport(client);
|
|
407
|
+
return restored.width === original.width
|
|
408
|
+
&& restored.height === original.height
|
|
409
|
+
&& restored.deviceScaleFactor === original.deviceScaleFactor;
|
|
332
410
|
} catch {
|
|
333
411
|
return false;
|
|
334
412
|
}
|
|
@@ -381,25 +459,66 @@ export function classifyEncodeFailure(result: { error?: (Error & { code?: string
|
|
|
381
459
|
return 'ffmpeg_encoding_failed';
|
|
382
460
|
}
|
|
383
461
|
|
|
462
|
+
/** Builds the concat manifest from the recorder's frame timestamps. A screencast is
|
|
463
|
+
* change-driven, but each retained frame's presentation timestamp is factual; a
|
|
464
|
+
* recording without a complete, ordered timestamp sequence cannot produce a
|
|
465
|
+
* timestamp-faithful video. */
|
|
466
|
+
function timestampedFrameManifest(recDir: string, frames: readonly string[], manifestPath: string): VideoEncoding | undefined {
|
|
467
|
+
let rects: unknown[];
|
|
468
|
+
let markers: Record<string, unknown>;
|
|
469
|
+
try {
|
|
470
|
+
rects = readPrivateFile(path.join(recDir, 'rects.jsonl')).toString('utf8').trim().split('\n').filter(Boolean).map(line => JSON.parse(line));
|
|
471
|
+
markers = JSON.parse(readPrivateFile(path.join(recDir, 'markers.json')).toString('utf8')) as Record<string, unknown>;
|
|
472
|
+
} catch {
|
|
473
|
+
return { status: 'unavailable', reason: 'frame_timestamps_unavailable' };
|
|
474
|
+
}
|
|
475
|
+
const timestamps = new Map<string, number>();
|
|
476
|
+
for (const rect of rects) {
|
|
477
|
+
if (!rect || typeof rect !== 'object') continue;
|
|
478
|
+
const { file, screencastTimestamp } = rect as { file?: unknown; screencastTimestamp?: unknown };
|
|
479
|
+
if (typeof file !== 'string' || typeof screencastTimestamp !== 'number' || !Number.isFinite(screencastTimestamp) || timestamps.has(file)) return { status: 'unavailable', reason: 'frame_timestamps_unavailable' };
|
|
480
|
+
timestamps.set(file, screencastTimestamp * 1000);
|
|
481
|
+
}
|
|
482
|
+
const frameTimes = frames.map(frame => timestamps.get(frame));
|
|
483
|
+
if (frameTimes.some((timestamp): timestamp is undefined => timestamp === undefined)) return { status: 'unavailable', reason: 'frame_timestamps_unavailable' };
|
|
484
|
+
if (frameTimes.some((timestamp, index) => index > 0 && timestamp <= frameTimes[index - 1]!)) return { status: 'unavailable', reason: 'frame_timestamps_unordered' };
|
|
485
|
+
const stoppedAtWallClockMs = markers.stoppedAtWallClockMs;
|
|
486
|
+
if (typeof stoppedAtWallClockMs !== 'number' || !Number.isFinite(stoppedAtWallClockMs)) return { status: 'unavailable', reason: 'frame_timestamps_unavailable' };
|
|
487
|
+
const finalDurationMs = stoppedAtWallClockMs - frameTimes[frameTimes.length - 1]!;
|
|
488
|
+
if (!(finalDurationMs > 0)) return { status: 'unavailable', reason: 'frame_timestamps_unavailable' };
|
|
489
|
+
const durationsMs = frameTimes.slice(1).map((timestamp, index) => timestamp - frameTimes[index]!);
|
|
490
|
+
durationsMs.push(finalDurationMs);
|
|
491
|
+
const quote = (filename: string) => filename.replace(/'/g, "'\\''");
|
|
492
|
+
const lines = ['ffconcat version 1.0'];
|
|
493
|
+
for (let index = 0; index < frames.length; index++) {
|
|
494
|
+
lines.push(`file '${quote(path.join(recDir, 'frames', frames[index]!))}'`, 'option framerate 1000', `duration ${(durationsMs[index]! / 1000).toFixed(9)}`);
|
|
495
|
+
}
|
|
496
|
+
// ffconcat applies a duration to the following file, so repeat the final
|
|
497
|
+
// image at the recorder's observed stop boundary to retain its final hold.
|
|
498
|
+
lines.push(`file '${quote(path.join(recDir, 'frames', frames[frames.length - 1]!))}'`, 'option framerate 1000');
|
|
499
|
+
writePrivateFile(manifestPath, lines.join('\n') + '\n');
|
|
500
|
+
return undefined;
|
|
501
|
+
}
|
|
502
|
+
|
|
384
503
|
/** Encodes inside a new private directory, then installs through the shared
|
|
385
|
-
* atomic no-follow writer.
|
|
386
|
-
*
|
|
387
|
-
export function encodeVideoIfAvailable(recDir: string,
|
|
504
|
+
* atomic no-follow writer. The concat demuxer receives every recorded
|
|
505
|
+
* screencast presentation timestamp instead of a fabricated constant cadence. */
|
|
506
|
+
export function encodeVideoIfAvailable(recDir: string, _durationMs: number): VideoEncoding {
|
|
388
507
|
const framesDir = path.join(recDir, 'frames');
|
|
389
508
|
const frames = fs.existsSync(framesDir) ? fs.readdirSync(framesDir).filter((name) => name.endsWith('.png')).sort() : [];
|
|
390
509
|
if (frames.length === 0) return { status: 'unavailable', reason: 'no_frames' };
|
|
391
|
-
if (durationMs <= 0) return { status: 'unavailable', reason: 'recording_duration_unavailable' };
|
|
392
510
|
const probe = spawnSync('ffmpeg', ['-hide_banner', '-encoders'], { encoding: 'utf8', timeout: 5_000 });
|
|
393
511
|
if (probe.error || probe.status !== 0 || !String(probe.stdout ?? '').includes('libvpx-vp9')) return { status: 'unavailable', reason: 'ffmpeg_or_vp9_encoder_unavailable' };
|
|
394
512
|
const tempDir = path.join(recDir, `.video-encode-${crypto.randomBytes(8).toString('hex')}`);
|
|
395
513
|
ensurePrivateDir(tempDir);
|
|
396
514
|
const tempOutput = path.join(tempDir, 'video.webm');
|
|
397
|
-
const cadence = String(frames.length / (durationMs / 1000));
|
|
398
|
-
const result = spawnSync('ffmpeg', [
|
|
399
|
-
'-y', '-framerate', cadence, '-pattern_type', 'glob', '-i', path.join(framesDir, '*.png'),
|
|
400
|
-
'-c:v', 'libvpx-vp9', '-pix_fmt', 'yuv420p', tempOutput,
|
|
401
|
-
], { stdio: 'ignore', timeout: encodeTimeoutMs(frames.length) });
|
|
402
515
|
try {
|
|
516
|
+
const manifestOutcome = timestampedFrameManifest(recDir, frames, path.join(tempDir, 'frames.ffconcat'));
|
|
517
|
+
if (manifestOutcome) return manifestOutcome;
|
|
518
|
+
const result = spawnSync('ffmpeg', [
|
|
519
|
+
'-y', '-f', 'concat', '-safe', '0', '-i', path.join(tempDir, 'frames.ffconcat'), '-fps_mode', 'vfr',
|
|
520
|
+
'-c:v', 'libvpx-vp9', '-pix_fmt', 'yuv420p', tempOutput,
|
|
521
|
+
], { stdio: 'ignore', timeout: encodeTimeoutMs(frames.length) });
|
|
403
522
|
if (result.error || result.status !== 0 || !fs.existsSync(tempOutput)) {
|
|
404
523
|
return { status: 'failed', reason: classifyEncodeFailure(result) };
|
|
405
524
|
}
|
|
@@ -473,8 +592,88 @@ async function handleStop(parsed: ParsedArgs): Promise<void> {
|
|
|
473
592
|
emitFinalizedResult(parsed, stopped);
|
|
474
593
|
}
|
|
475
594
|
|
|
595
|
+
type RecordingLossCounts =
|
|
596
|
+
| { rectSampleElementsDropped: number; bindingRecordsDropped: number; rectSampleFramesTruncated: number; rectSampleFramesCandidateTruncated: number }
|
|
597
|
+
| { unavailableReason: string };
|
|
598
|
+
|
|
599
|
+
function recordingLossCounts(recDir: string): RecordingLossCounts {
|
|
600
|
+
let raw: string;
|
|
601
|
+
try {
|
|
602
|
+
raw = readPrivateFile(path.join(recDir, 'events.jsonl')).toString('utf8');
|
|
603
|
+
} catch (error) {
|
|
604
|
+
return { unavailableReason: `events.jsonl could not be read (${error instanceof Error ? error.message : String(error)})` };
|
|
605
|
+
}
|
|
606
|
+
let rectSampleElementsDropped = 0;
|
|
607
|
+
let bindingRecordsDropped = 0;
|
|
608
|
+
let rectSampleFramesTruncated = 0;
|
|
609
|
+
let rectSampleFramesCandidateTruncated = 0;
|
|
610
|
+
for (const line of raw.split('\n').filter(Boolean)) {
|
|
611
|
+
let event: unknown;
|
|
612
|
+
try {
|
|
613
|
+
event = JSON.parse(line);
|
|
614
|
+
} catch {
|
|
615
|
+
return { unavailableReason: 'events.jsonl contains a malformed event record' };
|
|
616
|
+
}
|
|
617
|
+
if (!event || typeof event !== 'object') continue;
|
|
618
|
+
const { kind, count } = event as { kind?: unknown; count?: unknown };
|
|
619
|
+
if (kind !== 'rect-sample-dropped' && kind !== 'binding-dropped') continue;
|
|
620
|
+
if (!Number.isSafeInteger(count) || count < 0) return { unavailableReason: `${String(kind)} has no non-negative integer count` };
|
|
621
|
+
if (kind === 'rect-sample-dropped') {
|
|
622
|
+
if (!Number.isSafeInteger(rectSampleElementsDropped + count)) return { unavailableReason: 'rect-sample-dropped count exceeds the safe integer range' };
|
|
623
|
+
rectSampleElementsDropped += count;
|
|
624
|
+
} else {
|
|
625
|
+
if (!Number.isSafeInteger(bindingRecordsDropped + count)) return { unavailableReason: 'binding-dropped count exceeds the safe integer range' };
|
|
626
|
+
bindingRecordsDropped += count;
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
let rectsRaw: string;
|
|
630
|
+
try {
|
|
631
|
+
rectsRaw = readPrivateFile(path.join(recDir, 'rects.jsonl')).toString('utf8');
|
|
632
|
+
} catch (error) {
|
|
633
|
+
return { unavailableReason: `rects.jsonl could not be read (${error instanceof Error ? error.message : String(error)})` };
|
|
634
|
+
}
|
|
635
|
+
for (const line of rectsRaw.split('\n').filter(Boolean)) {
|
|
636
|
+
let rect: unknown;
|
|
637
|
+
try {
|
|
638
|
+
rect = JSON.parse(line);
|
|
639
|
+
} catch {
|
|
640
|
+
return { unavailableReason: 'rects.jsonl contains a malformed rect record' };
|
|
641
|
+
}
|
|
642
|
+
if (!rect || typeof rect !== 'object') continue;
|
|
643
|
+
if ((rect as { elementSampleTruncated?: unknown }).elementSampleTruncated === true) {
|
|
644
|
+
if (!Number.isSafeInteger(rectSampleFramesTruncated + 1)) return { unavailableReason: 'rect-sample truncation count exceeds the safe integer range' };
|
|
645
|
+
rectSampleFramesTruncated++;
|
|
646
|
+
}
|
|
647
|
+
if ((rect as { candidateSampleTruncated?: unknown }).candidateSampleTruncated === true) {
|
|
648
|
+
if (!Number.isSafeInteger(rectSampleFramesCandidateTruncated + 1)) return { unavailableReason: 'rect-sample candidate truncation count exceeds the safe integer range' };
|
|
649
|
+
rectSampleFramesCandidateTruncated++;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
return { rectSampleElementsDropped, bindingRecordsDropped, rectSampleFramesTruncated, rectSampleFramesCandidateTruncated };
|
|
653
|
+
}
|
|
654
|
+
|
|
476
655
|
function emitFinalizedResult(parsed: ParsedArgs, stopped: FinalizedRecording & { action?: string }): void {
|
|
477
656
|
const durationS = `${(stopped.durationMs / 1000).toFixed(1)}s`;
|
|
657
|
+
const lossCounts = recordingLossCounts(stopped.recDir);
|
|
658
|
+
const lossAttrs = 'unavailableReason' in lossCounts
|
|
659
|
+
? { 'rect-sample-elements-dropped': 'unavailable', 'binding-records-dropped': 'unavailable', 'rect-sample-frames-truncated': 'unavailable', 'rect-sample-frames-candidate-truncated': 'unavailable' }
|
|
660
|
+
: {
|
|
661
|
+
'rect-sample-elements-dropped': lossCounts.rectSampleElementsDropped,
|
|
662
|
+
'binding-records-dropped': lossCounts.bindingRecordsDropped,
|
|
663
|
+
'rect-sample-frames-truncated': lossCounts.rectSampleFramesTruncated,
|
|
664
|
+
'rect-sample-frames-candidate-truncated': lossCounts.rectSampleFramesCandidateTruncated,
|
|
665
|
+
};
|
|
666
|
+
const lossFacts = 'unavailableReason' in lossCounts
|
|
667
|
+
? [fact`Dropped rect-sample element and binding-record counts and rect-sample truncation state are unavailable: ${lossCounts.unavailableReason}.`]
|
|
668
|
+
: [
|
|
669
|
+
fact`Retained rect samples contain only the frame and element facts the recorder retained; ${lossCounts.rectSampleElementsDropped} rect-sample element fact(s) and ${lossCounts.bindingRecordsDropped} binding record(s) were dropped.`,
|
|
670
|
+
...(lossCounts.rectSampleFramesTruncated > 0
|
|
671
|
+
? [fact`${lossCounts.rectSampleFramesTruncated} rect sample frame(s) stopped after 4000 non-zero-size elements; remaining DOM candidates were not sampled because of the element cap.`]
|
|
672
|
+
: []),
|
|
673
|
+
...(lossCounts.rectSampleFramesCandidateTruncated > 0
|
|
674
|
+
? [fact`${lossCounts.rectSampleFramesCandidateTruncated} rect sample frame(s) sampled rect geometry from 8000 DOM element candidates; remaining DOM candidates were not sampled because of the geometry-read cap.`]
|
|
675
|
+
: []),
|
|
676
|
+
];
|
|
478
677
|
const result: RenderableResult = {
|
|
479
678
|
tag: 'recording',
|
|
480
679
|
attestation: { kind: 'recording', id: stopped.recId, path: stopped.recDir },
|
|
@@ -484,9 +683,11 @@ function emitFinalizedResult(parsed: ParsedArgs, stopped: FinalizedRecording & {
|
|
|
484
683
|
duration: durationS,
|
|
485
684
|
state: stopped.state,
|
|
486
685
|
'event-records': stopped.eventCount ?? 'unavailable',
|
|
686
|
+
...lossAttrs,
|
|
487
687
|
'baseline-availability': baselineAvailability(stopped.recDir),
|
|
488
688
|
...(stopped.action ? { action: stopped.action } : {}),
|
|
489
689
|
...(stopped.viewportRestored !== null ? { 'viewport-restored': stopped.viewportRestored } : {}),
|
|
690
|
+
...(stopped.viewportRetained ? { 'viewport-retained': true } : {}),
|
|
490
691
|
...('video' in stopped ? { video: (stopped as { video?: VideoEncoding }).video?.status ?? 'unavailable' } : {}),
|
|
491
692
|
'timestamp-uncertainty': '±1 frame for frame-derived timestamps',
|
|
492
693
|
},
|
|
@@ -497,6 +698,7 @@ function emitFinalizedResult(parsed: ParsedArgs, stopped: FinalizedRecording & {
|
|
|
497
698
|
: stopped.eventCount !== null
|
|
498
699
|
? fact`Recording finalized: ${stopped.frames} frame(s) over ${durationS}, ${stopped.eventCount} event record(s).`
|
|
499
700
|
: fact`Recording finalized: ${stopped.frames} frame(s) over ${durationS}.`,
|
|
701
|
+
sections: lossFacts,
|
|
500
702
|
artifacts: formatArtifactList(listRecordingArtifacts(stopped.recDir)),
|
|
501
703
|
};
|
|
502
704
|
emitResult(result, { json: parsed.json });
|
|
@@ -87,7 +87,7 @@ export async function cmdMotionTimeline(parsed: ParsedArgs, _args: string[]): Pr
|
|
|
87
87
|
{ name: 'meta.json', note: `recording state ${meta.state}` },
|
|
88
88
|
]),
|
|
89
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
|
|
90
|
+
followUp: text`Use \`capture motion jank <rec>\` for observer-event facts, or \`capture motion response <rec>\` for input-to-settle timing facts.`,
|
|
91
91
|
};
|
|
92
92
|
emitResult(result, { json: parsed.json });
|
|
93
93
|
} catch (err) {
|
|
@@ -63,10 +63,11 @@ export async function capturePageInputScreenshot(
|
|
|
63
63
|
action: string,
|
|
64
64
|
label: string,
|
|
65
65
|
noScreenshot?: boolean,
|
|
66
|
+
out?: string,
|
|
66
67
|
): Promise<PageInputScreenshot> {
|
|
67
68
|
try {
|
|
68
69
|
return {
|
|
69
|
-
screenshot: await deps.autoScreenshot(client, action, label, noScreenshot),
|
|
70
|
+
screenshot: await deps.autoScreenshot(client, action, label, noScreenshot, out),
|
|
70
71
|
screenshotWarning: null,
|
|
71
72
|
};
|
|
72
73
|
} catch (error) {
|
|
@@ -112,7 +113,7 @@ export function emitResolutionError(parsed: ParsedArgs, command: string, failure
|
|
|
112
113
|
{
|
|
113
114
|
tag: 'error',
|
|
114
115
|
attrs: { command, code: 'unsupported_prefix' },
|
|
115
|
-
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>.`,
|
|
116
|
+
summary: fact`received: target \`${failure.input}\` — the \`text:\` prefix is not accepted by live driving verbs; expected one of: bare CSS selector (or exact accessible name when CSS finds none), ax:<name>, axid:<id>, backend:<id>.`,
|
|
116
117
|
followUp: fact`Retry \`${command}\` with \`ax:<name>\` (accessible-name substring) or a CSS selector; \`text:\` matching exists only on the query leaves.`,
|
|
117
118
|
},
|
|
118
119
|
{ json: parsed.json },
|
|
@@ -170,13 +171,14 @@ export function emitResolutionError(parsed: ParsedArgs, command: string, failure
|
|
|
170
171
|
const USAGE = `capture page click <target> — dispatch one real click on exactly one resolved element
|
|
171
172
|
|
|
172
173
|
input:
|
|
173
|
-
<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.
|
|
174
|
+
<target> resolved against the LIVE page: bare CSS selector (takes precedence) or exact accessible name when CSS finds none, 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.
|
|
174
175
|
--settle <ms> network-settle window applied after the click (default: 1000; 2500 with an active session; 0 disables)
|
|
175
176
|
--no-screenshot skip the auto-screenshot
|
|
177
|
+
--out <path> destination for the auto-screenshot; same semantics as page shot — overrides the active session's shots/ sequence and enables the screenshot without an active session
|
|
176
178
|
output:
|
|
177
|
-
<clicked backend-node-id=… role=… name=…> — resolved identity, dispatched coordinates, the measured settle (requested/waited), and either the screenshot artifact path or a warning when only that follow-up capture timed out; --json mirrors the same fields
|
|
179
|
+
<clicked backend-node-id=… role=… name=…> — resolved identity, dispatched coordinates, a hit-test receiver fact when another node covers that dispatch point, the measured settle (requested/waited), and either the screenshot artifact path or a warning when only that follow-up capture timed out; --json mirrors the same fields
|
|
178
180
|
effects:
|
|
179
|
-
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`;
|
|
181
|
+
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. --out writes that screenshot to the caller-chosen path instead and works without an active session`;
|
|
180
182
|
|
|
181
183
|
export async function cmdPageClick(parsed: ParsedArgs, _args: string[]): Promise<void> {
|
|
182
184
|
if (parsed.help) {
|
|
@@ -188,10 +190,13 @@ export async function cmdPageClick(parsed: ParsedArgs, _args: string[]): Promise
|
|
|
188
190
|
return emitInvalidInput(
|
|
189
191
|
parsed,
|
|
190
192
|
'page click',
|
|
191
|
-
fact`received: ${parsed.positional.length} positional arguments; expected exactly one target (CSS selector, ax:<name>, axid:<id>, or backend:<id>).`,
|
|
193
|
+
fact`received: ${parsed.positional.length} positional arguments; expected exactly one target (CSS selector or exact accessible name, ax:<name>, axid:<id>, or backend:<id>).`,
|
|
192
194
|
);
|
|
193
195
|
}
|
|
194
196
|
|
|
197
|
+
const settleDefaultSource = parsed.settle === undefined
|
|
198
|
+
? deps.getActiveSession() === null ? 'standalone default' : 'active-session default'
|
|
199
|
+
: undefined;
|
|
195
200
|
const settle = effectiveSettle(parsed, { standalone: 1000, session: 2500 });
|
|
196
201
|
// connection.ts derives the recorder landmark label from parsed.command,
|
|
197
202
|
// which the router leaves as the branch token 'page' — restore the verb so
|
|
@@ -203,8 +208,8 @@ export async function cmdPageClick(parsed: ParsedArgs, _args: string[]): Promise
|
|
|
203
208
|
const live = client as unknown as LiveClient;
|
|
204
209
|
const resolved = await resolveLiveTarget(live, target);
|
|
205
210
|
if (!resolved.ok) return { failure: resolved } as const;
|
|
206
|
-
const dispatch = await clickResolved(live, resolved);
|
|
207
|
-
const screenshotResult = await capturePageInputScreenshot(client, 'click', target, parsed.noScreenshot);
|
|
211
|
+
const dispatch = await clickResolved(live, resolved, { inspectHitTest: true });
|
|
212
|
+
const screenshotResult = await capturePageInputScreenshot(client, 'click', target, parsed.noScreenshot, parsed.out);
|
|
208
213
|
return { dispatch, ...screenshotResult } as const;
|
|
209
214
|
},
|
|
210
215
|
);
|
|
@@ -216,7 +221,11 @@ export async function cmdPageClick(parsed: ParsedArgs, _args: string[]): Promise
|
|
|
216
221
|
const { dispatch, screenshot, screenshotWarning } = outcome;
|
|
217
222
|
const rows: FactLine[] = [
|
|
218
223
|
fact`clicked ${dispatch.role ?? 'unknown'} "${dispatch.name ?? ''}" (backend:${dispatch.backendNodeId}) at x=${dispatch.x} y=${dispatch.y}`,
|
|
224
|
+
...(dispatch.hitTestReceiverBackendNodeId === undefined
|
|
225
|
+
? []
|
|
226
|
+
: [fact`dispatch hit test at x=${dispatch.x} y=${dispatch.y} resolved to backend:${dispatch.hitTestReceiverBackendNodeId}, not target backend:${dispatch.backendNodeId}`]),
|
|
219
227
|
fact`settle: requested ${settleFacts.requestedMs}ms, waited ${settleFacts.waitedMs}ms`,
|
|
228
|
+
...(settleDefaultSource === undefined ? [] : [fact`settle source: ${settleDefaultSource}; no --settle flag was passed.`]),
|
|
220
229
|
];
|
|
221
230
|
if (screenshot) rows.push(fact`screenshot: ${screenshot}`);
|
|
222
231
|
if (screenshotWarning) rows.push(fact`screenshot-warning: ${screenshotWarning}`);
|
|
@@ -228,6 +237,7 @@ export async function cmdPageClick(parsed: ParsedArgs, _args: string[]): Promise
|
|
|
228
237
|
'backend-node-id': dispatch.backendNodeId,
|
|
229
238
|
role: dispatch.role ?? undefined,
|
|
230
239
|
name: dispatch.name ?? undefined,
|
|
240
|
+
'hit-test-receiver-backend-node-id': dispatch.hitTestReceiverBackendNodeId,
|
|
231
241
|
},
|
|
232
242
|
summary: lineList(rows),
|
|
233
243
|
},
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import { type ParsedArgs } from '../../types.js';
|
|
20
20
|
import { invalidInput } from '../../../errors.js';
|
|
21
|
+
import { selectRecords } from '../../../output/selection.js';
|
|
21
22
|
import { withConnection } from '../../connection.js';
|
|
22
23
|
import { INTERACTIVE_ROLES, readFullAXTree } from '../../a11y.js';
|
|
23
24
|
import {
|
|
@@ -37,7 +38,8 @@ const USAGE = `capture page elements — list what can be acted on in the live t
|
|
|
37
38
|
|
|
38
39
|
Input:
|
|
39
40
|
--all full exposed accessibility tree instead of interactive elements only
|
|
40
|
-
--
|
|
41
|
+
--name <text> case-insensitive substring filter over each exposed accessibility name; applies before --limit
|
|
42
|
+
--limit <n> max filtered elements returned in prose and JSON (positive integer, default ${DEFAULT_LIMIT}; a capped list states the total as an elements-truncated fact)
|
|
41
43
|
--target <tabId> | --url <pattern> | --port <n> tab targeting; defaults to the active session tab
|
|
42
44
|
--json mirror the result as JSON
|
|
43
45
|
|
|
@@ -69,9 +71,24 @@ export interface ElementRecord {
|
|
|
69
71
|
*/
|
|
70
72
|
export async function collectElements(
|
|
71
73
|
client: ElementsClient,
|
|
72
|
-
opts: { all?: boolean } = {},
|
|
74
|
+
opts: { all?: boolean; name?: string } = {},
|
|
73
75
|
): Promise<ElementRecord[]> {
|
|
74
76
|
const nodes = await readFullAXTree(client);
|
|
77
|
+
const nodesById = new Map(nodes.map((node) => [node.nodeId, node]));
|
|
78
|
+
const parents = new Map<string, (typeof nodes)[number]>();
|
|
79
|
+
for (const node of nodes) {
|
|
80
|
+
for (const childId of node.childIds ?? []) parents.set(childId, node);
|
|
81
|
+
}
|
|
82
|
+
const nameOf = (node: (typeof nodes)[number]): string => {
|
|
83
|
+
const ownName = node.name?.value ?? '';
|
|
84
|
+
if (ownName) return ownName;
|
|
85
|
+
return (node.childIds ?? [])
|
|
86
|
+
.map((childId) => nodesById.get(childId))
|
|
87
|
+
.filter((child): child is (typeof nodes)[number] => child?.role?.value === 'StaticText')
|
|
88
|
+
.map((child) => child.name?.value ?? '')
|
|
89
|
+
.filter(Boolean)
|
|
90
|
+
.join(' ');
|
|
91
|
+
};
|
|
75
92
|
|
|
76
93
|
const records: ElementRecord[] = [];
|
|
77
94
|
for (const node of nodes) {
|
|
@@ -81,14 +98,29 @@ export async function collectElements(
|
|
|
81
98
|
if (!opts.all) {
|
|
82
99
|
if (!INTERACTIVE_ROLES.has(role)) continue;
|
|
83
100
|
if (node.backendDOMNodeId === undefined) continue;
|
|
101
|
+
} else {
|
|
102
|
+
const parent = parents.get(node.nodeId);
|
|
103
|
+
const duplicateParentText =
|
|
104
|
+
parent !== undefined &&
|
|
105
|
+
(node.name?.value === parent.name?.value || (role === 'StaticText' && !parent.name?.value));
|
|
106
|
+
if (
|
|
107
|
+
(role === 'StaticText' || role === 'InlineTextBox') &&
|
|
108
|
+
typeof node.name?.value === 'string' &&
|
|
109
|
+
node.name.value.length > 0 &&
|
|
110
|
+
duplicateParentText
|
|
111
|
+
) {
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
84
114
|
}
|
|
85
115
|
records.push({
|
|
86
116
|
role,
|
|
87
|
-
name: node
|
|
117
|
+
name: nameOf(node),
|
|
88
118
|
backendNodeId: node.backendDOMNodeId ?? null,
|
|
89
119
|
});
|
|
90
120
|
}
|
|
91
|
-
return
|
|
121
|
+
return opts.name === undefined
|
|
122
|
+
? records
|
|
123
|
+
: records.filter((record) => record.name.toLowerCase().includes(opts.name!.toLowerCase()));
|
|
92
124
|
}
|
|
93
125
|
|
|
94
126
|
/**
|
|
@@ -99,10 +131,10 @@ export async function collectElements(
|
|
|
99
131
|
*/
|
|
100
132
|
export function buildElementsResult(
|
|
101
133
|
records: readonly ElementRecord[],
|
|
102
|
-
opts: { all: boolean; limit: number },
|
|
134
|
+
opts: { all: boolean; limit: number; name?: string },
|
|
103
135
|
): RenderableResult {
|
|
104
136
|
const total = records.length;
|
|
105
|
-
const shown = records.
|
|
137
|
+
const shown = selectRecords(records, { limit: opts.limit }, opts.limit);
|
|
106
138
|
|
|
107
139
|
const rows = shown.map((r) =>
|
|
108
140
|
r.backendNodeId !== null
|
|
@@ -118,10 +150,10 @@ export function buildElementsResult(
|
|
|
118
150
|
|
|
119
151
|
return {
|
|
120
152
|
tag: 'elements',
|
|
121
|
-
attrs: { scope: opts.all ? 'all' : 'interactive', count: total },
|
|
153
|
+
attrs: { scope: opts.all ? 'all' : 'interactive', count: total, 'name-filter': opts.name },
|
|
122
154
|
summary: opts.all
|
|
123
|
-
? fact`${total} elements in the exposed accessibility tree`
|
|
124
|
-
: fact`${total} interactive elements`,
|
|
155
|
+
? opts.name === undefined ? fact`${total} elements in the exposed accessibility tree` : fact`${total} elements in the exposed accessibility tree with accessible name containing ${opts.name}`
|
|
156
|
+
: opts.name === undefined ? fact`${total} interactive elements` : fact`${total} interactive elements with accessible name containing ${opts.name}`,
|
|
125
157
|
sections,
|
|
126
158
|
followUp: text`capture page click <target> · capture measure map ax <url|snap>`,
|
|
127
159
|
};
|
|
@@ -144,11 +176,11 @@ export async function cmdPageElements(parsed: ParsedArgs, _args: string[]): Prom
|
|
|
144
176
|
|
|
145
177
|
const records = await withConnection(
|
|
146
178
|
parsed,
|
|
147
|
-
(client) => collectElements(client, { all: parsed.all }),
|
|
179
|
+
(client) => collectElements(client, { all: parsed.all, name: parsed.name }),
|
|
148
180
|
{ settle: 0 },
|
|
149
181
|
);
|
|
150
182
|
|
|
151
|
-
emitResult(buildElementsResult(records, { all: Boolean(parsed.all), limit }), {
|
|
183
|
+
emitResult(buildElementsResult(records, { all: Boolean(parsed.all), limit, name: parsed.name }), {
|
|
152
184
|
json: parsed.json,
|
|
153
185
|
});
|
|
154
186
|
}
|