@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
package/src/cdp/screenshot.ts
CHANGED
|
@@ -1,84 +1,215 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { PNG } from 'pngjs';
|
|
2
2
|
import { CDPClient } from './client.js';
|
|
3
3
|
import { nextStepPath } from '../session-context.js';
|
|
4
|
+
import { writeBinaryPrivate } from '../session/artifacts.js';
|
|
5
|
+
import { withScopeSerialization } from './scope-lock.js';
|
|
4
6
|
|
|
7
|
+
/**
|
|
8
|
+
* Downscales a PNG so its longest side fits within maxDim, using a box
|
|
9
|
+
* filter. Used when a clipped/scaled CDP capture is unavailable and the raw
|
|
10
|
+
* capture exceeds the dimension budget.
|
|
11
|
+
*/
|
|
12
|
+
function downscalePngToFit(png: Buffer, maxDim: number): Buffer {
|
|
13
|
+
const src = PNG.sync.read(png);
|
|
14
|
+
const maxSide = Math.max(src.width, src.height);
|
|
15
|
+
if (maxSide <= maxDim) return png;
|
|
16
|
+
const ratio = maxDim / maxSide;
|
|
17
|
+
const dstW = Math.max(1, Math.round(src.width * ratio));
|
|
18
|
+
const dstH = Math.max(1, Math.round(src.height * ratio));
|
|
19
|
+
const dst = new PNG({ width: dstW, height: dstH });
|
|
20
|
+
for (let y = 0; y < dstH; y++) {
|
|
21
|
+
const sy0 = Math.floor((y * src.height) / dstH);
|
|
22
|
+
const sy1 = Math.max(sy0 + 1, Math.floor(((y + 1) * src.height) / dstH));
|
|
23
|
+
for (let x = 0; x < dstW; x++) {
|
|
24
|
+
const sx0 = Math.floor((x * src.width) / dstW);
|
|
25
|
+
const sx1 = Math.max(sx0 + 1, Math.floor(((x + 1) * src.width) / dstW));
|
|
26
|
+
let r = 0;
|
|
27
|
+
let g = 0;
|
|
28
|
+
let b = 0;
|
|
29
|
+
let a = 0;
|
|
30
|
+
let n = 0;
|
|
31
|
+
for (let sy = sy0; sy < sy1 && sy < src.height; sy++) {
|
|
32
|
+
for (let sx = sx0; sx < sx1 && sx < src.width; sx++) {
|
|
33
|
+
const i = (sy * src.width + sx) * 4;
|
|
34
|
+
r += src.data[i];
|
|
35
|
+
g += src.data[i + 1];
|
|
36
|
+
b += src.data[i + 2];
|
|
37
|
+
a += src.data[i + 3];
|
|
38
|
+
n++;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const j = (y * dstW + x) * 4;
|
|
42
|
+
dst.data[j] = Math.round(r / n);
|
|
43
|
+
dst.data[j + 1] = Math.round(g / n);
|
|
44
|
+
dst.data[j + 2] = Math.round(b / n);
|
|
45
|
+
dst.data[j + 3] = Math.round(a / n);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return PNG.sync.write(dst);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Captures one screenshot, applying (and always clearing) a device-metrics
|
|
53
|
+
* override when a viewport or full-page capture is requested. The override
|
|
54
|
+
* is target-visible state driven over the connection: a direct connection's
|
|
55
|
+
* override dies with its own websocket session, but the recorder-held
|
|
56
|
+
* connection is shared across commands, so the entire
|
|
57
|
+
* setDeviceMetricsOverride→capture→clear scope serializes under the owning
|
|
58
|
+
* session's `.viewport-scope.lock` (`withScopeSerialization`) when the
|
|
59
|
+
* client is the recorder-held adapter — otherwise a concurrent routed
|
|
60
|
+
* caller could clear this capture's live override mid-scope (or capture
|
|
61
|
+
* inside someone else's).
|
|
62
|
+
*/
|
|
5
63
|
export async function captureScreenshot(
|
|
6
64
|
client: CDPClient,
|
|
7
65
|
viewport?: { width: number; height: number },
|
|
8
66
|
options?: { fullPage?: boolean },
|
|
67
|
+
): Promise<Buffer> {
|
|
68
|
+
return withScopeSerialization(client, 'viewport', 'screenshot capture', () =>
|
|
69
|
+
viewportScopedCapture(client, viewport, options),
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The one viewport/capture state transaction: override (when requested) →
|
|
75
|
+
* capture → clear. Restoration ownership is claimed BEFORE the override is
|
|
76
|
+
* awaited (a lost response does not prove Chrome rejected it); a clear
|
|
77
|
+
* failure prevents success — alone it throws, paired with a primary failure
|
|
78
|
+
* it throws an `AggregateError` preserving both facts.
|
|
79
|
+
*/
|
|
80
|
+
async function viewportScopedCapture(
|
|
81
|
+
client: CDPClient,
|
|
82
|
+
viewport?: { width: number; height: number },
|
|
83
|
+
options?: { fullPage?: boolean },
|
|
9
84
|
): Promise<Buffer> {
|
|
10
85
|
const MAX_DIM = 1600; // headroom below Anthropic's 2000px many-image limit
|
|
86
|
+
let ownsDeviceMetricsOverride = false;
|
|
87
|
+
let primaryFailed = false;
|
|
88
|
+
let primaryError: unknown;
|
|
11
89
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
90
|
+
try {
|
|
91
|
+
if (viewport) {
|
|
92
|
+
// A rejected response does not prove that Chrome rejected the request.
|
|
93
|
+
// Claim cleanup responsibility before awaiting every override request.
|
|
94
|
+
ownsDeviceMetricsOverride = true;
|
|
95
|
+
await client.send('Emulation.setDeviceMetricsOverride', {
|
|
96
|
+
width: viewport.width,
|
|
97
|
+
height: viewport.height,
|
|
98
|
+
deviceScaleFactor: 1,
|
|
99
|
+
mobile: false,
|
|
100
|
+
});
|
|
101
|
+
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
102
|
+
}
|
|
23
103
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
104
|
+
if (options?.fullPage) {
|
|
105
|
+
const layoutMetrics = (await client.send('Page.getLayoutMetrics')) as {
|
|
106
|
+
contentSize?: { width: number; height: number };
|
|
107
|
+
cssVisualViewport?: { clientWidth: number };
|
|
108
|
+
};
|
|
109
|
+
const contentWidth = layoutMetrics.cssVisualViewport?.clientWidth ?? viewport?.width ?? 1280;
|
|
110
|
+
const contentHeight = layoutMetrics.contentSize?.height ?? viewport?.height ?? 800;
|
|
111
|
+
ownsDeviceMetricsOverride = true;
|
|
112
|
+
await client.send('Emulation.setDeviceMetricsOverride', {
|
|
113
|
+
width: contentWidth,
|
|
114
|
+
height: Math.ceil(contentHeight),
|
|
115
|
+
deviceScaleFactor: 1,
|
|
116
|
+
mobile: false,
|
|
117
|
+
});
|
|
118
|
+
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
let screenshotOpts: Record<string, unknown> = {
|
|
122
|
+
format: 'png',
|
|
123
|
+
captureBeyondViewport: false,
|
|
29
124
|
};
|
|
30
|
-
const contentWidth = layoutMetrics.cssVisualViewport?.clientWidth ?? viewport?.width ?? 1280;
|
|
31
|
-
const contentHeight = layoutMetrics.contentSize?.height ?? viewport?.height ?? 800;
|
|
32
|
-
await client.send('Emulation.setDeviceMetricsOverride', {
|
|
33
|
-
width: contentWidth,
|
|
34
|
-
height: Math.ceil(contentHeight),
|
|
35
|
-
deviceScaleFactor: 1,
|
|
36
|
-
mobile: false,
|
|
37
|
-
});
|
|
38
|
-
await new Promise((r) => setTimeout(r, 150));
|
|
39
|
-
}
|
|
40
125
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
126
|
+
try {
|
|
127
|
+
const metrics = (await client.send('Page.getLayoutMetrics')) as {
|
|
128
|
+
cssVisualViewport?: { clientWidth: number; clientHeight: number; pageX: number; pageY: number };
|
|
129
|
+
};
|
|
130
|
+
// Snap the clip to integer CSS pixels: browser zoom (e.g. 110%) makes the
|
|
131
|
+
// CSS viewport fractional, and some Chromium-based browsers (Arc, at
|
|
132
|
+
// least) answer a clipped Page.captureScreenshot whose output dimensions
|
|
133
|
+
// are fractional with a SUCCESSFUL response carrying empty `data`.
|
|
134
|
+
const vw = Math.round(metrics.cssVisualViewport?.clientWidth ?? 0);
|
|
135
|
+
const vh = Math.round(metrics.cssVisualViewport?.clientHeight ?? 0);
|
|
136
|
+
const sx = Math.floor(metrics.cssVisualViewport?.pageX ?? 0);
|
|
137
|
+
const sy = Math.floor(metrics.cssVisualViewport?.pageY ?? 0);
|
|
45
138
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
const vh = metrics.cssVisualViewport?.clientHeight ?? 0;
|
|
52
|
-
const sx = metrics.cssVisualViewport?.pageX ?? 0;
|
|
53
|
-
const sy = metrics.cssVisualViewport?.pageY ?? 0;
|
|
54
|
-
|
|
55
|
-
const dprResult = (await client.send('Runtime.evaluate', {
|
|
56
|
-
expression: 'window.devicePixelRatio',
|
|
57
|
-
returnByValue: true,
|
|
58
|
-
})) as { result: { value: number } };
|
|
59
|
-
const dpr = dprResult.result.value ?? 1;
|
|
60
|
-
|
|
61
|
-
const actualMaxSide = Math.max(vw, vh) * dpr;
|
|
62
|
-
const scale = actualMaxSide > MAX_DIM ? MAX_DIM / actualMaxSide : 1 / dpr;
|
|
63
|
-
screenshotOpts = {
|
|
64
|
-
...screenshotOpts,
|
|
65
|
-
clip: { x: sx, y: sy, width: vw, height: vh, scale },
|
|
66
|
-
};
|
|
67
|
-
} catch {
|
|
68
|
-
// Fallback: capture without downscaling
|
|
69
|
-
}
|
|
139
|
+
const dprResult = (await client.send('Runtime.evaluate', {
|
|
140
|
+
expression: 'window.devicePixelRatio',
|
|
141
|
+
returnByValue: true,
|
|
142
|
+
})) as { result: { value: number } };
|
|
143
|
+
const dpr = dprResult.result.value ?? 1;
|
|
70
144
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
145
|
+
const actualMaxSide = Math.max(vw, vh) * dpr;
|
|
146
|
+
const scale = actualMaxSide > MAX_DIM ? MAX_DIM / actualMaxSide : 1 / dpr;
|
|
147
|
+
screenshotOpts = {
|
|
148
|
+
...screenshotOpts,
|
|
149
|
+
clip: { x: sx, y: sy, width: vw, height: vh, scale },
|
|
150
|
+
};
|
|
151
|
+
} catch {
|
|
152
|
+
// Capture without downscaling when the optional metrics probe is unavailable.
|
|
153
|
+
}
|
|
75
154
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
155
|
+
const result = (await client.send(
|
|
156
|
+
'Page.captureScreenshot',
|
|
157
|
+
screenshotOpts,
|
|
158
|
+
)) as { data?: string };
|
|
159
|
+
let png = Buffer.from(result.data ?? '', 'base64');
|
|
160
|
+
|
|
161
|
+
// A fractional scale (1/devicePixelRatio) can still produce fractional
|
|
162
|
+
// output dimensions, which the browsers above answer with empty data.
|
|
163
|
+
// Retry the same integer clip at scale 1, preserving full-page captures,
|
|
164
|
+
// then enforce the dimension budget locally.
|
|
165
|
+
if (png.length === 0 && screenshotOpts.clip) {
|
|
166
|
+
const clip = screenshotOpts.clip as Record<string, unknown>;
|
|
167
|
+
const retry = (await client.send('Page.captureScreenshot', {
|
|
168
|
+
...screenshotOpts,
|
|
169
|
+
clip: { ...clip, scale: 1 },
|
|
170
|
+
})) as { data?: string };
|
|
171
|
+
png = Buffer.from(retry.data ?? '', 'base64');
|
|
172
|
+
if (png.length > 0) png = downscalePngToFit(png, MAX_DIM);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// If the browser rejects clipped capture entirely, an unclipped viewport
|
|
176
|
+
// capture is still better than a false successful 0-byte artifact.
|
|
177
|
+
if (png.length === 0 && screenshotOpts.clip && !options?.fullPage) {
|
|
178
|
+
const retry = (await client.send('Page.captureScreenshot', {
|
|
179
|
+
format: 'png',
|
|
180
|
+
captureBeyondViewport: false,
|
|
181
|
+
})) as { data?: string };
|
|
182
|
+
png = Buffer.from(retry.data ?? '', 'base64');
|
|
183
|
+
if (png.length > 0) png = downscalePngToFit(png, MAX_DIM);
|
|
184
|
+
}
|
|
80
185
|
|
|
81
|
-
|
|
186
|
+
if (png.length === 0) {
|
|
187
|
+
throw new Error(
|
|
188
|
+
'Page.captureScreenshot returned no image data (the tab may not be rendering); refusing to write a 0-byte PNG',
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return png;
|
|
193
|
+
} catch (error) {
|
|
194
|
+
primaryFailed = true;
|
|
195
|
+
primaryError = error;
|
|
196
|
+
throw error;
|
|
197
|
+
} finally {
|
|
198
|
+
if (ownsDeviceMetricsOverride) {
|
|
199
|
+
try {
|
|
200
|
+
await client.send('Emulation.clearDeviceMetricsOverride');
|
|
201
|
+
} catch (cleanupError) {
|
|
202
|
+
if (primaryFailed) {
|
|
203
|
+
throw new AggregateError(
|
|
204
|
+
[primaryError, cleanupError],
|
|
205
|
+
'Screenshot capture failed and device-metrics cleanup also failed.',
|
|
206
|
+
{ cause: primaryError },
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
throw cleanupError;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
82
213
|
}
|
|
83
214
|
|
|
84
215
|
export async function autoScreenshot(
|
|
@@ -88,13 +219,15 @@ export async function autoScreenshot(
|
|
|
88
219
|
noScreenshot?: boolean,
|
|
89
220
|
): Promise<string | null> {
|
|
90
221
|
if (noScreenshot) return null;
|
|
91
|
-
const shotPath = nextStepPath(action, label);
|
|
222
|
+
const shotPath = await nextStepPath(action, label);
|
|
92
223
|
if (!shotPath) return null;
|
|
93
224
|
|
|
94
225
|
// Brief settle for UI to update
|
|
95
226
|
await new Promise((r) => setTimeout(r, 300));
|
|
96
227
|
const png = await captureScreenshot(client);
|
|
97
|
-
|
|
228
|
+
// Shot path always resolves under the session dir (CAPTURE_ROOT); the
|
|
229
|
+
// private writer creates the file 0600 and re-ensures shots/ is 0700.
|
|
230
|
+
writeBinaryPrivate(shotPath, png);
|
|
98
231
|
console.error(` [screenshot] ${shotPath}`);
|
|
99
232
|
return shotPath;
|
|
100
233
|
}
|
package/src/cdp/targets.ts
CHANGED
|
@@ -25,7 +25,7 @@ export async function getBrowserClient(
|
|
|
25
25
|
throw new Error(
|
|
26
26
|
`Port ${port} answers CDP's /json/version but has no browser-level WebSocket debugger URL — ` +
|
|
27
27
|
`it's not a real browser (e.g. a Node/workerd inspector), so it can't host tabs. ` +
|
|
28
|
-
`Run "capture
|
|
28
|
+
`Run "capture tab list" to find a real browser endpoint, or pass --port explicitly.`,
|
|
29
29
|
);
|
|
30
30
|
}
|
|
31
31
|
const client = new CDPClient(version.webSocketDebuggerUrl);
|
|
@@ -208,6 +208,26 @@ export function requireTargetId(
|
|
|
208
208
|
return targetId;
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
+
/**
|
|
212
|
+
* Closes exactly one target through the browser-level connection, the mirror of
|
|
213
|
+
* `openTab`. Transactional owners (session-start rollback) call this to release
|
|
214
|
+
* ONLY the target this attempt opened; it never touches foreign/pre-existing
|
|
215
|
+
* tabs. Throws when `Target.closeTarget` reports the target was not closed.
|
|
216
|
+
*/
|
|
217
|
+
export async function closeTarget(port: number, targetId: string): Promise<void> {
|
|
218
|
+
const { client } = await getBrowserClient(port);
|
|
219
|
+
try {
|
|
220
|
+
const result = (await client.send('Target.closeTarget', { targetId })) as {
|
|
221
|
+
success?: boolean;
|
|
222
|
+
};
|
|
223
|
+
if (result?.success === false) {
|
|
224
|
+
throw new Error(`Target.closeTarget reported no close for target ${targetId} on port ${port}.`);
|
|
225
|
+
}
|
|
226
|
+
} finally {
|
|
227
|
+
client.close();
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
211
231
|
export async function openTab(port: number, url: string): Promise<CDPTarget> {
|
|
212
232
|
const { client } = await getBrowserClient(port);
|
|
213
233
|
|
package/src/cdp/timing.ts
CHANGED
|
@@ -20,13 +20,15 @@ export interface BracketedTiming<T> {
|
|
|
20
20
|
/**
|
|
21
21
|
* Runs `fn`, bracketed by two `performance.now()` reads taken in the page's document execution
|
|
22
22
|
* context. Used to attribute a discrete action (e.g. an `Input.dispatch*` call) to a window in
|
|
23
|
-
* the recorder's authoritative `performance.now()` clock domain
|
|
24
|
-
* the
|
|
23
|
+
* the recorder's authoritative `performance.now()` clock domain. The action's landmark label
|
|
24
|
+
* (`mark`, when the caller supplied one) is recorded host-side only — appended straight to
|
|
25
|
+
* `events.jsonl` alongside these two bracket numbers (see `../recorder-bridge.ts`'s `handleCdp`)
|
|
26
|
+
* — never written into the page's own PerformanceTimeline via `performance.mark()`: that page-
|
|
27
|
+
* visible side channel was removed (a predictable, page-observable channel the injected
|
|
28
|
+
* PerformanceObserver would otherwise re-observe as its own event, in violation of the
|
|
29
|
+
* observational-collector invariant against page-side channels).
|
|
25
30
|
*/
|
|
26
|
-
export async function withDocumentPerformanceNow<T>(
|
|
27
|
-
client: CDPClient,
|
|
28
|
-
fn: () => Promise<T>,
|
|
29
|
-
): Promise<BracketedTiming<T>> {
|
|
31
|
+
export async function withDocumentPerformanceNow<T>(client: CDPClient, fn: () => Promise<T>): Promise<BracketedTiming<T>> {
|
|
30
32
|
const startPerformanceNow = await readPerformanceNow(client);
|
|
31
33
|
const result = await fn();
|
|
32
34
|
const endPerformanceNow = await readPerformanceNow(client);
|
|
@@ -57,10 +59,13 @@ export interface TraceClockBaseline {
|
|
|
57
59
|
|
|
58
60
|
/**
|
|
59
61
|
* Reads one synchronized (`performance.now()`, wall-clock) pair from the page in a single
|
|
60
|
-
* round trip. The recorder bridge
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
62
|
+
* round trip. The recorder bridge reads this once when it arms (`rec-start`) as the anchor half
|
|
63
|
+
* of the three-way `markers.json` baseline; the other two members — the first screencast frame's
|
|
64
|
+
* `metadata.timestamp` (wall-clock seconds) and the first Tracing batch's earliest event `ts`
|
|
65
|
+
* (trace-clock microseconds) — are captured separately by the recorder bridge itself as those
|
|
66
|
+
* events arrive (not by this function, since neither exists yet at arm time). Post-processing
|
|
67
|
+
* converts frame-time and trace-time into the `performance.now()` domain by subtracting this
|
|
68
|
+
* baseline's wall-clock offset.
|
|
64
69
|
*/
|
|
65
70
|
export async function readTraceClockBaseline(client: CDPClient): Promise<TraceClockBaseline> {
|
|
66
71
|
const evaluation = (await client.send('Runtime.evaluate', {
|
package/src/cdp/types.ts
CHANGED
|
@@ -12,24 +12,37 @@ export interface ParsedArgs {
|
|
|
12
12
|
port?: number;
|
|
13
13
|
out?: string;
|
|
14
14
|
json?: boolean;
|
|
15
|
-
interactive?: boolean;
|
|
16
|
-
harOut?: string;
|
|
17
|
-
record?: boolean;
|
|
18
15
|
duration?: number;
|
|
19
16
|
settle?: number;
|
|
20
17
|
file?: string;
|
|
21
|
-
|
|
18
|
+
/** Session-filled internal slot: NOT CLI-settable (no --har flag, no env
|
|
19
|
+
* var). Auto-filled from the active session's harId so withConnection()
|
|
20
|
+
* can auto-append recorded traffic to the session HAR. */
|
|
22
21
|
har?: string;
|
|
23
22
|
new?: boolean;
|
|
24
23
|
target?: string;
|
|
24
|
+
/** Where `target` was assigned from: an actual `--target` flag, active-session
|
|
25
|
+
* autofill, or the ambient `CDP_TARGET` env var. Recorded at the assignment
|
|
26
|
+
* point — browser-scope CDP (`cdp --browser`) attaches a flattened target
|
|
27
|
+
* session only when this is `'flag'`; provenance is never inferred later by
|
|
28
|
+
* comparing final strings. */
|
|
29
|
+
targetSource?: 'flag' | 'session' | 'env';
|
|
25
30
|
url?: string;
|
|
26
|
-
role?: string;
|
|
27
31
|
into?: string;
|
|
28
32
|
noScreenshot?: boolean;
|
|
29
33
|
viewport?: string;
|
|
30
34
|
fullPage?: boolean;
|
|
31
|
-
height?: number;
|
|
32
35
|
help?: boolean;
|
|
36
|
+
/** `--all` — boolean scope-widener (per-leaf semantics). */
|
|
37
|
+
all?: boolean;
|
|
38
|
+
/** `--session <id>` — explicit session id override (per-leaf semantics). */
|
|
39
|
+
session?: string;
|
|
40
|
+
/** `session start --hold` — hold one CDP browser connection open for the session's lifetime. */
|
|
41
|
+
hold?: boolean;
|
|
42
|
+
/** `session view --filter <section>` — show only one bundle-manifest section. */
|
|
43
|
+
filter?: string;
|
|
44
|
+
/** `session log --name <label>` */
|
|
45
|
+
name?: string;
|
|
33
46
|
filterUrl?: string;
|
|
34
47
|
filterStatus?: string;
|
|
35
48
|
filterMethod?: string;
|
|
@@ -78,7 +91,7 @@ export interface ParsedArgs {
|
|
|
78
91
|
from?: string;
|
|
79
92
|
/** `sweep --to <val>` — raw string; axis units vary (px/dpr/zoom). */
|
|
80
93
|
to?: string;
|
|
81
|
-
/** `sweep --viewport-height <val>` — raw string
|
|
94
|
+
/** `sweep --viewport-height <val>` — raw string for the viewport-height sweep axis. */
|
|
82
95
|
viewportHeight?: string;
|
|
83
96
|
/** `motion rec --stop --rec-id <id>` — explicit recording id override. */
|
|
84
97
|
recId?: string;
|
|
@@ -105,4 +118,6 @@ export interface ParsedArgs {
|
|
|
105
118
|
prop?: string;
|
|
106
119
|
/** `motion response --action <action>` */
|
|
107
120
|
action?: string;
|
|
121
|
+
/** `motion response --occurrence <n>` — 1-based occurrence of a repeated action label. */
|
|
122
|
+
occurrence?: number;
|
|
108
123
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { invalidInput } from '../errors.js';
|
|
2
|
+
|
|
3
|
+
export interface Viewport {
|
|
4
|
+
readonly width: number;
|
|
5
|
+
readonly height: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const VIEWPORT_PATTERN = /^([1-9]\d*)x([1-9]\d*)$/;
|
|
9
|
+
|
|
10
|
+
/** Parses the one viewport grammar used by every live capture command. */
|
|
11
|
+
export function parseViewport(value: string): Viewport {
|
|
12
|
+
const match = VIEWPORT_PATTERN.exec(value);
|
|
13
|
+
if (match) {
|
|
14
|
+
const width = Number(match[1]);
|
|
15
|
+
const height = Number(match[2]);
|
|
16
|
+
if (Number.isSafeInteger(width) && Number.isSafeInteger(height)) {
|
|
17
|
+
return { width, height };
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
throw invalidInput(`--viewport must match <positive-safe-int>x<positive-safe-int> using lowercase x with no whitespace; received "${value}"`, 'invalid_viewport');
|
|
21
|
+
}
|
package/src/cdp.ts
CHANGED
|
@@ -1,38 +1,16 @@
|
|
|
1
|
-
#!/usr/bin/env tsx
|
|
2
1
|
/**
|
|
3
|
-
* CDP (Chrome DevTools Protocol) —
|
|
2
|
+
* CDP (Chrome DevTools Protocol) — the library barrel over the CDP substrate.
|
|
4
3
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* capture screenshot Capture screenshot
|
|
16
|
-
* capture a11y Get accessibility tree
|
|
17
|
-
* capture record Passive HAR recording
|
|
18
|
-
* capture navigate <url> Navigate + record HAR
|
|
19
|
-
* capture har create|read|delete Manage HAR recordings
|
|
20
|
-
*
|
|
21
|
-
* Options:
|
|
22
|
-
* --port <port> Override CDP port (auto-detects if not specified)
|
|
23
|
-
* --target <tabId> Target tab by exact ID (preferred, parallel-safe)
|
|
24
|
-
* --new Force open a new tab (open command)
|
|
25
|
-
* --record Enable HAR recording (exec)
|
|
26
|
-
* --har <id> Append traffic to a HAR recording
|
|
27
|
-
* --har-out <path> HAR output path
|
|
28
|
-
* --file <path> Read JS from file (exec)
|
|
29
|
-
* --duration <secs> Recording duration (record, default: 10)
|
|
30
|
-
* --settle <ms> Settle time after navigation (navigate, default: 2000)
|
|
31
|
-
* --out <path> Output path (screenshot)
|
|
32
|
-
* --height <px> Override viewport height (screenshot)
|
|
33
|
-
* --full-page Capture entire scrollable page (screenshot)
|
|
34
|
-
* --json JSON output (a11y)
|
|
35
|
-
* --interactive Interactive elements only (a11y)
|
|
4
|
+
* Re-exports the client, target discovery, screenshot, a11y, console-recording,
|
|
5
|
+
* and tab-lock building blocks, plus `cdpMain` (the routed command dispatch).
|
|
6
|
+
* HAR ownership is NOT exported here: the session HAR manager
|
|
7
|
+
* (`src/har-manager.ts`) fed by the internal streaming recorder
|
|
8
|
+
* (`src/cdp/har-recorder.ts`, a direct import of the recorder bridge) is the
|
|
9
|
+
* sole HAR lane.
|
|
10
|
+
* The command surface itself lives under `src/cdp/commands/` and is routed by
|
|
11
|
+
* `src/capture.ts` → `src/cdp/dispatch.ts`. This module is a pure barrel:
|
|
12
|
+
* `src/capture.ts` is the ONLY entrypoint and the ONLY error/termination
|
|
13
|
+
* boundary — nothing here executes, renders, or exits.
|
|
36
14
|
*/
|
|
37
15
|
|
|
38
16
|
export { CDPClient } from './cdp/client.js';
|
|
@@ -42,32 +20,8 @@ export { listTargets, findTab, findTabById, openTab } from './cdp/targets.js';
|
|
|
42
20
|
export { captureScreenshot } from './cdp/screenshot.js';
|
|
43
21
|
export type { A11yNode } from './cdp/a11y.js';
|
|
44
22
|
export { getAccessibilityTree } from './cdp/a11y.js';
|
|
45
|
-
export {
|
|
46
|
-
export type { ExecuteOptions, ExecuteResult } from './cdp/execute.js';
|
|
47
|
-
export { recordTraffic, navigateAndRecord, navigateAndWait } from './cdp/record.js';
|
|
48
|
-
export type { RecordOptions, RecordResult, NavigateAndRecordOptions, NavigateAndRecordResult } from './cdp/record.js';
|
|
49
|
-
export { HARRecorder } from './cdp/har-recorder.js';
|
|
23
|
+
export { navigateAndWait } from './cdp/record.js';
|
|
50
24
|
export { ConsoleRecorder } from './cdp/console-recorder.js';
|
|
51
25
|
export type { ConsoleEntry } from './cdp/console-recorder.js';
|
|
52
26
|
export { acquireTabLock, isTabLocked, releaseTabLock, withTabLock } from './cdp/tab-lock.js';
|
|
53
27
|
export { cdpMain } from './cdp/dispatch.js';
|
|
54
|
-
|
|
55
|
-
// Only run CLI when executed directly (not imported via capture.ts)
|
|
56
|
-
const isMainModule =
|
|
57
|
-
process.argv[1]?.endsWith('cdp.ts') ||
|
|
58
|
-
process.argv[1]?.endsWith('cdp.mjs');
|
|
59
|
-
if (isMainModule) {
|
|
60
|
-
cdpMain()
|
|
61
|
-
.then(() => {
|
|
62
|
-
// Flush stdout before exiting — process.exit() doesn't wait for stream drain
|
|
63
|
-
if (process.stdout.writableNeedDrain) {
|
|
64
|
-
process.stdout.once('drain', () => process.exit(0));
|
|
65
|
-
} else {
|
|
66
|
-
process.exit(0);
|
|
67
|
-
}
|
|
68
|
-
})
|
|
69
|
-
.catch((err) => {
|
|
70
|
-
console.error(err.message);
|
|
71
|
-
process.exit(1);
|
|
72
|
-
});
|
|
73
|
-
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** Pure specification-help assembly from Capture's descriptor tree. */
|
|
2
|
+
import { type RegistryNode } from './registry.js';
|
|
3
|
+
|
|
4
|
+
const usage = (node: RegistryNode): string => `capture${node.path ? ` ${node.path}` : ''}`;
|
|
5
|
+
const parameter = (name: string, grammar: string, required: boolean, variadic = false): string => ` ${required ? '<' : '['}${name}${required ? '>' : ']'} ${grammar}${variadic ? ' (variadic)' : ''}`;
|
|
6
|
+
|
|
7
|
+
/** Renders only descriptor-owned content; help never parses inputs or performs effects. */
|
|
8
|
+
export function assembleHelp(node: RegistryNode): string {
|
|
9
|
+
const { help } = node;
|
|
10
|
+
const lines = [usage(node), '', help.description, '', `When to use: ${help.whenToUse}`, `Model: ${help.model ?? help.rubric}`];
|
|
11
|
+
if (help.globals) lines.push('', 'Globals:', help.globals);
|
|
12
|
+
if (help.ioContract) lines.push('', `I/O contract: ${help.ioContract}`);
|
|
13
|
+
if (node.kind === 'branch') {
|
|
14
|
+
const children = node.children.filter((child) => child.visibility === 'public');
|
|
15
|
+
if (children.length) {
|
|
16
|
+
lines.push('', 'Commands:');
|
|
17
|
+
for (const child of children) lines.push(` ${child.path.split(' ').at(-1)} ${child.help.description} When to use: ${child.help.whenToUse}`);
|
|
18
|
+
}
|
|
19
|
+
} else {
|
|
20
|
+
if (node.positionals.length || node.flags.length) {
|
|
21
|
+
lines.push('', 'Parameters:');
|
|
22
|
+
for (const positional of node.positionals) lines.push(parameter(positional.name, positional.grammar, positional.required, positional.variadic));
|
|
23
|
+
for (const flag of node.flags) lines.push(` ${flag.name}${flag.grammar === 'boolean' ? '' : ` <${flag.grammar}>`}${flag.required ? ' required' : ''}${flag.repeatable ? ' repeatable' : ''}${flag.default === undefined ? '' : ` default=${flag.default}`}${flag.values ? ` values=${flag.values.join('|')}` : ''}${flag.units ? ` units=${flag.units}` : ''}`);
|
|
24
|
+
}
|
|
25
|
+
lines.push('', `Constraints: ${help.constraints}`, `Output: ${help.orderedOutput}`, `Artifact ownership: ${help.artifactOwnership}`, `Effects: browser=${node.effects.browser}, session=${node.effects.session}, artifact=${node.effects.artifact}, environment=${node.effects.environment}`, `Recovery: ${node.recovery}`);
|
|
26
|
+
}
|
|
27
|
+
lines.push('', `Next action: ${help.followUp}`);
|
|
28
|
+
return `${lines.join('\n')}\n`;
|
|
29
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** Pure pre-dispatch control precedence for U15's future launcher wiring. */
|
|
2
|
+
import { assembleHelp } from './command-help.js';
|
|
3
|
+
import { hasHelpFlag, hasRootVersionFlag, walkCommand } from './command-walker.js';
|
|
4
|
+
import { CAPTURE_REGISTRY, type RegistryBranch, type RegistryNode } from './registry.js';
|
|
5
|
+
|
|
6
|
+
export type CommandResolution =
|
|
7
|
+
| { readonly kind: 'version' }
|
|
8
|
+
| { readonly kind: 'help'; readonly node: RegistryNode; readonly text: string }
|
|
9
|
+
| { readonly kind: 'dispatch'; readonly node: RegistryNode; readonly argv: readonly string[] }
|
|
10
|
+
| { readonly kind: 'unknown-path'; readonly received: string; readonly expected: readonly string[]; readonly path: string };
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Applies only control precedence. It deliberately does not parse leaf argv,
|
|
14
|
+
* validate `--json`, invoke a handler, or allocate any effectful dependency.
|
|
15
|
+
*/
|
|
16
|
+
export function resolveCommand(argv: readonly string[], root: RegistryBranch = CAPTURE_REGISTRY): CommandResolution {
|
|
17
|
+
if (hasRootVersionFlag(argv)) return { kind: 'version' };
|
|
18
|
+
const walked = walkCommand(argv, root);
|
|
19
|
+
if (walked.kind === 'unknown-path') return { kind: 'unknown-path', received: walked.received, expected: walked.expected, path: walked.resolved.path };
|
|
20
|
+
if (hasHelpFlag(walked.remaining)) return { kind: 'help', node: walked.node, text: assembleHelp(walked.node) };
|
|
21
|
+
return { kind: 'dispatch', node: walked.node, argv: walked.remaining };
|
|
22
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/** Pure exact command-path resolution for the descriptor registry. */
|
|
2
|
+
import { CAPTURE_REGISTRY, type RegistryBranch, type RegistryNode } from './registry.js';
|
|
3
|
+
|
|
4
|
+
export type WalkResult =
|
|
5
|
+
| { readonly kind: 'resolved'; readonly node: RegistryNode; readonly remaining: readonly string[] }
|
|
6
|
+
| { readonly kind: 'unknown-path'; readonly received: string; readonly resolved: RegistryBranch; readonly expected: readonly string[]; readonly remaining: readonly string[] };
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Resolves only exact canonical path tokens. It stops before the first option,
|
|
10
|
+
* resolves the deepest known leaf/branch, and leaves all leaf argv untouched.
|
|
11
|
+
* Parsing, output compatibility, effects, and handler dispatch are intentionally
|
|
12
|
+
* outside this pure walker.
|
|
13
|
+
*/
|
|
14
|
+
export function walkCommand(argv: readonly string[], root: RegistryBranch = CAPTURE_REGISTRY): WalkResult {
|
|
15
|
+
let current: RegistryNode = root;
|
|
16
|
+
let index = 0;
|
|
17
|
+
while (current.kind === 'branch' && index < argv.length) {
|
|
18
|
+
const token = argv[index];
|
|
19
|
+
if (token.startsWith('-')) break;
|
|
20
|
+
const child: RegistryNode | undefined = current.children.find((candidate: RegistryNode) => candidate.path.split(' ').at(-1) === token);
|
|
21
|
+
if (!child) return { kind: 'unknown-path', received: token, resolved: current, expected: current.children.filter((candidate) => candidate.visibility === 'public').map((candidate) => candidate.path.split(' ').at(-1)!), remaining: argv.slice(index) };
|
|
22
|
+
current = child;
|
|
23
|
+
index += 1;
|
|
24
|
+
}
|
|
25
|
+
return { kind: 'resolved', node: current, remaining: argv.slice(index) };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function hasHelpFlag(argv: readonly string[]): boolean {
|
|
29
|
+
return argv.includes('-h') || argv.includes('--help');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Root version is the sole accepted version spelling and wins before walking. */
|
|
33
|
+
export function hasRootVersionFlag(argv: readonly string[]): boolean {
|
|
34
|
+
return argv[0] === '--version';
|
|
35
|
+
}
|