@crouton-kit/capture 1.3.7 → 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 +32786 -9989
- package/package.json +9 -3
- package/src/capture.ts +86 -163
- package/src/cdp/a11y.ts +113 -14
- package/src/cdp/args.ts +300 -116
- package/src/cdp/bridge/protocol.ts +187 -1
- package/src/cdp/bridge/server.ts +211 -84
- package/src/cdp/bridge/spawn.ts +110 -1
- package/src/cdp/client.ts +13 -3
- package/src/cdp/commands/bridge-serve.ts +35 -4
- package/src/cdp/commands/cdp.ts +266 -69
- package/src/cdp/commands/gate-guard.ts +43 -0
- package/src/cdp/commands/lib.ts +339 -213
- package/src/cdp/commands/measure/census.ts +87 -0
- package/src/cdp/commands/measure/check.ts +109 -0
- package/src/cdp/commands/measure/diff.ts +120 -0
- package/src/cdp/commands/measure/explain.ts +133 -0
- package/src/cdp/commands/measure/index.ts +106 -0
- package/src/cdp/commands/measure/map-ax.ts +52 -0
- package/src/cdp/commands/measure/map-focus.ts +52 -0
- package/src/cdp/commands/measure/map-layers.ts +59 -0
- package/src/cdp/commands/measure/map-scroll.ts +94 -0
- package/src/cdp/commands/measure/snap.ts +416 -0
- package/src/cdp/commands/measure/sweep.ts +227 -0
- package/src/cdp/commands/motion/index.ts +61 -0
- package/src/cdp/commands/motion/jank.ts +132 -0
- package/src/cdp/commands/motion/mask.ts +92 -0
- package/src/cdp/commands/motion/rec.ts +534 -0
- package/src/cdp/commands/motion/response.ts +76 -0
- package/src/cdp/commands/motion/timeline.ts +152 -0
- 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/coordinates.ts +158 -0
- package/src/cdp/detect.ts +42 -13
- package/src/cdp/dispatch.ts +76 -26
- package/src/cdp/domains.ts +75 -0
- 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 +1894 -0
- 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/source-map.ts +309 -0
- package/src/cdp/targets.ts +21 -1
- package/src/cdp/timing.ts +87 -0
- package/src/cdp/types.ts +87 -6
- 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 +526 -0
- package/src/output/diff.ts +111 -0
- package/src/output/exact-raw.ts +114 -0
- package/src/output/render.ts +987 -0
- package/src/output/selector.ts +185 -0
- package/src/output/structured.ts +211 -0
- package/src/registry.ts +208 -0
- package/src/session/artifacts.ts +481 -0
- package/src/session/commands.ts +1237 -368
- 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 +302 -34
- 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 -77
- 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
|
}
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Source provenance helper: resolves a generated CSS/JS location (as reported by
|
|
3
|
+
* `CSS.getMatchedStylesForNode`'s stylesheet ranges, or any other generated-source location)
|
|
4
|
+
* to an authored file/line/column via the standard source-map-v3 `mappings` VLQ format, when
|
|
5
|
+
* the generated source carries a `sourceMappingURL`. Degrades to the generated source URL when
|
|
6
|
+
* no source map is present, fails to fetch, or fails to parse — this module never throws for
|
|
7
|
+
* "no source map available"; it throws only for a malformed CDP response.
|
|
8
|
+
*
|
|
9
|
+
* No `source-map` npm dependency is added (this unit does not own `package.json`); the VLQ
|
|
10
|
+
* decoder below is a self-contained implementation of the standard algorithm.
|
|
11
|
+
*
|
|
12
|
+
* Selector/specificity fields that round out full provenance (used by `styles.json`) are the
|
|
13
|
+
* caller's (U08 styles collector's) responsibility — this module resolves source *location*
|
|
14
|
+
* only.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { CDPClient } from './client.js';
|
|
18
|
+
|
|
19
|
+
/** Where a generated location came from and which position in it to resolve. */
|
|
20
|
+
export interface GeneratedSourceRef {
|
|
21
|
+
/** A CDP stylesheet id — when present, the generated text is read via `CSS.getStyleSheetText`. */
|
|
22
|
+
styleSheetId?: string;
|
|
23
|
+
/**
|
|
24
|
+
* Absolute URL of the generated source. Used as the fetch target when `styleSheetId` is
|
|
25
|
+
* absent, as the base URL for resolving a relative `sourceMappingURL`, and as the reported
|
|
26
|
+
* generated-source URL either way.
|
|
27
|
+
*/
|
|
28
|
+
sourceURL?: string;
|
|
29
|
+
/** 0-indexed line in the generated source. */
|
|
30
|
+
line: number;
|
|
31
|
+
/** 0-indexed column in the generated source. */
|
|
32
|
+
column: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface GeneratedLocation {
|
|
36
|
+
kind: 'generated';
|
|
37
|
+
sourceURL: string;
|
|
38
|
+
line: number;
|
|
39
|
+
column: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface AuthoredLocation {
|
|
43
|
+
kind: 'authored';
|
|
44
|
+
generated: { sourceURL: string; line: number; column: number };
|
|
45
|
+
/** Authored source file path/URL as recorded in the source map's `sources` (joined with `sourceRoot` when present). */
|
|
46
|
+
file: string;
|
|
47
|
+
/** 1-indexed authored line. */
|
|
48
|
+
line: number;
|
|
49
|
+
/** 0-indexed authored column. */
|
|
50
|
+
column: number;
|
|
51
|
+
/** Inlined authored source text, when the source map carries `sourcesContent` for this source. */
|
|
52
|
+
sourceContent?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type ResolvedSourceLocation = AuthoredLocation | GeneratedLocation;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Resolves `ref` to an authored file/line/column when a `sourceMappingURL` is present and
|
|
59
|
+
* resolvable, otherwise returns the generated source location unchanged.
|
|
60
|
+
*/
|
|
61
|
+
export async function resolveAuthoredSourceLocation(
|
|
62
|
+
client: CDPClient,
|
|
63
|
+
ref: GeneratedSourceRef,
|
|
64
|
+
): Promise<ResolvedSourceLocation> {
|
|
65
|
+
const { text, sourceURL } = await fetchGeneratedText(client, ref);
|
|
66
|
+
const generated: GeneratedLocation = { kind: 'generated', sourceURL, line: ref.line, column: ref.column };
|
|
67
|
+
|
|
68
|
+
if (!text) return generated;
|
|
69
|
+
|
|
70
|
+
const mappingURL = extractSourceMappingURL(text);
|
|
71
|
+
if (!mappingURL) return generated;
|
|
72
|
+
|
|
73
|
+
const rawMap = await loadSourceMap(mappingURL, sourceURL);
|
|
74
|
+
if (!rawMap) return generated;
|
|
75
|
+
|
|
76
|
+
const authored = mapGeneratedPosition(rawMap, ref.line, ref.column);
|
|
77
|
+
if (!authored) return generated;
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
kind: 'authored',
|
|
81
|
+
generated: { sourceURL, line: ref.line, column: ref.column },
|
|
82
|
+
file: authored.file,
|
|
83
|
+
line: authored.line,
|
|
84
|
+
column: authored.column,
|
|
85
|
+
sourceContent: authored.sourceContent,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function fetchGeneratedText(
|
|
90
|
+
client: CDPClient,
|
|
91
|
+
ref: GeneratedSourceRef,
|
|
92
|
+
): Promise<{ text: string | null; sourceURL: string }> {
|
|
93
|
+
if (ref.styleSheetId) {
|
|
94
|
+
try {
|
|
95
|
+
const result = (await client.send('CSS.getStyleSheetText', {
|
|
96
|
+
styleSheetId: ref.styleSheetId,
|
|
97
|
+
})) as { text: string };
|
|
98
|
+
return { text: result.text, sourceURL: ref.sourceURL ?? ref.styleSheetId };
|
|
99
|
+
} catch {
|
|
100
|
+
// Stylesheet text unavailable (e.g. a stale styleSheetId) — fall through to a direct fetch.
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (ref.sourceURL) {
|
|
105
|
+
try {
|
|
106
|
+
const text = await fetchText(ref.sourceURL);
|
|
107
|
+
return { text, sourceURL: ref.sourceURL };
|
|
108
|
+
} catch {
|
|
109
|
+
return { text: null, sourceURL: ref.sourceURL };
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return { text: null, sourceURL: '' };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const SOURCE_MAPPING_URL_RE = /\/\*[#@]\s*sourceMappingURL=([^\s*]+)\s*\*\/|\/\/[#@]\s*sourceMappingURL=([^\s]+)/g;
|
|
117
|
+
|
|
118
|
+
/** Finds the `sourceMappingURL` comment in generated CSS/JS text. When more than one is present, the last wins (spec convention). */
|
|
119
|
+
export function extractSourceMappingURL(text: string): string | null {
|
|
120
|
+
SOURCE_MAPPING_URL_RE.lastIndex = 0;
|
|
121
|
+
let match: RegExpExecArray | null;
|
|
122
|
+
let last: string | null = null;
|
|
123
|
+
while ((match = SOURCE_MAPPING_URL_RE.exec(text)) !== null) {
|
|
124
|
+
last = match[1] ?? match[2] ?? last;
|
|
125
|
+
}
|
|
126
|
+
return last;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export interface RawSourceMap {
|
|
130
|
+
version: number;
|
|
131
|
+
sources: string[];
|
|
132
|
+
sourcesContent?: (string | null)[];
|
|
133
|
+
names?: string[];
|
|
134
|
+
mappings: string;
|
|
135
|
+
sourceRoot?: string;
|
|
136
|
+
file?: string;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function loadSourceMap(mappingURL: string, baseURL: string): Promise<RawSourceMap | null> {
|
|
140
|
+
if (mappingURL.startsWith('data:')) {
|
|
141
|
+
return parseDataURISourceMap(mappingURL);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
const resolved = baseURL ? new URL(mappingURL, baseURL).toString() : mappingURL;
|
|
146
|
+
const text = await fetchText(resolved);
|
|
147
|
+
return JSON.parse(text) as RawSourceMap;
|
|
148
|
+
} catch {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function parseDataURISourceMap(dataURI: string): RawSourceMap | null {
|
|
154
|
+
const commaIndex = dataURI.indexOf(',');
|
|
155
|
+
if (commaIndex === -1) return null;
|
|
156
|
+
const meta = dataURI.slice('data:'.length, commaIndex);
|
|
157
|
+
const payload = dataURI.slice(commaIndex + 1);
|
|
158
|
+
try {
|
|
159
|
+
const json = meta.includes(';base64') ? Buffer.from(payload, 'base64').toString('utf8') : decodeURIComponent(payload);
|
|
160
|
+
return JSON.parse(json) as RawSourceMap;
|
|
161
|
+
} catch {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function fetchText(url: string): Promise<string> {
|
|
167
|
+
const response = await fetch(url);
|
|
168
|
+
if (!response.ok) {
|
|
169
|
+
throw new Error(`Fetching ${url} returned HTTP ${response.status}`);
|
|
170
|
+
}
|
|
171
|
+
return response.text();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export interface AuthoredPosition {
|
|
175
|
+
file: string;
|
|
176
|
+
/** 1-indexed authored line. */
|
|
177
|
+
line: number;
|
|
178
|
+
/** 0-indexed authored column. */
|
|
179
|
+
column: number;
|
|
180
|
+
sourceContent?: string;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Maps a 0-indexed (generatedLine, generatedColumn) position through a parsed source-map-v3
|
|
185
|
+
* `mappings` string to the nearest preceding authored position on that generated line. Pure —
|
|
186
|
+
* no I/O, no CDP — so it is directly testable against a fixture map.
|
|
187
|
+
*/
|
|
188
|
+
export function mapGeneratedPosition(
|
|
189
|
+
rawMap: RawSourceMap,
|
|
190
|
+
generatedLine: number,
|
|
191
|
+
generatedColumn: number,
|
|
192
|
+
): AuthoredPosition | null {
|
|
193
|
+
const entries = decodeMappings(rawMap.mappings).filter(
|
|
194
|
+
(entry) => entry.generatedLine === generatedLine && entry.sourceIndex !== undefined,
|
|
195
|
+
);
|
|
196
|
+
if (entries.length === 0) return null;
|
|
197
|
+
|
|
198
|
+
let best: MappingEntry | undefined;
|
|
199
|
+
for (const entry of entries) {
|
|
200
|
+
if (entry.generatedColumn <= generatedColumn && (!best || entry.generatedColumn > best.generatedColumn)) {
|
|
201
|
+
best = entry;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
best = best ?? entries[0];
|
|
205
|
+
|
|
206
|
+
const sourceIndex = best.sourceIndex as number;
|
|
207
|
+
const sourcePath = rawMap.sources[sourceIndex];
|
|
208
|
+
if (sourcePath === undefined) return null;
|
|
209
|
+
|
|
210
|
+
const file = rawMap.sourceRoot ? joinSourceRoot(rawMap.sourceRoot, sourcePath) : sourcePath;
|
|
211
|
+
const sourceContent = rawMap.sourcesContent?.[sourceIndex] ?? undefined;
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
file,
|
|
215
|
+
line: (best.sourceLine ?? 0) + 1,
|
|
216
|
+
column: best.sourceColumn ?? 0,
|
|
217
|
+
sourceContent,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function joinSourceRoot(root: string, source: string): string {
|
|
222
|
+
if (source.startsWith('/') || /^[a-z]+:\/\//i.test(source)) return source;
|
|
223
|
+
return root.endsWith('/') ? `${root}${source}` : `${root}/${source}`;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
interface MappingEntry {
|
|
227
|
+
generatedLine: number;
|
|
228
|
+
generatedColumn: number;
|
|
229
|
+
sourceIndex?: number;
|
|
230
|
+
sourceLine?: number;
|
|
231
|
+
sourceColumn?: number;
|
|
232
|
+
nameIndex?: number;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const BASE64_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
|
236
|
+
const BASE64_LOOKUP = new Map<string, number>(Array.from(BASE64_CHARS).map((char, index) => [char, index]));
|
|
237
|
+
|
|
238
|
+
const VLQ_BASE_SHIFT = 5;
|
|
239
|
+
const VLQ_BASE = 1 << VLQ_BASE_SHIFT;
|
|
240
|
+
const VLQ_BASE_MASK = VLQ_BASE - 1;
|
|
241
|
+
const VLQ_CONTINUATION_BIT = VLQ_BASE;
|
|
242
|
+
|
|
243
|
+
/** Decodes every VLQ-encoded number concatenated in one comma-delimited mapping segment. */
|
|
244
|
+
function decodeVLQSegment(segment: string): number[] {
|
|
245
|
+
const values: number[] = [];
|
|
246
|
+
let shift = 0;
|
|
247
|
+
let result = 0;
|
|
248
|
+
|
|
249
|
+
for (const char of segment) {
|
|
250
|
+
const digit = BASE64_LOOKUP.get(char);
|
|
251
|
+
if (digit === undefined) {
|
|
252
|
+
throw new Error(`Invalid base64 VLQ character in source map mappings: ${JSON.stringify(char)}`);
|
|
253
|
+
}
|
|
254
|
+
const continuation = digit & VLQ_CONTINUATION_BIT;
|
|
255
|
+
const bits = digit & VLQ_BASE_MASK;
|
|
256
|
+
result += bits << shift;
|
|
257
|
+
if (continuation) {
|
|
258
|
+
shift += VLQ_BASE_SHIFT;
|
|
259
|
+
} else {
|
|
260
|
+
const negate = result & 1;
|
|
261
|
+
result >>= 1;
|
|
262
|
+
values.push(negate ? -result : result);
|
|
263
|
+
result = 0;
|
|
264
|
+
shift = 0;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return values;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Decodes a full source-map-v3 `mappings` string into per-generated-position entries with sources/names deltas resolved. */
|
|
272
|
+
function decodeMappings(mappings: string): MappingEntry[] {
|
|
273
|
+
const entries: MappingEntry[] = [];
|
|
274
|
+
const lines = mappings.split(';');
|
|
275
|
+
|
|
276
|
+
let sourceIndex = 0;
|
|
277
|
+
let sourceLine = 0;
|
|
278
|
+
let sourceColumn = 0;
|
|
279
|
+
let nameIndex = 0;
|
|
280
|
+
|
|
281
|
+
for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
|
|
282
|
+
const lineText = lines[lineIndex];
|
|
283
|
+
if (lineText === '') continue;
|
|
284
|
+
|
|
285
|
+
let generatedColumn = 0;
|
|
286
|
+
for (const segment of lineText.split(',')) {
|
|
287
|
+
if (segment === '') continue;
|
|
288
|
+
const fields = decodeVLQSegment(segment);
|
|
289
|
+
generatedColumn += fields[0];
|
|
290
|
+
|
|
291
|
+
const entry: MappingEntry = { generatedLine: lineIndex, generatedColumn };
|
|
292
|
+
if (fields.length > 1) {
|
|
293
|
+
sourceIndex += fields[1];
|
|
294
|
+
sourceLine += fields[2];
|
|
295
|
+
sourceColumn += fields[3];
|
|
296
|
+
entry.sourceIndex = sourceIndex;
|
|
297
|
+
entry.sourceLine = sourceLine;
|
|
298
|
+
entry.sourceColumn = sourceColumn;
|
|
299
|
+
if (fields.length > 4) {
|
|
300
|
+
nameIndex += fields[4];
|
|
301
|
+
entry.nameIndex = nameIndex;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
entries.push(entry);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return entries;
|
|
309
|
+
}
|
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
|
|