@crouton-kit/capture 1.4.2 → 1.4.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +187 -0
- package/bin/capture +31278 -7312
- 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 +228 -26
- package/src/cdp/commands/motion/timeline.ts +1 -1
- package/src/cdp/commands/page/click.ts +15 -5
- 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 +10 -5
- package/src/cdp/commands/page/shot.ts +66 -31
- package/src/cdp/commands/page/type.ts +6 -2
- 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/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 +50 -23
- 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
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
*/
|
|
22
22
|
import { connectForCommand, withPageAction, type SettleFacts } from '../../connection.js';
|
|
23
23
|
import { EventBroker } from '../../bridge/server.js';
|
|
24
|
-
import {
|
|
24
|
+
import { isMotionHeldClient, type MotionHeldClient } from '../../host/held-client.js';
|
|
25
25
|
import { type CDPClient } from '../../client.js';
|
|
26
26
|
import { type ParsedArgs } from '../../types.js';
|
|
27
27
|
import { CaptureError, captureError, invalidInput } from '../../../errors.js';
|
|
@@ -60,12 +60,15 @@ picks which tab navigates. Navigate never creates a tab — use "tab open" for
|
|
|
60
60
|
that; an unresolvable source is a targeting error, not a new tab.
|
|
61
61
|
|
|
62
62
|
Output:
|
|
63
|
-
One <navigated url=… settle=… load-outcome=… deadline-exceeded=…> block:
|
|
63
|
+
One <navigated url=… settle=… load-outcome=… document-outcome=… deadline-exceeded=…> block:
|
|
64
64
|
the tab URL after navigation, the measured settle, and — reported
|
|
65
|
-
separately — the load outcome (observed | bounded-timeout),
|
|
66
|
-
|
|
65
|
+
separately — the load outcome (observed | bounded-timeout), the document/network
|
|
66
|
+
outcome (http-response | http-error-document | not-observed; the latter has no
|
|
67
|
+
document status or URL), whether the nav phase exceeded its deadline, the
|
|
68
|
+
dispatched navigation's loaderId (and
|
|
67
69
|
any about:blank bounce for a same-document target), and whether the CDP
|
|
68
|
-
routed through an active recording.
|
|
70
|
+
routed through an active recording. A document HTTP error does not change the
|
|
71
|
+
navigation dispatch or load outcome. --json mirrors the same fields.
|
|
69
72
|
|
|
70
73
|
Effects:
|
|
71
74
|
Navigates the source tab (exactly one Page.navigate for the destination;
|
|
@@ -78,8 +81,13 @@ Effects:
|
|
|
78
81
|
/** Load outcome plus the dispatched navigation's own facts, produced by one
|
|
79
82
|
* navigation core (recorder-routed or direct). Distinct fields so the render
|
|
80
83
|
* layer can report the load result separately from the method result. */
|
|
84
|
+
type DocumentOutcome =
|
|
85
|
+
| { kind: 'http-response' | 'http-error-document'; status: number; url: string }
|
|
86
|
+
| { kind: 'not-observed' };
|
|
87
|
+
|
|
81
88
|
interface NavCore {
|
|
82
89
|
loadOutcome: 'observed' | 'bounded-timeout';
|
|
90
|
+
documentOutcome: DocumentOutcome;
|
|
83
91
|
/** The dispatched navigation's loaderId (present on a real cross-document
|
|
84
92
|
* navigation; absent for a same-document target before the bounce). */
|
|
85
93
|
loaderId?: string;
|
|
@@ -96,18 +104,23 @@ interface NavCore {
|
|
|
96
104
|
* target returns no loaderId — recover with exactly one dest→about:blank→dest
|
|
97
105
|
* bounce, bundling the wait only on the final re-navigate.
|
|
98
106
|
*/
|
|
99
|
-
|
|
100
|
-
|
|
107
|
+
function documentOutcomeFor(response?: { status: number; url: string }): DocumentOutcome {
|
|
108
|
+
if (!response) return { kind: 'not-observed' };
|
|
109
|
+
return { kind: response.status >= 400 ? 'http-error-document' : 'http-response', status: response.status, url: response.url };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function navigateViaRecorder(client: MotionHeldClient, url: string): Promise<NavCore> {
|
|
113
|
+
const first = await client.dispatch('Page.navigate', { url }, LOAD_EVENT_NAME, timing.innerTimeoutMs, true);
|
|
101
114
|
const loaderId = (first.result as { loaderId?: string } | undefined)?.loaderId;
|
|
102
115
|
if (loaderId) {
|
|
103
|
-
return { loadOutcome: first.waitOutcome ?? 'bounded-timeout', loaderId, bounced: false };
|
|
116
|
+
return { loadOutcome: first.waitOutcome ?? 'bounded-timeout', documentOutcome: documentOutcomeFor(first.documentResponse), loaderId, bounced: false };
|
|
104
117
|
}
|
|
105
118
|
// Same-document target: the destination is already committed (no loaderId);
|
|
106
119
|
// bounce through about:blank and re-navigate so a fresh document mounts.
|
|
107
120
|
await client.dispatch('Page.navigate', { url: 'about:blank' });
|
|
108
|
-
const final = await client.dispatch('Page.navigate', { url }, LOAD_EVENT_NAME, timing.innerTimeoutMs);
|
|
121
|
+
const final = await client.dispatch('Page.navigate', { url }, LOAD_EVENT_NAME, timing.innerTimeoutMs, true);
|
|
109
122
|
const finalLoaderId = (final.result as { loaderId?: string } | undefined)?.loaderId;
|
|
110
|
-
return { loadOutcome: final.waitOutcome ?? 'bounded-timeout', loaderId: finalLoaderId, bounced: true };
|
|
123
|
+
return { loadOutcome: final.waitOutcome ?? 'bounded-timeout', documentOutcome: documentOutcomeFor(final.documentResponse), loaderId: finalLoaderId, bounced: true };
|
|
111
124
|
}
|
|
112
125
|
|
|
113
126
|
/** Arms a bounded `Page.loadEventFired` wait on a direct client's broker and
|
|
@@ -134,43 +147,66 @@ function armLoadWait(broker: EventBroker): {
|
|
|
134
147
|
* exactly one dest→about:blank→dest bounce, re-arming the wait before the
|
|
135
148
|
* final send.
|
|
136
149
|
*/
|
|
150
|
+
function observeDocumentResponses(client: CDPClient): { outcomeFor(loaderId?: string): DocumentOutcome; dispose(): void } {
|
|
151
|
+
const responses: Array<{ loaderId: string; status: number; url: string }> = [];
|
|
152
|
+
const handler = (params: unknown) => {
|
|
153
|
+
if (!params || typeof params !== 'object' || Array.isArray(params)) return;
|
|
154
|
+
const event = params as { type?: unknown; loaderId?: unknown; response?: unknown };
|
|
155
|
+
if (event.type !== 'Document' || typeof event.loaderId !== 'string' || !event.response || typeof event.response !== 'object' || Array.isArray(event.response)) return;
|
|
156
|
+
const response = event.response as { status?: unknown; url?: unknown };
|
|
157
|
+
if (typeof response.status !== 'number' || !Number.isFinite(response.status) || typeof response.url !== 'string') return;
|
|
158
|
+
responses.push({ loaderId: event.loaderId, status: response.status, url: response.url });
|
|
159
|
+
};
|
|
160
|
+
client.on('Network.responseReceived', handler);
|
|
161
|
+
return {
|
|
162
|
+
outcomeFor(loaderId) {
|
|
163
|
+
const response = loaderId === undefined ? undefined : responses.filter((candidate) => candidate.loaderId === loaderId).at(-1);
|
|
164
|
+
if (!response) return { kind: 'not-observed' };
|
|
165
|
+
return { kind: response.status >= 400 ? 'http-error-document' : 'http-response', status: response.status, url: response.url };
|
|
166
|
+
},
|
|
167
|
+
dispose() { client.off('Network.responseReceived', handler); },
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
137
171
|
async function navigateDirect(
|
|
138
172
|
client: CDPClient,
|
|
139
173
|
url: string,
|
|
140
174
|
armed?: { cancel: () => void },
|
|
141
175
|
): Promise<NavCore> {
|
|
142
176
|
await client.send('Page.enable');
|
|
177
|
+
await client.send('Network.enable');
|
|
178
|
+
const documents = observeDocumentResponses(client);
|
|
143
179
|
const broker = new EventBroker(client);
|
|
144
180
|
|
|
145
|
-
const first = armLoadWait(broker);
|
|
146
|
-
// Publish the currently-armed wait's canceller so an outer-deadline
|
|
147
|
-
// abandonment can clear its inner timer instead of letting it fire on its own.
|
|
148
|
-
if (armed) armed.cancel = () => first.wait.cancel();
|
|
149
|
-
let nav: { loaderId?: string } | undefined;
|
|
150
181
|
try {
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
182
|
+
const first = armLoadWait(broker);
|
|
183
|
+
if (armed) armed.cancel = () => first.wait.cancel();
|
|
184
|
+
let nav: { loaderId?: string } | undefined;
|
|
185
|
+
try {
|
|
186
|
+
nav = (await client.send('Page.navigate', { url })) as { loaderId?: string };
|
|
187
|
+
} catch (err) {
|
|
188
|
+
first.wait.cancel();
|
|
189
|
+
throw err;
|
|
190
|
+
}
|
|
191
|
+
if (nav?.loaderId) {
|
|
192
|
+
return { loadOutcome: await first.outcome, documentOutcome: documents.outcomeFor(nav.loaderId), loaderId: nav.loaderId, bounced: false };
|
|
193
|
+
}
|
|
159
194
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
195
|
+
first.wait.cancel();
|
|
196
|
+
await client.send('Page.navigate', { url: 'about:blank' });
|
|
197
|
+
const second = armLoadWait(broker);
|
|
198
|
+
if (armed) armed.cancel = () => second.wait.cancel();
|
|
199
|
+
let navFinal: { loaderId?: string } | undefined;
|
|
200
|
+
try {
|
|
201
|
+
navFinal = (await client.send('Page.navigate', { url })) as { loaderId?: string };
|
|
202
|
+
} catch (err) {
|
|
203
|
+
second.wait.cancel();
|
|
204
|
+
throw err;
|
|
205
|
+
}
|
|
206
|
+
return { loadOutcome: await second.outcome, documentOutcome: documents.outcomeFor(navFinal?.loaderId), loaderId: navFinal?.loaderId, bounced: true };
|
|
207
|
+
} finally {
|
|
208
|
+
documents.dispose();
|
|
172
209
|
}
|
|
173
|
-
return { loadOutcome: await second.outcome, loaderId: navFinal?.loaderId, bounced: true };
|
|
174
210
|
}
|
|
175
211
|
|
|
176
212
|
/** Races the nav+load phase against the outer deadline. The deadline never
|
|
@@ -200,6 +236,7 @@ interface NavigatedFacts {
|
|
|
200
236
|
tabUrl: string;
|
|
201
237
|
requestedUrl: string;
|
|
202
238
|
loadOutcome: 'observed' | 'bounded-timeout';
|
|
239
|
+
documentOutcome: DocumentOutcome;
|
|
203
240
|
deadlineExceeded: boolean;
|
|
204
241
|
loaderId?: string;
|
|
205
242
|
bounced: boolean;
|
|
@@ -212,6 +249,9 @@ function buildNavigatedResult(f: NavigatedFacts): RenderableResult {
|
|
|
212
249
|
f.loadOutcome === 'observed'
|
|
213
250
|
? fact`load: ${LOAD_EVENT_NAME} observed within the ${timing.innerTimeoutMs}ms load-wait window.`
|
|
214
251
|
: fact`load: ${LOAD_EVENT_NAME} not observed within the ${timing.innerTimeoutMs}ms load-wait window (bounded-timeout); the navigation still dispatched.`,
|
|
252
|
+
f.documentOutcome.kind === 'not-observed'
|
|
253
|
+
? fact`document/network outcome: no matching HTTP document response was observed.`
|
|
254
|
+
: fact`document/network outcome: HTTP ${f.documentOutcome.status} at ${f.documentOutcome.url}${f.documentOutcome.kind === 'http-error-document' ? ' (HTTP error document)' : ''}.`,
|
|
215
255
|
fact`navigation dispatched: loaderId ${f.loaderId ?? '(none)'}${f.bounced ? '; delivered via dest→about:blank→dest bounce (same-document target)' : ''}.`,
|
|
216
256
|
fact`settle: requested ${f.settle.requestedMs}ms, waited ${f.settle.waitedMs}ms.`,
|
|
217
257
|
];
|
|
@@ -229,6 +269,9 @@ function buildNavigatedResult(f: NavigatedFacts): RenderableResult {
|
|
|
229
269
|
url: f.tabUrl,
|
|
230
270
|
settle: f.settle.requestedMs,
|
|
231
271
|
'load-outcome': f.loadOutcome,
|
|
272
|
+
'document-outcome': f.documentOutcome.kind,
|
|
273
|
+
'document-status': f.documentOutcome.kind === 'not-observed' ? undefined : f.documentOutcome.status,
|
|
274
|
+
'document-url': f.documentOutcome.kind === 'not-observed' ? undefined : f.documentOutcome.url,
|
|
232
275
|
'deadline-exceeded': f.deadlineExceeded || undefined,
|
|
233
276
|
routed: f.routed || undefined,
|
|
234
277
|
},
|
|
@@ -276,12 +319,12 @@ export async function cmdPageNavigate(parsed: ParsedArgs, _args: string[]): Prom
|
|
|
276
319
|
{ ...parsed, command: 'navigate' },
|
|
277
320
|
{ settleMs: settle },
|
|
278
321
|
async (client, _tab) => {
|
|
279
|
-
const routed =
|
|
322
|
+
const routed = isMotionHeldClient(client);
|
|
280
323
|
// Holder for the direct path's currently-armed load wait, so an
|
|
281
324
|
// outer-deadline abandonment can cancel it (clearing its inner timer).
|
|
282
325
|
const armed = { cancel: () => {} };
|
|
283
326
|
const work = routed
|
|
284
|
-
? navigateViaRecorder(client as unknown as
|
|
327
|
+
? navigateViaRecorder(client as unknown as MotionHeldClient, url)
|
|
285
328
|
: navigateDirect(client, url, armed);
|
|
286
329
|
let raced: Awaited<ReturnType<typeof raceNavDeadline>>;
|
|
287
330
|
try {
|
|
@@ -305,6 +348,7 @@ export async function cmdPageNavigate(parsed: ParsedArgs, _args: string[]): Prom
|
|
|
305
348
|
armed.cancel();
|
|
306
349
|
return {
|
|
307
350
|
loadOutcome: 'bounded-timeout' as const,
|
|
351
|
+
documentOutcome: { kind: 'not-observed' } as const,
|
|
308
352
|
loaderId: undefined,
|
|
309
353
|
bounced: false,
|
|
310
354
|
deadlineExceeded: true,
|
|
@@ -319,6 +363,7 @@ export async function cmdPageNavigate(parsed: ParsedArgs, _args: string[]): Prom
|
|
|
319
363
|
tabUrl: core.tabUrl,
|
|
320
364
|
requestedUrl: url,
|
|
321
365
|
loadOutcome: core.loadOutcome,
|
|
366
|
+
documentOutcome: core.documentOutcome,
|
|
322
367
|
deadlineExceeded: core.deadlineExceeded,
|
|
323
368
|
loaderId: core.loaderId,
|
|
324
369
|
bounced: core.bounced,
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { type ParsedArgs } from '../../types.js';
|
|
2
|
+
import { resolveLiveTarget, clickResolved, focusAndType, typeText, type ClickDispatch, type LiveClient, type ResolutionFailure } from '../../../interact.js';
|
|
3
|
+
import { emitResult, fact, lineList, type FactLine } from '../../../output/render.js';
|
|
4
|
+
import { effectiveSettle, emitInvalidInput, pageInputDeps } from './click.js';
|
|
5
|
+
|
|
6
|
+
const MAX_STEPS = 20;
|
|
7
|
+
const MAX_ITERATIONS = 100;
|
|
8
|
+
|
|
9
|
+
const USAGE = `capture page repeat --steps <json> --iterations <n> [--settle <ms>] — repeat one bounded declarative click/type sequence
|
|
10
|
+
|
|
11
|
+
input:
|
|
12
|
+
--steps <json> required JSON array of 1..${MAX_STEPS} action objects, in order: {"action":"click","target":"<live target>"} or {"action":"type","text":"<text>","into":"<live target>"}; type omits into to insert into the focused element
|
|
13
|
+
--iterations <n> required repeat count from 1 to ${MAX_ITERATIONS}
|
|
14
|
+
--settle <ms> network-settle window applied after each completed sequence (default: 1000; 2500 with an active session; 0 disables)
|
|
15
|
+
--target <tabId> | --url <pattern> | --port <n> tab targeting; defaults to the active session tab
|
|
16
|
+
output:
|
|
17
|
+
<repeated iterations=… steps=…> — one factual completion record per iteration with step action, resolved identity when applicable, dispatch coordinates, elapsed duration, and measured requested/waited settle
|
|
18
|
+
effects:
|
|
19
|
+
drives every click through the same live target resolution and real mouse dispatch as page click, and every type through the same focused/targeted real text insertion as page type; each completed iteration receives its own action lifecycle and settle window; no automatic screenshots are written`;
|
|
20
|
+
|
|
21
|
+
type RepeatStep =
|
|
22
|
+
| { readonly action: 'click'; readonly target: string }
|
|
23
|
+
| { readonly action: 'type'; readonly text: string; readonly into?: string };
|
|
24
|
+
|
|
25
|
+
interface ActionRecord {
|
|
26
|
+
readonly action: 'click' | 'type';
|
|
27
|
+
readonly target?: string;
|
|
28
|
+
readonly text?: string;
|
|
29
|
+
readonly backendNodeId?: number;
|
|
30
|
+
readonly role?: string;
|
|
31
|
+
readonly name?: string;
|
|
32
|
+
readonly x?: number;
|
|
33
|
+
readonly y?: number;
|
|
34
|
+
readonly focused?: boolean;
|
|
35
|
+
readonly hitTestReceiverBackendNodeId?: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface FailureRecord {
|
|
39
|
+
readonly code: string;
|
|
40
|
+
readonly target: string;
|
|
41
|
+
readonly matches?: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface IterationRecord {
|
|
45
|
+
readonly iteration: number;
|
|
46
|
+
readonly completion: 'completed' | 'failed';
|
|
47
|
+
readonly durationMs: number;
|
|
48
|
+
readonly settle?: { readonly requestedMs: number; readonly waitedMs: number };
|
|
49
|
+
readonly actions: readonly ActionRecord[];
|
|
50
|
+
readonly failure?: FailureRecord;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
class RepeatResolutionFailure extends Error {
|
|
54
|
+
constructor(readonly failure: ResolutionFailure, readonly actions: readonly ActionRecord[]) {
|
|
55
|
+
super('page repeat target resolution did not complete.');
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function parseSteps(raw: string | undefined): RepeatStep[] | null {
|
|
60
|
+
if (raw === undefined || raw.length > 10_000) return null;
|
|
61
|
+
try {
|
|
62
|
+
const value = JSON.parse(raw) as unknown;
|
|
63
|
+
if (!Array.isArray(value) || value.length < 1 || value.length > MAX_STEPS) return null;
|
|
64
|
+
const out: RepeatStep[] = [];
|
|
65
|
+
for (const valueStep of value) {
|
|
66
|
+
if (!valueStep || typeof valueStep !== 'object' || Array.isArray(valueStep)) return null;
|
|
67
|
+
const step = valueStep as Record<string, unknown>;
|
|
68
|
+
if (step.action === 'click' && Object.keys(step).length === 2 && typeof step.target === 'string' && step.target.length > 0) {
|
|
69
|
+
out.push({ action: 'click', target: step.target });
|
|
70
|
+
} else if (step.action === 'type' && Object.keys(step).every(key => key === 'action' || key === 'text' || key === 'into') && typeof step.text === 'string' && (step.into === undefined || (typeof step.into === 'string' && step.into.trim().length > 0))) {
|
|
71
|
+
out.push({ action: 'type', text: step.text, ...(typeof step.into === 'string' ? { into: step.into } : {}) });
|
|
72
|
+
} else return null;
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
} catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function actionRecord(action: 'click' | 'type', dispatch: ClickDispatch, target: string, text?: string): ActionRecord {
|
|
81
|
+
return {
|
|
82
|
+
action,
|
|
83
|
+
target,
|
|
84
|
+
...(text === undefined ? {} : { text }),
|
|
85
|
+
backendNodeId: dispatch.backendNodeId,
|
|
86
|
+
...(dispatch.role === null ? {} : { role: dispatch.role }),
|
|
87
|
+
...(dispatch.name === null ? {} : { name: dispatch.name }),
|
|
88
|
+
x: dispatch.x,
|
|
89
|
+
y: dispatch.y,
|
|
90
|
+
...(action === 'type' ? { focused: true } : {}),
|
|
91
|
+
...(dispatch.hitTestReceiverBackendNodeId === undefined ? {} : { hitTestReceiverBackendNodeId: dispatch.hitTestReceiverBackendNodeId }),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function emitRepeatResult(parsed: ParsedArgs, steps: number, settle: number, records: readonly IterationRecord[], completion: 'completed' | 'partial'): void {
|
|
96
|
+
const sections: FactLine[] = records.map(record => lineList([
|
|
97
|
+
record.completion === 'completed'
|
|
98
|
+
? fact`iteration ${record.iteration} completed in ${record.durationMs.toFixed(1)}ms; settle requested ${record.settle!.requestedMs}ms, waited ${record.settle!.waitedMs}ms.`
|
|
99
|
+
: fact`iteration ${record.iteration} did not complete in ${record.durationMs.toFixed(1)}ms; target ${record.failure!.target} resolution returned ${record.failure!.code}${record.failure!.matches === undefined ? '' : ` with ${record.failure!.matches} live matches`}.`,
|
|
100
|
+
...record.actions.map(action => action.action === 'click'
|
|
101
|
+
? fact`click ${action.target ?? '(unknown)'} → backend:${action.backendNodeId ?? '(unknown)'}${action.role === undefined ? '' : ` role=${action.role}`}${action.name === undefined ? '' : ` name=${action.name}`} at x=${action.x ?? '(unknown)'} y=${action.y ?? '(unknown)'}${action.hitTestReceiverBackendNodeId === undefined ? '' : `; hit-test receiver backend:${action.hitTestReceiverBackendNodeId}`}`
|
|
102
|
+
: action.backendNodeId === undefined
|
|
103
|
+
? fact`type into focused element: ${action.text ?? ''}`
|
|
104
|
+
: fact`type into ${action.target ?? '(unknown)'} → backend:${action.backendNodeId}${action.role === undefined ? '' : ` role=${action.role}`}${action.name === undefined ? '' : ` name=${action.name}`} at x=${action.x ?? '(unknown)'} y=${action.y ?? '(unknown)'}${action.hitTestReceiverBackendNodeId === undefined ? '' : `; hit-test receiver backend:${action.hitTestReceiverBackendNodeId}`}: ${action.text ?? ''}`),
|
|
105
|
+
]));
|
|
106
|
+
emitResult({
|
|
107
|
+
tag: 'repeated',
|
|
108
|
+
attrs: { iterations: records.length, steps, 'settle-ms': settle, completion, ...(completion === 'partial' ? { 'failed-iteration': records.at(-1)!.iteration, 'failure-code': records.at(-1)!.failure!.code } : {}) },
|
|
109
|
+
summary: completion === 'completed'
|
|
110
|
+
? fact`${records.length} iteration(s) completed; each record is one ordered ${steps}-step sequence against the live tab.`
|
|
111
|
+
: fact`${records.length - 1} iteration(s) completed before iteration ${records.at(-1)!.iteration} did not complete; completed action records remain in this result.`,
|
|
112
|
+
sections,
|
|
113
|
+
jsonSections: { iterations: records },
|
|
114
|
+
}, { json: parsed.json });
|
|
115
|
+
if (completion === 'partial') process.exitCode = 1;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function cmdPageRepeat(parsed: ParsedArgs, _args: string[]): Promise<void> {
|
|
119
|
+
if (parsed.help) {
|
|
120
|
+
console.log(USAGE);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const steps = parseSteps(parsed.steps);
|
|
124
|
+
const iterations = parsed.iterations;
|
|
125
|
+
if (parsed.positional.length !== 0 || steps === null || iterations === undefined || !Number.isSafeInteger(iterations) || iterations < 1 || iterations > MAX_ITERATIONS) {
|
|
126
|
+
return emitInvalidInput(parsed, 'page repeat', fact`received: ${parsed.positional.length} positional arguments, --steps=${parsed.steps === undefined ? '(missing)' : 'provided'}, --iterations=${String(iterations ?? '(missing)')}; expected no positionals, a JSON array of 1..${MAX_STEPS} click/type step objects, and an iteration count from 1 to ${MAX_ITERATIONS}.`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const deps = pageInputDeps();
|
|
130
|
+
const settle = effectiveSettle(parsed, { standalone: 1000, session: 2500 });
|
|
131
|
+
const records: IterationRecord[] = [];
|
|
132
|
+
|
|
133
|
+
for (let iteration = 1; iteration <= iterations; iteration++) {
|
|
134
|
+
const started = performance.now();
|
|
135
|
+
try {
|
|
136
|
+
const { result, settle: settleFacts } = await deps.withPageAction(
|
|
137
|
+
{ ...parsed, command: 'repeat' },
|
|
138
|
+
{ settleMs: settle },
|
|
139
|
+
async (client) => {
|
|
140
|
+
const live = client as unknown as LiveClient;
|
|
141
|
+
const actions: ActionRecord[] = [];
|
|
142
|
+
for (const step of steps) {
|
|
143
|
+
if (step.action === 'click') {
|
|
144
|
+
const resolved = await resolveLiveTarget(live, step.target);
|
|
145
|
+
if (!resolved.ok) throw new RepeatResolutionFailure(resolved, actions);
|
|
146
|
+
const dispatch = await clickResolved(live, resolved, { inspectHitTest: true, includeHitTestReceiver: true });
|
|
147
|
+
actions.push(actionRecord('click', dispatch, step.target));
|
|
148
|
+
} else if (step.into !== undefined) {
|
|
149
|
+
const resolved = await resolveLiveTarget(live, step.into);
|
|
150
|
+
if (!resolved.ok) throw new RepeatResolutionFailure(resolved, actions);
|
|
151
|
+
const dispatch = await focusAndType(live, resolved, step.text, { inspectHitTest: true, includeHitTestReceiver: true });
|
|
152
|
+
actions.push(actionRecord('type', dispatch, step.into, step.text));
|
|
153
|
+
} else {
|
|
154
|
+
await typeText(live, step.text);
|
|
155
|
+
actions.push({ action: 'type', text: step.text, focused: true });
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return { actions } as const;
|
|
159
|
+
},
|
|
160
|
+
);
|
|
161
|
+
records.push({ iteration, completion: 'completed', durationMs: performance.now() - started, settle: { requestedMs: settleFacts.requestedMs, waitedMs: settleFacts.waitedMs }, actions: result.actions });
|
|
162
|
+
} catch (error) {
|
|
163
|
+
if (!(error instanceof RepeatResolutionFailure)) throw error;
|
|
164
|
+
const failure = error.failure;
|
|
165
|
+
const failureRecord: FailureRecord = {
|
|
166
|
+
code: failure.code,
|
|
167
|
+
target: failure.input,
|
|
168
|
+
...('matchCount' in failure ? { matches: failure.matchCount } : {}),
|
|
169
|
+
};
|
|
170
|
+
records.push({ iteration, completion: 'failed', durationMs: performance.now() - started, actions: error.actions, failure: failureRecord });
|
|
171
|
+
return emitRepeatResult(parsed, steps.length, settle, records, 'partial');
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
emitRepeatResult(parsed, steps.length, settle, records, 'completed');
|
|
176
|
+
}
|
|
@@ -22,14 +22,15 @@ import {
|
|
|
22
22
|
const USAGE = `capture page scroll <target> --to <top|bottom|px> — scroll one resolved container to a position
|
|
23
23
|
|
|
24
24
|
input:
|
|
25
|
-
<target> resolved against the LIVE page: bare CSS selector (takes precedence) or exact accessible name when CSS finds none, ax:<name> (case-insensitive substring), axid:<id>, backend:<id>; text: is not accepted; exactly one match required — zero or many matches is a structured error listing candidates
|
|
25
|
+
<target> resolved against the LIVE page: bare CSS selector (takes precedence) or exact accessible name when CSS finds none, ax:<name> (case-insensitive substring), axid:<id>, backend:<id>; text: is not accepted; exactly one match required — zero or many matches is a structured error listing candidates. This leaf does not enumerate scroll containers; when the target is unknown, use \`capture measure map scroll\`.
|
|
26
26
|
--to <dest> required destination: top, bottom, or a pixel offset (scrollTop value)
|
|
27
|
+
--behavior <instant|smooth> dispatch behavior; instant is the default, smooth waits at most 5000ms for scrollend before reporting the measured offset
|
|
27
28
|
--settle <ms> network-settle window applied after the scroll (default: 1000; 2500 with an active session; 0 disables)
|
|
28
29
|
--no-screenshot skip the auto-screenshot
|
|
29
30
|
output:
|
|
30
|
-
<scrolled backend-node-id=… role=… name=…> — resolved identity, destination, the container's
|
|
31
|
+
<scrolled backend-node-id=… role=… name=…> — resolved identity, destination, scrollTop before/after, the container's maximum scrollTop, 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
|
|
31
32
|
effects:
|
|
32
|
-
|
|
33
|
+
drives the container's in-page scroll (instant by default; may trigger lazy-load network); writes one screenshot into the active session's shots/ sequence unless --no-screenshot`;
|
|
33
34
|
|
|
34
35
|
export async function cmdPageScroll(parsed: ParsedArgs, _args: string[]): Promise<void> {
|
|
35
36
|
if (parsed.help) {
|
|
@@ -74,7 +75,7 @@ export async function cmdPageScroll(parsed: ParsedArgs, _args: string[]): Promis
|
|
|
74
75
|
if (!resolved.ok) return { failure: resolved } as const;
|
|
75
76
|
// Same landmark shape `motion rec --do scroll:` records; carried by
|
|
76
77
|
// the one mutating call when the transport records landmarks.
|
|
77
|
-
const dispatch = await scrollResolved(live, resolved, to, { mark: `scroll:${target},to=${to}
|
|
78
|
+
const dispatch = await scrollResolved(live, resolved, to, { mark: `scroll:${target},to=${to}`, behavior: parsed.behavior ?? 'instant' });
|
|
78
79
|
const screenshotResult = await capturePageInputScreenshot(client, 'scroll', target, parsed.noScreenshot);
|
|
79
80
|
return { dispatch, ...screenshotResult } as const;
|
|
80
81
|
},
|
|
@@ -86,7 +87,8 @@ export async function cmdPageScroll(parsed: ParsedArgs, _args: string[]): Promis
|
|
|
86
87
|
|
|
87
88
|
const { dispatch, screenshot, screenshotWarning } = outcome;
|
|
88
89
|
const rows: FactLine[] = [
|
|
89
|
-
fact`scrolled ${dispatch.role ?? 'unknown'} "${dispatch.name ?? ''}" (backend:${dispatch.backendNodeId}) to ${dispatch.to} — scrollTop
|
|
90
|
+
fact`scrolled ${dispatch.role ?? 'unknown'} "${dispatch.name ?? ''}" (backend:${dispatch.backendNodeId}) to ${dispatch.to} — scrollTop ${dispatch.scrollTopBefore} → ${dispatch.scrollTop} within 0…${dispatch.maxScrollTop}`,
|
|
91
|
+
fact`traversed ${Math.abs(dispatch.scrollTop - dispatch.scrollTopBefore)} CSS px of the container's ${dispatch.maxScrollTop} CSS px vertical range`,
|
|
90
92
|
fact`settle: requested ${settleFacts.requestedMs}ms, waited ${settleFacts.waitedMs}ms`,
|
|
91
93
|
];
|
|
92
94
|
if (screenshot) rows.push(fact`screenshot: ${screenshot}`);
|
|
@@ -100,7 +102,10 @@ export async function cmdPageScroll(parsed: ParsedArgs, _args: string[]): Promis
|
|
|
100
102
|
role: dispatch.role ?? undefined,
|
|
101
103
|
name: dispatch.name ?? undefined,
|
|
102
104
|
to: dispatch.to,
|
|
105
|
+
'scroll-top-before': dispatch.scrollTopBefore,
|
|
103
106
|
'scroll-top': dispatch.scrollTop,
|
|
107
|
+
'max-scroll-top': dispatch.maxScrollTop,
|
|
108
|
+
'scroll-range-traversed': Math.abs(dispatch.scrollTop - dispatch.scrollTopBefore),
|
|
104
109
|
},
|
|
105
110
|
summary: lineList(rows),
|
|
106
111
|
},
|