@crouton-kit/capture 1.3.8 → 1.3.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `capture page navigate <url>` — navigate the SOURCE tab to a destination
|
|
3
|
+
* URL, then report the load outcome as a fact separate from the settle.
|
|
4
|
+
*
|
|
5
|
+
* Source targeting is ordinary page targeting: `--target <id>` / `--url
|
|
6
|
+
* <pattern>` (or, in a session, the session's own tab) selects which tab
|
|
7
|
+
* navigates; the single positional is the destination only. Navigate never
|
|
8
|
+
* creates a tab — `tab open` is the sole tab-creating verb — so an
|
|
9
|
+
* unresolvable source is `connectForCommand`'s structured targeting error,
|
|
10
|
+
* not a silently-opened tab.
|
|
11
|
+
*
|
|
12
|
+
* Dispatch is single-shot: exactly one `Page.navigate` for the destination
|
|
13
|
+
* (plus a `dest → about:blank → dest` bounce only when the target is a
|
|
14
|
+
* same-document/fragment change that returns no loaderId). The load wait
|
|
15
|
+
* (`Page.loadEventFired`) is armed BEFORE the destination send and reported
|
|
16
|
+
* as its own factual outcome — `observed` (the event fired inside the
|
|
17
|
+
* load-wait window) or `bounded-timeout` (it did not; the navigation still
|
|
18
|
+
* dispatched). A load-wait timeout NEVER discards the navigation's own
|
|
19
|
+
* result (its loaderId), and the navigation is never redispatched to chase a
|
|
20
|
+
* missing event.
|
|
21
|
+
*/
|
|
22
|
+
import { connectForCommand, withPageAction, type SettleFacts } from '../../connection.js';
|
|
23
|
+
import { EventBroker } from '../../bridge/server.js';
|
|
24
|
+
import { isRecorderHeldClient, type RecorderHeldClient } from '../../recorder-client.js';
|
|
25
|
+
import { type CDPClient } from '../../client.js';
|
|
26
|
+
import { type ParsedArgs } from '../../types.js';
|
|
27
|
+
import { CaptureError, captureError, invalidInput } from '../../../errors.js';
|
|
28
|
+
import { isParseableUrl } from '../../leaf-grammar.js';
|
|
29
|
+
import { emitResult, fact, text, type FactLine, type RenderableResult } from '../../../output/render.js';
|
|
30
|
+
|
|
31
|
+
const LOAD_EVENT_NAME = 'Page.loadEventFired';
|
|
32
|
+
const DEFAULT_SETTLE_MS = 2000;
|
|
33
|
+
|
|
34
|
+
/** Load-wait window (inner) and total nav-phase deadline (outer) in ms.
|
|
35
|
+
* Injectable so tests drive the timing with tiny timers instead of the real
|
|
36
|
+
* multi-second waits — mirrors `connection.ts`'s `__setConnectionSeamsForTest`
|
|
37
|
+
* seam pattern. */
|
|
38
|
+
interface NavigateTiming {
|
|
39
|
+
innerTimeoutMs: number;
|
|
40
|
+
outerDeadlineMs: number;
|
|
41
|
+
}
|
|
42
|
+
let timing: NavigateTiming = { innerTimeoutMs: 10_000, outerDeadlineMs: 60_000 };
|
|
43
|
+
export function __setNavigateTimingForTest(overrides: Partial<NavigateTiming>): () => void {
|
|
44
|
+
const previous = timing;
|
|
45
|
+
timing = { ...timing, ...overrides };
|
|
46
|
+
return () => { timing = previous; };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const USAGE = `capture page navigate <url> [--settle <ms>] — navigate the source tab to a URL and report the load outcome.
|
|
50
|
+
|
|
51
|
+
Input:
|
|
52
|
+
<url> Destination URL (absolute, WHATWG-parseable). This is the
|
|
53
|
+
destination only — it never selects or creates a tab.
|
|
54
|
+
--settle <ms> Post-load settle wait; default ${DEFAULT_SETTLE_MS}ms. 0 disables.
|
|
55
|
+
--target <id> Select the SOURCE tab to navigate (ordinary page targeting).
|
|
56
|
+
--url <pat> Select the SOURCE tab by URL pattern (ordinary page targeting).
|
|
57
|
+
|
|
58
|
+
Source resolution: --target / --url (or, in a session, the session's own tab)
|
|
59
|
+
picks which tab navigates. Navigate never creates a tab — use "tab open" for
|
|
60
|
+
that; an unresolvable source is a targeting error, not a new tab.
|
|
61
|
+
|
|
62
|
+
Output:
|
|
63
|
+
One <navigated url=… settle=… load-outcome=… deadline-exceeded=…> block:
|
|
64
|
+
the tab URL after navigation, the measured settle, and — reported
|
|
65
|
+
separately — the load outcome (observed | bounded-timeout), whether the
|
|
66
|
+
nav phase exceeded its deadline, the dispatched navigation's loaderId (and
|
|
67
|
+
any about:blank bounce for a same-document target), and whether the CDP
|
|
68
|
+
routed through an active recording. --json mirrors the same fields.
|
|
69
|
+
|
|
70
|
+
Effects:
|
|
71
|
+
Navigates the source tab (exactly one Page.navigate for the destination;
|
|
72
|
+
one dest→about:blank→dest bounce only for a same-document/fragment target).
|
|
73
|
+
During a live composed recording the CDP routes through the recorder socket
|
|
74
|
+
and Page.navigate carries a labeled performance.mark landmark; local
|
|
75
|
+
console/HAR collectors are skipped while routed — traffic streams into the
|
|
76
|
+
session HAR via the recording's held connection.`;
|
|
77
|
+
|
|
78
|
+
/** Load outcome plus the dispatched navigation's own facts, produced by one
|
|
79
|
+
* navigation core (recorder-routed or direct). Distinct fields so the render
|
|
80
|
+
* layer can report the load result separately from the method result. */
|
|
81
|
+
interface NavCore {
|
|
82
|
+
loadOutcome: 'observed' | 'bounded-timeout';
|
|
83
|
+
/** The dispatched navigation's loaderId (present on a real cross-document
|
|
84
|
+
* navigation; absent for a same-document target before the bounce). */
|
|
85
|
+
loaderId?: string;
|
|
86
|
+
/** True when a same-document (fragment-only) target forced the
|
|
87
|
+
* dest→about:blank→dest bounce so a freshly-mounted document sees it. */
|
|
88
|
+
bounced: boolean;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Recorder-routed navigation. The load wait is bundled atomically onto the
|
|
93
|
+
* destination `Page.navigate` via `dispatch()` (the bridge arms the wait
|
|
94
|
+
* before sending), so a fast load cannot race a separate subscription and a
|
|
95
|
+
* wait timeout no longer discards the navigation's loaderId. A same-document
|
|
96
|
+
* target returns no loaderId — recover with exactly one dest→about:blank→dest
|
|
97
|
+
* bounce, bundling the wait only on the final re-navigate.
|
|
98
|
+
*/
|
|
99
|
+
async function navigateViaRecorder(client: RecorderHeldClient, url: string): Promise<NavCore> {
|
|
100
|
+
const first = await client.dispatch('Page.navigate', { url }, LOAD_EVENT_NAME, timing.innerTimeoutMs);
|
|
101
|
+
const loaderId = (first.result as { loaderId?: string } | undefined)?.loaderId;
|
|
102
|
+
if (loaderId) {
|
|
103
|
+
return { loadOutcome: first.waitOutcome ?? 'bounded-timeout', loaderId, bounced: false };
|
|
104
|
+
}
|
|
105
|
+
// Same-document target: the destination is already committed (no loaderId);
|
|
106
|
+
// bounce through about:blank and re-navigate so a fresh document mounts.
|
|
107
|
+
await client.dispatch('Page.navigate', { url: 'about:blank' });
|
|
108
|
+
const final = await client.dispatch('Page.navigate', { url }, LOAD_EVENT_NAME, timing.innerTimeoutMs);
|
|
109
|
+
const finalLoaderId = (final.result as { loaderId?: string } | undefined)?.loaderId;
|
|
110
|
+
return { loadOutcome: final.waitOutcome ?? 'bounded-timeout', loaderId: finalLoaderId, bounced: true };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Arms a bounded `Page.loadEventFired` wait on a direct client's broker and
|
|
114
|
+
* returns the wait handle plus a never-rejecting outcome promise
|
|
115
|
+
* (`observed` | `bounded-timeout`). Arm this BEFORE the paired
|
|
116
|
+
* `Page.navigate` send so a synchronous load event cannot be missed. */
|
|
117
|
+
function armLoadWait(broker: EventBroker): {
|
|
118
|
+
wait: ReturnType<EventBroker['wait']>;
|
|
119
|
+
outcome: Promise<'observed' | 'bounded-timeout'>;
|
|
120
|
+
} {
|
|
121
|
+
const wait = broker.wait(LOAD_EVENT_NAME, undefined, timing.innerTimeoutMs);
|
|
122
|
+
const outcome = wait.result().then(
|
|
123
|
+
() => 'observed' as const,
|
|
124
|
+
() => 'bounded-timeout' as const,
|
|
125
|
+
);
|
|
126
|
+
return { wait, outcome };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Direct (non-recorder) navigation over a real CDP client. The load wait is
|
|
131
|
+
* armed before the destination send (via the shared `EventBroker`), so a
|
|
132
|
+
* synchronous `Page.loadEventFired` is still observed. A method throw
|
|
133
|
+
* propagates (no retry). A same-document target (no loaderId) recovers with
|
|
134
|
+
* exactly one dest→about:blank→dest bounce, re-arming the wait before the
|
|
135
|
+
* final send.
|
|
136
|
+
*/
|
|
137
|
+
async function navigateDirect(
|
|
138
|
+
client: CDPClient,
|
|
139
|
+
url: string,
|
|
140
|
+
armed?: { cancel: () => void },
|
|
141
|
+
): Promise<NavCore> {
|
|
142
|
+
await client.send('Page.enable');
|
|
143
|
+
const broker = new EventBroker(client);
|
|
144
|
+
|
|
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
|
+
try {
|
|
151
|
+
nav = (await client.send('Page.navigate', { url })) as { loaderId?: string };
|
|
152
|
+
} catch (err) {
|
|
153
|
+
first.wait.cancel();
|
|
154
|
+
throw err;
|
|
155
|
+
}
|
|
156
|
+
if (nav?.loaderId) {
|
|
157
|
+
return { loadOutcome: await first.outcome, loaderId: nav.loaderId, bounced: false };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Same-document target: discard the first wait, bounce through about:blank,
|
|
161
|
+
// re-arm the wait, then re-navigate to the destination.
|
|
162
|
+
first.wait.cancel();
|
|
163
|
+
await client.send('Page.navigate', { url: 'about:blank' });
|
|
164
|
+
const second = armLoadWait(broker);
|
|
165
|
+
if (armed) armed.cancel = () => second.wait.cancel();
|
|
166
|
+
let navFinal: { loaderId?: string } | undefined;
|
|
167
|
+
try {
|
|
168
|
+
navFinal = (await client.send('Page.navigate', { url })) as { loaderId?: string };
|
|
169
|
+
} catch (err) {
|
|
170
|
+
second.wait.cancel();
|
|
171
|
+
throw err;
|
|
172
|
+
}
|
|
173
|
+
return { loadOutcome: await second.outcome, loaderId: navFinal?.loaderId, bounced: true };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Races the nav+load phase against the outer deadline. The deadline never
|
|
177
|
+
* throws — on elapse it returns `{ completed: false }` and the caller reports
|
|
178
|
+
* `deadline-exceeded` as a fact alongside whatever partial outcome it holds.
|
|
179
|
+
* A method rejection from `work` propagates (no retry). */
|
|
180
|
+
async function raceNavDeadline(work: Promise<NavCore>): Promise<{ completed: true; core: NavCore } | { completed: false }> {
|
|
181
|
+
let timer: ReturnType<typeof setTimeout>;
|
|
182
|
+
const deadline = new Promise<{ completed: false }>((resolve) => {
|
|
183
|
+
timer = setTimeout(() => resolve({ completed: false }), timing.outerDeadlineMs);
|
|
184
|
+
if (typeof timer === 'object' && 'unref' in timer) timer.unref();
|
|
185
|
+
});
|
|
186
|
+
const workOutcome = work.then((core) => ({ completed: true as const, core }));
|
|
187
|
+
// If the deadline wins the race, `work` is still pending and may later
|
|
188
|
+
// reject (e.g. a hung dispatch that eventually errors) — mark it handled so
|
|
189
|
+
// that late rejection is not an unhandled promise. This does not suppress a
|
|
190
|
+
// rejection that arrives BEFORE the deadline: Promise.race still observes it.
|
|
191
|
+
workOutcome.catch(() => {});
|
|
192
|
+
try {
|
|
193
|
+
return await Promise.race([workOutcome, deadline]);
|
|
194
|
+
} finally {
|
|
195
|
+
clearTimeout(timer!);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
interface NavigatedFacts {
|
|
200
|
+
tabUrl: string;
|
|
201
|
+
requestedUrl: string;
|
|
202
|
+
loadOutcome: 'observed' | 'bounded-timeout';
|
|
203
|
+
deadlineExceeded: boolean;
|
|
204
|
+
loaderId?: string;
|
|
205
|
+
bounced: boolean;
|
|
206
|
+
routed: boolean;
|
|
207
|
+
settle: SettleFacts;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function buildNavigatedResult(f: NavigatedFacts): RenderableResult {
|
|
211
|
+
const sections: FactLine[] = [
|
|
212
|
+
f.loadOutcome === 'observed'
|
|
213
|
+
? fact`load: ${LOAD_EVENT_NAME} observed within the ${timing.innerTimeoutMs}ms load-wait window.`
|
|
214
|
+
: fact`load: ${LOAD_EVENT_NAME} not observed within the ${timing.innerTimeoutMs}ms load-wait window (bounded-timeout); the navigation still dispatched.`,
|
|
215
|
+
fact`navigation dispatched: loaderId ${f.loaderId ?? '(none)'}${f.bounced ? '; delivered via dest→about:blank→dest bounce (same-document target)' : ''}.`,
|
|
216
|
+
fact`settle: requested ${f.settle.requestedMs}ms, waited ${f.settle.waitedMs}ms.`,
|
|
217
|
+
];
|
|
218
|
+
if (f.deadlineExceeded) {
|
|
219
|
+
sections.push(fact`navigation phase exceeded its ${timing.outerDeadlineMs / 1000}s deadline before completing.`);
|
|
220
|
+
}
|
|
221
|
+
if (f.routed) {
|
|
222
|
+
sections.push(
|
|
223
|
+
text`Page.navigate routed through the active recording with a labeled performance.mark; local console/HAR collectors skipped while routed — traffic streams into the session HAR via the recording's held connection.`,
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
return {
|
|
227
|
+
tag: 'navigated',
|
|
228
|
+
attrs: {
|
|
229
|
+
url: f.tabUrl,
|
|
230
|
+
settle: f.settle.requestedMs,
|
|
231
|
+
'load-outcome': f.loadOutcome,
|
|
232
|
+
'deadline-exceeded': f.deadlineExceeded || undefined,
|
|
233
|
+
routed: f.routed || undefined,
|
|
234
|
+
},
|
|
235
|
+
summary: fact`tab at ${f.tabUrl} after Page.navigate to ${f.requestedUrl}.`,
|
|
236
|
+
sections,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export async function cmdPageNavigate(parsed: ParsedArgs, _args: string[]): Promise<void> {
|
|
241
|
+
if (parsed.help) {
|
|
242
|
+
console.log(USAGE);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Positional cardinality (exactly 1) is enforced by `validateCliInvocation`
|
|
247
|
+
// before dispatch reaches this leaf; this guard covers direct programmatic
|
|
248
|
+
// callers only. Failures cross the boundary as typed CaptureErrors —
|
|
249
|
+
// capture.ts is the sole renderer/exit-status owner.
|
|
250
|
+
const url = parsed.positional[0];
|
|
251
|
+
if (!url) {
|
|
252
|
+
throw invalidInput('received: no URL; expected: `capture page navigate <url> [--settle <ms>]`.', 'missing_url');
|
|
253
|
+
}
|
|
254
|
+
// Validate the destination BEFORE any effect (connect/send). connectForCommand
|
|
255
|
+
// never runs for an unparseable URL, so no tab is touched.
|
|
256
|
+
if (!isParseableUrl(url)) {
|
|
257
|
+
throw invalidInput(`received: \`${url}\`; expected: an absolute, WHATWG-parseable URL.`, 'invalid_url');
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const settle = parsed.settle ?? DEFAULT_SETTLE_MS;
|
|
261
|
+
|
|
262
|
+
// Session admission is owned by dispatch.ts's `withActiveSessionAdmission`,
|
|
263
|
+
// which wraps every `page` command already — this leaf mirrors its
|
|
264
|
+
// siblings (click/type/scroll) and does NOT re-admit here.
|
|
265
|
+
//
|
|
266
|
+
// connection.ts derives the recorder landmark label from parsed.command,
|
|
267
|
+
// which the router leaves as the branch token 'page' — restore the verb
|
|
268
|
+
// so a routed navigate's landmark stays `navigate:<url>`.
|
|
269
|
+
//
|
|
270
|
+
// Connection/targeting failures from `connectForCommand` (e.g. its
|
|
271
|
+
// "Use --target/--url" or "No tab found" errors) surface with their own
|
|
272
|
+
// message; only a genuinely raw navigation dispatch failure is re-tagged as
|
|
273
|
+
// `navigate_failed` (scoped to the nav core below), so a valid-URL targeting
|
|
274
|
+
// miss is never mislabeled with a URL/browser hint that doesn't apply.
|
|
275
|
+
const { result: core, settle: settleFacts } = await withPageAction(
|
|
276
|
+
{ ...parsed, command: 'navigate' },
|
|
277
|
+
{ settleMs: settle },
|
|
278
|
+
async (client, _tab) => {
|
|
279
|
+
const routed = isRecorderHeldClient(client);
|
|
280
|
+
// Holder for the direct path's currently-armed load wait, so an
|
|
281
|
+
// outer-deadline abandonment can cancel it (clearing its inner timer).
|
|
282
|
+
const armed = { cancel: () => {} };
|
|
283
|
+
const work = routed
|
|
284
|
+
? navigateViaRecorder(client as unknown as RecorderHeldClient, url)
|
|
285
|
+
: navigateDirect(client, url, armed);
|
|
286
|
+
let raced: Awaited<ReturnType<typeof raceNavDeadline>>;
|
|
287
|
+
try {
|
|
288
|
+
raced = await raceNavDeadline(work);
|
|
289
|
+
} catch (err) {
|
|
290
|
+
// A raw navigation dispatch failure — tag it. Typed capture errors
|
|
291
|
+
// (recorder/targeting) pass through unmodified.
|
|
292
|
+
if (err instanceof CaptureError) throw err;
|
|
293
|
+
throw captureError(
|
|
294
|
+
'world',
|
|
295
|
+
'navigate_failed',
|
|
296
|
+
`${err instanceof Error ? err.message : String(err)} — check the URL is absolute and a CDP-enabled browser is running (probe: capture tab list).`,
|
|
297
|
+
err,
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
if (raced.completed) {
|
|
301
|
+
return { ...raced.core, deadlineExceeded: false, routed, tabUrl: url };
|
|
302
|
+
}
|
|
303
|
+
// Deadline elapsed before the nav phase completed — cancel the armed
|
|
304
|
+
// load wait and report partial facts (load unconfirmed) rather than throwing.
|
|
305
|
+
armed.cancel();
|
|
306
|
+
return {
|
|
307
|
+
loadOutcome: 'bounded-timeout' as const,
|
|
308
|
+
loaderId: undefined,
|
|
309
|
+
bounced: false,
|
|
310
|
+
deadlineExceeded: true,
|
|
311
|
+
routed,
|
|
312
|
+
tabUrl: url,
|
|
313
|
+
};
|
|
314
|
+
},
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
emitResult(
|
|
318
|
+
buildNavigatedResult({
|
|
319
|
+
tabUrl: core.tabUrl,
|
|
320
|
+
requestedUrl: url,
|
|
321
|
+
loadOutcome: core.loadOutcome,
|
|
322
|
+
deadlineExceeded: core.deadlineExceeded,
|
|
323
|
+
loaderId: core.loaderId,
|
|
324
|
+
bounced: core.bounced,
|
|
325
|
+
routed: core.routed,
|
|
326
|
+
settle: settleFacts,
|
|
327
|
+
}),
|
|
328
|
+
{ json: parsed.json },
|
|
329
|
+
);
|
|
330
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `page scroll <target> --to <top|bottom|px>` — scroll exactly one
|
|
3
|
+
* live-resolved container to a destination (design D3).
|
|
4
|
+
*
|
|
5
|
+
* Drives through `interact.ts`'s shared `scrollResolved` helper, passing a
|
|
6
|
+
* landmark label so a recorder-routed scroll's one mutating call
|
|
7
|
+
* (`Runtime.callFunctionOn`) lands the same labeled input landmark
|
|
8
|
+
* `motion rec --do scroll:` records.
|
|
9
|
+
*/
|
|
10
|
+
import { type ParsedArgs } from '../../types.js';
|
|
11
|
+
import { isScrollDestination } from '../../leaf-grammar.js';
|
|
12
|
+
import { resolveLiveTarget, scrollResolved, type LiveClient } from '../../../interact.js';
|
|
13
|
+
import { emitResult, fact, lineList, type FactLine } from '../../../output/render.js';
|
|
14
|
+
import {
|
|
15
|
+
pageInputDeps,
|
|
16
|
+
effectiveSettle,
|
|
17
|
+
emitInvalidInput,
|
|
18
|
+
emitResolutionError,
|
|
19
|
+
} from './click.js';
|
|
20
|
+
|
|
21
|
+
const USAGE = `capture page scroll <target> --to <top|bottom|px> — scroll one resolved container to a position
|
|
22
|
+
|
|
23
|
+
input:
|
|
24
|
+
<target> resolved against the LIVE page: bare CSS selector, 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
|
+
--to <dest> required destination: top, bottom, or a pixel offset (scrollTop value)
|
|
26
|
+
--settle <ms> network-settle window applied after the scroll (default: 1000; 2500 with an active session; 0 disables)
|
|
27
|
+
--no-screenshot skip the auto-screenshot
|
|
28
|
+
output:
|
|
29
|
+
<scrolled backend-node-id=… role=… name=…> — resolved identity, destination, the container's resulting scrollTop, the measured settle (requested/waited), screenshot artifact path; --json mirrors the same fields
|
|
30
|
+
effects:
|
|
31
|
+
assigns the container's scrollTop in-page (may trigger lazy-load network); writes one screenshot into the active session's shots/ sequence unless --no-screenshot`;
|
|
32
|
+
|
|
33
|
+
export async function cmdPageScroll(parsed: ParsedArgs, _args: string[]): Promise<void> {
|
|
34
|
+
if (parsed.help) {
|
|
35
|
+
console.log(USAGE);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const target = parsed.positional[0];
|
|
39
|
+
if (!target || parsed.positional.length !== 1) {
|
|
40
|
+
return emitInvalidInput(
|
|
41
|
+
parsed,
|
|
42
|
+
'page scroll',
|
|
43
|
+
fact`received: ${parsed.positional.length} positional arguments; expected exactly one target (CSS selector, ax:<name>, axid:<id>, or backend:<id>) plus --to <top|bottom|px>.`,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
if (parsed.to === undefined) {
|
|
47
|
+
return emitInvalidInput(
|
|
48
|
+
parsed,
|
|
49
|
+
'page scroll',
|
|
50
|
+
fact`received: no --to destination; expected --to <top|bottom|px>.`,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
const to = parsed.to;
|
|
54
|
+
if (!isScrollDestination(to)) {
|
|
55
|
+
return emitInvalidInput(
|
|
56
|
+
parsed,
|
|
57
|
+
'page scroll',
|
|
58
|
+
fact`received: --to \`${to}\`; expected top, bottom, or a pixel offset.`,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const deps = pageInputDeps();
|
|
63
|
+
const settle = effectiveSettle(parsed, { standalone: 1000, session: 2500 });
|
|
64
|
+
// connection.ts derives the recorder landmark label from parsed.command,
|
|
65
|
+
// which the router leaves as the branch token 'page' — restore the verb so
|
|
66
|
+
// a routed scroll's connection-level label stays `scroll:<target>`.
|
|
67
|
+
const { result: outcome, settle: settleFacts } = await deps.withPageAction(
|
|
68
|
+
{ ...parsed, command: 'scroll' },
|
|
69
|
+
{ settleMs: settle },
|
|
70
|
+
async (client) => {
|
|
71
|
+
const live = client as unknown as LiveClient;
|
|
72
|
+
const resolved = await resolveLiveTarget(live, target);
|
|
73
|
+
if (!resolved.ok) return { failure: resolved } as const;
|
|
74
|
+
// Same landmark shape `motion rec --do scroll:` records; carried by
|
|
75
|
+
// the one mutating call when the transport records landmarks.
|
|
76
|
+
const dispatch = await scrollResolved(live, resolved, to, { mark: `scroll:${target},to=${to}` });
|
|
77
|
+
const screenshot = await deps.autoScreenshot(client, 'scroll', target, parsed.noScreenshot);
|
|
78
|
+
return { dispatch, screenshot } as const;
|
|
79
|
+
},
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
if ('failure' in outcome) {
|
|
83
|
+
return emitResolutionError(parsed, 'page scroll', outcome.failure);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const { dispatch, screenshot } = outcome;
|
|
87
|
+
const rows: FactLine[] = [
|
|
88
|
+
fact`scrolled ${dispatch.role ?? 'unknown'} "${dispatch.name ?? ''}" (backend:${dispatch.backendNodeId}) to ${dispatch.to} — scrollTop now ${dispatch.scrollTop}`,
|
|
89
|
+
fact`settle: requested ${settleFacts.requestedMs}ms, waited ${settleFacts.waitedMs}ms`,
|
|
90
|
+
];
|
|
91
|
+
if (screenshot) rows.push(fact`screenshot: ${screenshot}`);
|
|
92
|
+
|
|
93
|
+
emitResult(
|
|
94
|
+
{
|
|
95
|
+
tag: 'scrolled',
|
|
96
|
+
attrs: {
|
|
97
|
+
'backend-node-id': dispatch.backendNodeId,
|
|
98
|
+
role: dispatch.role ?? undefined,
|
|
99
|
+
name: dispatch.name ?? undefined,
|
|
100
|
+
to: dispatch.to,
|
|
101
|
+
'scroll-top': dispatch.scrollTop,
|
|
102
|
+
},
|
|
103
|
+
summary: lineList(rows),
|
|
104
|
+
},
|
|
105
|
+
{ json: parsed.json },
|
|
106
|
+
);
|
|
107
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `page shot` — capture a PNG of the tab as it is right now (design D2/D10).
|
|
3
|
+
*
|
|
4
|
+
* The navigational look: vision as navigator, never inspector — cheap, no
|
|
5
|
+
* settling, no collectors. With no flags it captures the browser's ACTUAL
|
|
6
|
+
* current viewport and performs zero `Emulation.*` calls; emulation exists
|
|
7
|
+
* only when explicitly asked (`--viewport <WxH>` / `--full-page`), applied
|
|
8
|
+
* as a transient `Emulation.setDeviceMetricsOverride` (+ ~150ms re-layout)
|
|
9
|
+
* and cleared after — two page-observable resizes, declared as the leaf's
|
|
10
|
+
* effect in `-h` (I-1/I-2 posture). Explicit viewport emulation uses the
|
|
11
|
+
* single `WxH` grammar (D10).
|
|
12
|
+
*
|
|
13
|
+
* Destination: `--out <path>` wins; otherwise the active session's `shots/`
|
|
14
|
+
* sequence; sessionless with no `--out` → a fresh `oneshot-{id}/page` dir
|
|
15
|
+
* under the capture root (never a loose /tmp file).
|
|
16
|
+
*/
|
|
17
|
+
import * as fs from 'fs';
|
|
18
|
+
import * as path from 'path';
|
|
19
|
+
import { type ParsedArgs } from '../../types.js';
|
|
20
|
+
import { withConnection } from '../../connection.js';
|
|
21
|
+
import { captureScreenshot } from '../../screenshot.js';
|
|
22
|
+
import { parseViewport, type Viewport } from '../../viewport.js';
|
|
23
|
+
import { nextStepPath } from '../../../session-context.js';
|
|
24
|
+
import { createOneshotSession } from '../../../session/commands.js';
|
|
25
|
+
import { assertUnderCaptureRoot, writeBinaryPrivate } from '../../../session/artifacts.js';
|
|
26
|
+
import {
|
|
27
|
+
emitResult,
|
|
28
|
+
fact,
|
|
29
|
+
text,
|
|
30
|
+
type FactLine,
|
|
31
|
+
type RenderableResult,
|
|
32
|
+
} from '../../../output/render.js';
|
|
33
|
+
|
|
34
|
+
const USAGE = `capture page shot — capture a PNG of the tab as it is right now
|
|
35
|
+
|
|
36
|
+
input:
|
|
37
|
+
--viewport <WxH> transient device-metrics override for this capture; grammar: <positive-safe-int>x<positive-safe-int>, exact lowercase x with no whitespace — preset names are not accepted. Absent → no emulation, the browser's actual current viewport is captured
|
|
38
|
+
--full-page transient override to the full scrollable content height for this capture
|
|
39
|
+
--out <path> destination file; default: the active session's shots/ sequence, or a fresh oneshot-*/page/ dir under the capture root when no session is active
|
|
40
|
+
--target <tabId> | --url <pattern> | --port <n> tab targeting; defaults to the active session tab
|
|
41
|
+
--json mirror the result as JSON
|
|
42
|
+
output:
|
|
43
|
+
<screenshot path=… width=… height=… emulation=none|viewport|full-page> — saved path, PNG pixel dimensions, byte size, and the emulation fact (whether a transient override was applied, or its absence)
|
|
44
|
+
effects:
|
|
45
|
+
no flags: none — the capture reads the viewport as-is, with zero Emulation.* calls. With --viewport/--full-page: applies a transient Emulation.setDeviceMetricsOverride (~150ms re-layout wait) and clears it after the capture — two page-observable resizes (resize events fire; media queries flip and flip back)`;
|
|
46
|
+
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// Test-injectable dependency seam (the CDP-stub test pattern; the capture
|
|
49
|
+
// pipeline itself — captureScreenshot — is NOT injectable, so the tests
|
|
50
|
+
// prove the real CDP traffic against a stub client).
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
export interface PageShotDeps {
|
|
54
|
+
withConnection: typeof withConnection;
|
|
55
|
+
nextStepPath: typeof nextStepPath;
|
|
56
|
+
createOneshotSession: typeof createOneshotSession;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let deps: PageShotDeps = { withConnection, nextStepPath, createOneshotSession };
|
|
60
|
+
|
|
61
|
+
/** Swap the connection/session seams for the CDP-stub tests. */
|
|
62
|
+
export function __setPageShotDepsForTest(overrides: Partial<PageShotDeps>): () => void {
|
|
63
|
+
const previous = deps;
|
|
64
|
+
deps = { ...deps, ...overrides };
|
|
65
|
+
return () => { deps = previous; };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
// PNG dimensions — measured from the actual bytes written, not echoed input
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
73
|
+
|
|
74
|
+
/** Reads width/height from the IHDR chunk (always first per the PNG spec).
|
|
75
|
+
* Returns null for anything that is not a parseable PNG. */
|
|
76
|
+
export function pngDimensions(png: Buffer): { width: number; height: number } | null {
|
|
77
|
+
if (png.length < 24) return null;
|
|
78
|
+
if (!png.subarray(0, 8).equals(PNG_SIGNATURE)) return null;
|
|
79
|
+
return { width: png.readUInt32BE(16), height: png.readUInt32BE(20) };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
// Destination + private write
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Resolves the output path: explicit `--out` > active session's `shots/`
|
|
88
|
+
* sequence > a fresh `oneshot-{id}/page` dir under the capture root.
|
|
89
|
+
*/
|
|
90
|
+
async function resolveOutPath(parsed: ParsedArgs): Promise<string> {
|
|
91
|
+
if (parsed.out) return parsed.out;
|
|
92
|
+
const sessionPath = await deps.nextStepPath('shot', 'manual');
|
|
93
|
+
if (sessionPath) return sessionPath;
|
|
94
|
+
return path.join(deps.createOneshotSession('page').artifactsDir, 'shot.png');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Writes the PNG privately (0600 under a 0700 dir) when it lands under
|
|
99
|
+
* CAPTURE_ROOT (auto-generated session/oneshot paths), and with a plain
|
|
100
|
+
* write when the user gave an explicit `--out` outside the capture tree —
|
|
101
|
+
* a user-chosen destination whose permissions are the user's to decide.
|
|
102
|
+
*/
|
|
103
|
+
function writeScreenshot(outPath: string, png: Buffer): void {
|
|
104
|
+
try {
|
|
105
|
+
assertUnderCaptureRoot(outPath);
|
|
106
|
+
} catch {
|
|
107
|
+
fs.writeFileSync(outPath, png);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
writeBinaryPrivate(outPath, png);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
// Result assembly
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
export type EmulationMode = 'none' | 'viewport' | 'full-page';
|
|
118
|
+
|
|
119
|
+
export function buildScreenshotResult(f: {
|
|
120
|
+
path: string;
|
|
121
|
+
bytes: number;
|
|
122
|
+
dimensions: { width: number; height: number } | null;
|
|
123
|
+
emulation: EmulationMode;
|
|
124
|
+
viewport?: { width: number; height: number };
|
|
125
|
+
}): RenderableResult {
|
|
126
|
+
const sections: FactLine[] = [
|
|
127
|
+
f.emulation === 'none'
|
|
128
|
+
? text`emulation: none — the browser's actual current viewport was captured; no Emulation call was made.`
|
|
129
|
+
: f.emulation === 'viewport'
|
|
130
|
+
? fact`emulation: transient ${f.viewport!.width}x${f.viewport!.height} device-metrics override applied for the capture and cleared after — two page-observable resizes.`
|
|
131
|
+
: f.viewport
|
|
132
|
+
? fact`emulation: transient full-page device-metrics override (from ${f.viewport.width}x${f.viewport.height}) applied for the capture and cleared after — two page-observable resizes.`
|
|
133
|
+
: text`emulation: transient full-page device-metrics override applied for the capture and cleared after — two page-observable resizes.`,
|
|
134
|
+
];
|
|
135
|
+
return {
|
|
136
|
+
tag: 'screenshot',
|
|
137
|
+
attrs: {
|
|
138
|
+
path: f.path,
|
|
139
|
+
width: f.dimensions?.width,
|
|
140
|
+
height: f.dimensions?.height,
|
|
141
|
+
emulation: f.emulation,
|
|
142
|
+
},
|
|
143
|
+
summary: f.dimensions
|
|
144
|
+
? fact`saved ${f.path} — ${f.dimensions.width}x${f.dimensions.height}px, ${f.bytes} bytes.`
|
|
145
|
+
: fact`saved ${f.path} — ${f.bytes} bytes.`,
|
|
146
|
+
sections,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
// page shot
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
export async function cmdPageShot(parsed: ParsedArgs, _args: string[]): Promise<void> {
|
|
155
|
+
if (parsed.help) {
|
|
156
|
+
console.log(USAGE);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (parsed.positional.length > 0) {
|
|
161
|
+
emitResult(
|
|
162
|
+
{
|
|
163
|
+
tag: 'error',
|
|
164
|
+
attrs: { command: 'page shot', code: 'invalid_input' },
|
|
165
|
+
summary: fact`received: ${parsed.positional.length} positional arguments; expected none — \`capture page shot [--viewport <WxH>] [--full-page] [--out <path>]\`.`,
|
|
166
|
+
},
|
|
167
|
+
{ json: parsed.json },
|
|
168
|
+
);
|
|
169
|
+
process.exitCode = 1;
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
let viewport: Viewport | undefined;
|
|
174
|
+
if (parsed.viewport !== undefined) {
|
|
175
|
+
try {
|
|
176
|
+
viewport = parseViewport(parsed.viewport);
|
|
177
|
+
} catch {
|
|
178
|
+
emitResult(
|
|
179
|
+
{
|
|
180
|
+
tag: 'error',
|
|
181
|
+
attrs: { command: 'page shot', code: 'invalid_viewport' },
|
|
182
|
+
summary: fact`received: --viewport ${parsed.viewport}; expected: <positive-safe-int>x<positive-safe-int> with exact lowercase x and no whitespace. Preset names are not accepted.`,
|
|
183
|
+
},
|
|
184
|
+
{ json: parsed.json },
|
|
185
|
+
);
|
|
186
|
+
process.exitCode = 1;
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const emulation: EmulationMode = parsed.fullPage ? 'full-page' : viewport ? 'viewport' : 'none';
|
|
192
|
+
|
|
193
|
+
// connection.ts derives the recorder-routed action label from
|
|
194
|
+
// parsed.command, which the router leaves as the branch token 'page' —
|
|
195
|
+
// restore the verb so stderr diagnostics identify this leaf.
|
|
196
|
+
const result = await deps.withConnection(
|
|
197
|
+
{ ...parsed, command: 'shot' },
|
|
198
|
+
async (client) => {
|
|
199
|
+
const png = await captureScreenshot(client, viewport, { fullPage: parsed.fullPage });
|
|
200
|
+
const outPath = await resolveOutPath(parsed);
|
|
201
|
+
writeScreenshot(outPath, png);
|
|
202
|
+
return { path: outPath, bytes: png.length, dimensions: pngDimensions(png) };
|
|
203
|
+
},
|
|
204
|
+
{ settle: 0 },
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
emitResult(buildScreenshotResult({ ...result, emulation, viewport }), { json: parsed.json });
|
|
208
|
+
}
|