@crouton-kit/capture 1.3.17 → 1.3.19
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 +4184 -3258
- package/package.json +1 -1
- package/src/capture.ts +4 -1
- package/src/cdp/args.ts +14 -2
- package/src/cdp/browser-process.ts +510 -0
- package/src/cdp/client.ts +10 -1
- package/src/cdp/commands/cdp.ts +5 -5
- package/src/cdp/commands/measure/snap.ts +37 -2
- package/src/cdp/commands/motion/mask.ts +8 -2
- package/src/cdp/commands/page/click.ts +14 -0
- package/src/cdp/commands/page/elements.ts +2 -2
- package/src/cdp/commands/page/shot.ts +26 -5
- package/src/cdp/commands/tab/index.ts +19 -8
- package/src/cdp/commands/tab/launch.ts +73 -0
- package/src/cdp/commands/tab/list.ts +14 -2
- package/src/cdp/commands/tab/quit.ts +78 -0
- package/src/cdp/har-recorder.ts +14 -20
- package/src/cdp/measure/collectors/ax.ts +3 -4
- package/src/cdp/measure/collectors/forms.ts +4 -6
- package/src/cdp/measure/collectors/media.ts +1 -2
- package/src/cdp/measure/collectors/states.ts +1 -6
- package/src/cdp/measure/collectors/style-provenance.ts +4 -16
- package/src/cdp/measure/collectors/styles.ts +21 -10
- package/src/cdp/measure/collectors/text.ts +3 -5
- package/src/cdp/measure/snapshot.ts +195 -29
- package/src/cdp/measure/types.ts +32 -2
- package/src/cdp/motion/jank.ts +8 -1
- package/src/cdp/record.ts +37 -5
- package/src/cdp/screenshot.ts +83 -8
- package/src/cdp/targets.ts +5 -0
- package/src/cdp/types.ts +4 -0
- package/src/har-manager.ts +16 -15
- package/src/interact.ts +20 -4
- package/src/output/render.ts +2 -0
- package/src/registry.ts +1 -1
- package/src/session/commands.ts +287 -70
- package/src/session/har-redact.ts +125 -0
package/src/cdp/record.ts
CHANGED
|
@@ -11,6 +11,42 @@ import { type CDPTarget } from './types.js';
|
|
|
11
11
|
* load-wait helper. Navigation of an existing tab lives in
|
|
12
12
|
* `./commands/page/navigate.ts` via ordinary page targeting.
|
|
13
13
|
*/
|
|
14
|
+
function isMissingDefaultExecutionContext(err: unknown): boolean {
|
|
15
|
+
return err instanceof Error && err.message === 'Cannot find default execution context';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Arc can report the page load before it has installed the default execution
|
|
20
|
+
* context (notably for hash-routed Gmail URLs). Retry only that known transient
|
|
21
|
+
* CDP error; every other Runtime.evaluate failure remains fatal.
|
|
22
|
+
*/
|
|
23
|
+
export async function readReadyStateWithRetry(
|
|
24
|
+
client: Pick<CDPClient, 'send'>,
|
|
25
|
+
timeout: number,
|
|
26
|
+
): Promise<unknown> {
|
|
27
|
+
const deadline = Date.now() + timeout;
|
|
28
|
+
let lastRetryableError: unknown;
|
|
29
|
+
while (true) {
|
|
30
|
+
const remaining = deadline - Date.now();
|
|
31
|
+
if (remaining <= 0) {
|
|
32
|
+
if (lastRetryableError) throw lastRetryableError;
|
|
33
|
+
throw new Error('Runtime.evaluate deadline exceeded');
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
const result = await client.send('Runtime.evaluate', {
|
|
37
|
+
expression: 'document.readyState',
|
|
38
|
+
returnByValue: true,
|
|
39
|
+
}, remaining);
|
|
40
|
+
return (result as { result?: { value?: unknown } } | undefined)?.result?.value;
|
|
41
|
+
} catch (err) {
|
|
42
|
+
const remainingAfterAttempt = deadline - Date.now();
|
|
43
|
+
if (!isMissingDefaultExecutionContext(err) || remainingAfterAttempt <= 0) throw err;
|
|
44
|
+
lastRetryableError = err;
|
|
45
|
+
await new Promise<void>((resolve) => setTimeout(resolve, Math.min(50, remainingAfterAttempt)));
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
14
50
|
export async function navigateAndWait(
|
|
15
51
|
port: number,
|
|
16
52
|
url: string,
|
|
@@ -59,11 +95,7 @@ export async function navigateAndWait(
|
|
|
59
95
|
});
|
|
60
96
|
|
|
61
97
|
try {
|
|
62
|
-
const
|
|
63
|
-
expression: 'document.readyState',
|
|
64
|
-
returnByValue: true,
|
|
65
|
-
});
|
|
66
|
-
const readyState = (result as { result?: { value?: unknown } } | undefined)?.result?.value;
|
|
98
|
+
const readyState = await readReadyStateWithRetry(client, timeout);
|
|
67
99
|
if (readyState !== 'complete') {
|
|
68
100
|
await loadPromise;
|
|
69
101
|
if (loadTimeoutError) throw loadTimeoutError;
|
package/src/cdp/screenshot.ts
CHANGED
|
@@ -4,6 +4,25 @@ import { nextStepPath } from '../session-context.js';
|
|
|
4
4
|
import { writeBinaryPrivate } from '../session/artifacts.js';
|
|
5
5
|
import { withScopeSerialization } from './scope-lock.js';
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* The CSS-pixel rectangle a captured PNG covers: its page-coordinate origin and
|
|
9
|
+
* its size. `image px / width` (and `/ height`) is the capture's true CSS-to-
|
|
10
|
+
* image scale, and `x`/`y` turn a page coordinate into an image coordinate.
|
|
11
|
+
*/
|
|
12
|
+
export interface CapturedRegion {
|
|
13
|
+
readonly x: number;
|
|
14
|
+
readonly y: number;
|
|
15
|
+
readonly width: number;
|
|
16
|
+
readonly height: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** A region is reportable only if every number is finite and its size is positive; anything else would produce a fabricated scale. */
|
|
20
|
+
function validRegion(region: CapturedRegion): CapturedRegion | undefined {
|
|
21
|
+
const finite = [region.x, region.y, region.width, region.height].every((n) => typeof n === 'number' && Number.isFinite(n));
|
|
22
|
+
if (!finite || region.width <= 0 || region.height <= 0) return undefined;
|
|
23
|
+
return region;
|
|
24
|
+
}
|
|
25
|
+
|
|
7
26
|
/**
|
|
8
27
|
* Downscales a PNG so its longest side fits within maxDim, using a box
|
|
9
28
|
* filter. Used when a clipped/scaled CDP capture is unavailable and the raw
|
|
@@ -65,9 +84,27 @@ export async function captureScreenshot(
|
|
|
65
84
|
viewport?: { width: number; height: number },
|
|
66
85
|
options?: { fullPage?: boolean },
|
|
67
86
|
): Promise<Buffer> {
|
|
68
|
-
|
|
87
|
+
const result = await withScopeSerialization(client, 'viewport', 'screenshot capture', () =>
|
|
69
88
|
viewportScopedCapture(client, viewport, options),
|
|
70
89
|
);
|
|
90
|
+
return result.png;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Captures a PNG and the CSS-pixel region the image actually covers — the clip
|
|
95
|
+
* sent to `Page.captureScreenshot` (or, for an unclipped capture, the visual
|
|
96
|
+
* viewport). Dividing the PNG's pixel dimensions by that region's size is the
|
|
97
|
+
* true CSS-to-image scale; the layout viewport (`window.innerWidth`) is a
|
|
98
|
+
* different coordinate space under page zoom and must not stand in for it.
|
|
99
|
+
*/
|
|
100
|
+
export async function captureScreenshotWithCssViewport(
|
|
101
|
+
client: CDPClient,
|
|
102
|
+
viewport?: { width: number; height: number },
|
|
103
|
+
options?: { fullPage?: boolean },
|
|
104
|
+
): Promise<{ png: Buffer; cssViewport?: CapturedRegion }> {
|
|
105
|
+
return withScopeSerialization(client, 'viewport', 'screenshot capture', () =>
|
|
106
|
+
viewportScopedCapture(client, viewport, options, true),
|
|
107
|
+
);
|
|
71
108
|
}
|
|
72
109
|
|
|
73
110
|
/**
|
|
@@ -81,7 +118,8 @@ async function viewportScopedCapture(
|
|
|
81
118
|
client: CDPClient,
|
|
82
119
|
viewport?: { width: number; height: number },
|
|
83
120
|
options?: { fullPage?: boolean },
|
|
84
|
-
|
|
121
|
+
includeCssViewport = false,
|
|
122
|
+
): Promise<{ png: Buffer; cssViewport?: CapturedRegion }> {
|
|
85
123
|
const MAX_DIM = 1600; // headroom below Anthropic's 2000px many-image limit
|
|
86
124
|
let ownsDeviceMetricsOverride = false;
|
|
87
125
|
let primaryFailed = false;
|
|
@@ -102,7 +140,7 @@ async function viewportScopedCapture(
|
|
|
102
140
|
}
|
|
103
141
|
|
|
104
142
|
if (options?.fullPage) {
|
|
105
|
-
const layoutMetrics = (await client.send('Page.getLayoutMetrics')) as {
|
|
143
|
+
const layoutMetrics = (await client.send('Page.getLayoutMetrics', {}, 5000)) as {
|
|
106
144
|
contentSize?: { width: number; height: number };
|
|
107
145
|
cssVisualViewport?: { clientWidth: number };
|
|
108
146
|
};
|
|
@@ -123,8 +161,13 @@ async function viewportScopedCapture(
|
|
|
123
161
|
captureBeyondViewport: false,
|
|
124
162
|
};
|
|
125
163
|
|
|
164
|
+
// The CSS-pixel region the returned image covers, set from the clip that is
|
|
165
|
+
// actually sent. An unclipped capture clears it again and it is read from the
|
|
166
|
+
// live visual viewport below instead.
|
|
167
|
+
let capturedRegion: CapturedRegion | undefined;
|
|
168
|
+
|
|
126
169
|
try {
|
|
127
|
-
const metrics = (await client.send('Page.getLayoutMetrics')) as {
|
|
170
|
+
const metrics = (await client.send('Page.getLayoutMetrics', {}, 5000)) as {
|
|
128
171
|
cssVisualViewport?: { clientWidth: number; clientHeight: number; pageX: number; pageY: number };
|
|
129
172
|
};
|
|
130
173
|
// Snap the clip to integer CSS pixels: browser zoom (e.g. 110%) makes the
|
|
@@ -139,7 +182,7 @@ async function viewportScopedCapture(
|
|
|
139
182
|
const dprResult = (await client.send('Runtime.evaluate', {
|
|
140
183
|
expression: 'window.devicePixelRatio',
|
|
141
184
|
returnByValue: true,
|
|
142
|
-
})) as { result: { value: number } };
|
|
185
|
+
}, 5000)) as { result: { value: number } };
|
|
143
186
|
const dpr = dprResult.result.value ?? 1;
|
|
144
187
|
|
|
145
188
|
const actualMaxSide = Math.max(vw, vh) * dpr;
|
|
@@ -148,6 +191,9 @@ async function viewportScopedCapture(
|
|
|
148
191
|
...screenshotOpts,
|
|
149
192
|
clip: { x: sx, y: sy, width: vw, height: vh, scale },
|
|
150
193
|
};
|
|
194
|
+
// The clip is what the image is cut from, rounding included — not the
|
|
195
|
+
// fractional viewport it was derived from.
|
|
196
|
+
capturedRegion = validRegion({ x: sx, y: sy, width: vw, height: vh });
|
|
151
197
|
} catch {
|
|
152
198
|
// Capture without downscaling when the optional metrics probe is unavailable.
|
|
153
199
|
}
|
|
@@ -155,6 +201,7 @@ async function viewportScopedCapture(
|
|
|
155
201
|
const result = (await client.send(
|
|
156
202
|
'Page.captureScreenshot',
|
|
157
203
|
screenshotOpts,
|
|
204
|
+
15000,
|
|
158
205
|
)) as { data?: string };
|
|
159
206
|
let png = Buffer.from(result.data ?? '', 'base64');
|
|
160
207
|
|
|
@@ -167,7 +214,7 @@ async function viewportScopedCapture(
|
|
|
167
214
|
const retry = (await client.send('Page.captureScreenshot', {
|
|
168
215
|
...screenshotOpts,
|
|
169
216
|
clip: { ...clip, scale: 1 },
|
|
170
|
-
})) as { data?: string };
|
|
217
|
+
}, 15000)) as { data?: string };
|
|
171
218
|
png = Buffer.from(retry.data ?? '', 'base64');
|
|
172
219
|
if (png.length > 0) png = downscalePngToFit(png, MAX_DIM);
|
|
173
220
|
}
|
|
@@ -178,9 +225,37 @@ async function viewportScopedCapture(
|
|
|
178
225
|
const retry = (await client.send('Page.captureScreenshot', {
|
|
179
226
|
format: 'png',
|
|
180
227
|
captureBeyondViewport: false,
|
|
181
|
-
})) as { data?: string };
|
|
228
|
+
}, 15000)) as { data?: string };
|
|
182
229
|
png = Buffer.from(retry.data ?? '', 'base64');
|
|
183
230
|
if (png.length > 0) png = downscalePngToFit(png, MAX_DIM);
|
|
231
|
+
// An unclipped capture covers the visual viewport itself, not the integer
|
|
232
|
+
// clip that was refused — and the two failed attempts above can have taken
|
|
233
|
+
// 30 seconds, so the viewport sampled before them is no longer evidence.
|
|
234
|
+
if (png.length > 0) capturedRegion = undefined;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// The image was captured unclipped (no clip was ever sent, or the clipped
|
|
238
|
+
// attempts were refused), so it covers the visual viewport. Read it as it
|
|
239
|
+
// stands now rather than reporting the layout viewport, which is a different
|
|
240
|
+
// space under page zoom, or a sample taken before a failed attempt.
|
|
241
|
+
if (includeCssViewport && !capturedRegion) {
|
|
242
|
+
try {
|
|
243
|
+
const response = (await client.send('Runtime.evaluate', {
|
|
244
|
+
expression: '(() => { const v = window.visualViewport; return v ? { x: v.pageLeft, y: v.pageTop, width: v.width, height: v.height } : null; })()',
|
|
245
|
+
returnByValue: true,
|
|
246
|
+
}, 5000)) as { result?: { value?: Record<string, unknown> | null } };
|
|
247
|
+
const value = response.result?.value;
|
|
248
|
+
if (value) {
|
|
249
|
+
capturedRegion = validRegion({
|
|
250
|
+
x: Number(value.x),
|
|
251
|
+
y: Number(value.y),
|
|
252
|
+
width: Number(value.width),
|
|
253
|
+
height: Number(value.height),
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
} catch {
|
|
257
|
+
// The image remains usable; only its CSS-coordinate mapping is unavailable.
|
|
258
|
+
}
|
|
184
259
|
}
|
|
185
260
|
|
|
186
261
|
if (png.length === 0) {
|
|
@@ -189,7 +264,7 @@ async function viewportScopedCapture(
|
|
|
189
264
|
);
|
|
190
265
|
}
|
|
191
266
|
|
|
192
|
-
return png;
|
|
267
|
+
return { png, ...(includeCssViewport && capturedRegion ? { cssViewport: capturedRegion } : {}) };
|
|
193
268
|
} catch (error) {
|
|
194
269
|
primaryFailed = true;
|
|
195
270
|
primaryError = error;
|
package/src/cdp/targets.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { CDPClient } from './client.js';
|
|
2
2
|
import { detectCdpPortsAsync } from './detect.js';
|
|
3
|
+
import { touchOwnedBrowser } from './browser-process.js';
|
|
3
4
|
import { type CDPTarget } from './types.js';
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -30,6 +31,10 @@ export async function getBrowserClient(
|
|
|
30
31
|
}
|
|
31
32
|
const client = new CDPClient(version.webSocketDebuggerUrl);
|
|
32
33
|
await client.waitReady();
|
|
34
|
+
// Every browser-level connection funnels through here, which makes this the
|
|
35
|
+
// one place that can tell capture's idle sweep the browser is still in use.
|
|
36
|
+
// A port capture did not launch has no registry record and this is a no-op.
|
|
37
|
+
touchOwnedBrowser(port);
|
|
33
38
|
return { client, browserWsUrl: version.webSocketDebuggerUrl };
|
|
34
39
|
}
|
|
35
40
|
|
package/src/cdp/types.ts
CHANGED
|
@@ -25,6 +25,8 @@ export interface ParsedArgs {
|
|
|
25
25
|
* can auto-append recorded traffic to the session HAR. */
|
|
26
26
|
har?: string;
|
|
27
27
|
new?: boolean;
|
|
28
|
+
/** `tab launch --headed` — a visible window instead of headless. */
|
|
29
|
+
headed?: boolean;
|
|
28
30
|
target?: string;
|
|
29
31
|
/** Where `target` was assigned from: an actual `--target` flag, active-session
|
|
30
32
|
* autofill, or the ambient `CDP_TARGET` env var. Recorded at the assignment
|
|
@@ -68,6 +70,8 @@ export interface ParsedArgs {
|
|
|
68
70
|
settleTimeout?: number;
|
|
69
71
|
/** `snap --capture-unsettled` — write full substrate despite non-settlement. */
|
|
70
72
|
captureUnsettled?: boolean;
|
|
73
|
+
/** `snap --skip-collector <name>` — repeatable; one entry per occurrence. */
|
|
74
|
+
skipCollector?: string[];
|
|
71
75
|
/** `snap|diff|mask --pixels` — include raster crop/diff facts. */
|
|
72
76
|
pixels?: boolean;
|
|
73
77
|
/** `snap --state <state[:selector]>` — repeatable; one entry per occurrence. */
|
package/src/har-manager.ts
CHANGED
|
@@ -552,8 +552,8 @@ function vEntry(v: unknown, source: string, path: string): HAREntry {
|
|
|
552
552
|
|
|
553
553
|
// Response-state ⇄ status coupling.
|
|
554
554
|
if (capResponse.state === 'received') {
|
|
555
|
-
if (status < 100 || status > 599) {
|
|
556
|
-
fail(source, `${path}.response.status`, 'a received response requires status 100..599');
|
|
555
|
+
if ((status < 100 || status > 599) && !(terminal.kind === 'redirect' && status === 0)) {
|
|
556
|
+
fail(source, `${path}.response.status`, 'a received response requires status 100..599 (or 0 for a redirect with no HTTP response)');
|
|
557
557
|
}
|
|
558
558
|
} else {
|
|
559
559
|
// unavailable
|
|
@@ -566,8 +566,8 @@ function vEntry(v: unknown, source: string, path: string): HAREntry {
|
|
|
566
566
|
fail(source, `${path}._capture.clocks.responseMonotonic`, 'must be null when no response was received');
|
|
567
567
|
}
|
|
568
568
|
}
|
|
569
|
-
if (status === 0 && capResponse.state !== 'unavailable') {
|
|
570
|
-
fail(source, `${path}.response.status`, 'status 0 is permitted only
|
|
569
|
+
if (status === 0 && capResponse.state !== 'unavailable' && terminal.kind !== 'redirect') {
|
|
570
|
+
fail(source, `${path}.response.status`, 'status 0 is permitted only for an unavailable response or a redirect with no HTTP response');
|
|
571
571
|
}
|
|
572
572
|
|
|
573
573
|
// Terminal ⇄ response/status/body coupling.
|
|
@@ -582,8 +582,8 @@ function vEntry(v: unknown, source: string, path: string): HAREntry {
|
|
|
582
582
|
if (capResponse.state !== 'received') {
|
|
583
583
|
fail(source, `${path}._capture.response.state`, 'a redirect requires a received response');
|
|
584
584
|
}
|
|
585
|
-
if (status < 300 || status > 399) {
|
|
586
|
-
fail(source, `${path}.response.status`, 'a redirect requires status 300..399');
|
|
585
|
+
if (status !== 0 && (status < 300 || status > 399)) {
|
|
586
|
+
fail(source, `${path}.response.status`, 'a redirect requires status 0 (no HTTP response) or 300..399');
|
|
587
587
|
}
|
|
588
588
|
if (body.state !== 'not_applicable' || body.reason !== 'redirect') {
|
|
589
589
|
fail(source, `${path}._capture.body`, 'a redirect requires body not_applicable/redirect');
|
|
@@ -666,17 +666,18 @@ function vIncompleteResponse(
|
|
|
666
666
|
path: string,
|
|
667
667
|
minResponseMonotonic: number,
|
|
668
668
|
allowUnknownClock = false,
|
|
669
|
+
allowNoHttpStatus = false,
|
|
669
670
|
): IncompleteResponse {
|
|
670
671
|
const o = vObject(v, source, path);
|
|
671
672
|
exactKeys(o, ['status', 'headers', 'responseMonotonic'], source, path);
|
|
672
673
|
const responseMonotonic = o.responseMonotonic === null
|
|
673
674
|
? allowUnknownClock ? null : fail(source, `${path}.responseMonotonic`, 'must be a finite number')
|
|
674
675
|
: vFiniteMin(o.responseMonotonic, minResponseMonotonic, source, `${path}.responseMonotonic`);
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
};
|
|
676
|
+
const status = vIntInRange(o.status, 0, 599, source, `${path}.status`);
|
|
677
|
+
if ((status < 100 && status !== 0) || (status === 0 && (!allowNoHttpStatus || responseMonotonic !== null))) {
|
|
678
|
+
fail(source, `${path}.status`, 'must be 100..599 (or 0 for a redirect with no HTTP response)');
|
|
679
|
+
}
|
|
680
|
+
return { status, headers: vHeaders(o.headers, source, `${path}.headers`), responseMonotonic };
|
|
680
681
|
}
|
|
681
682
|
|
|
682
683
|
function vStoppedBeforeTerminal(
|
|
@@ -754,9 +755,9 @@ function vInvalidClockOrder(
|
|
|
754
755
|
const generation = vSafeIntMin(o.generation, 1, source, `${path}.generation`);
|
|
755
756
|
const startedDateTime = vString(o.startedDateTime, source, `${path}.startedDateTime`);
|
|
756
757
|
const request = vRequest(o.request, source, `${path}.request`);
|
|
757
|
-
const response =
|
|
758
|
-
o.response === null ? null : vIncompleteResponse(o.response, source, `${path}.response`, 0, true);
|
|
759
758
|
const terminal = vInvalidClockTerminal(o.terminal, source, `${path}.terminal`);
|
|
759
|
+
const response =
|
|
760
|
+
o.response === null ? null : vIncompleteResponse(o.response, source, `${path}.response`, 0, true, terminal.kind === 'redirect');
|
|
760
761
|
|
|
761
762
|
const cap = vObject(o._capture, source, `${path}._capture`);
|
|
762
763
|
exactKeys(cap, ['schemaVersion', 'requestWallTime', 'requestMonotonic'], source, `${path}._capture`);
|
|
@@ -775,8 +776,8 @@ function vInvalidClockOrder(
|
|
|
775
776
|
// CDP redirectResponse preserves redirect facts but has no independently observed response clock.
|
|
776
777
|
if (terminal.kind === 'redirect') {
|
|
777
778
|
if (response === null) fail(source, `${path}.response`, 'a redirect terminal requires an observed response');
|
|
778
|
-
if (response.status < 300 || response.status > 399) {
|
|
779
|
-
fail(source, `${path}.response.status`, 'a redirect terminal requires status 300..399');
|
|
779
|
+
if (response.status !== 0 && (response.status < 300 || response.status > 399)) {
|
|
780
|
+
fail(source, `${path}.response.status`, 'a redirect terminal requires status 0 (no HTTP response) or 300..399');
|
|
780
781
|
}
|
|
781
782
|
if (response.responseMonotonic !== null) {
|
|
782
783
|
fail(source, `${path}.response.responseMonotonic`, 'must be null for a redirect terminal');
|
package/src/interact.ts
CHANGED
|
@@ -89,6 +89,11 @@ export type ResolutionFailure =
|
|
|
89
89
|
readonly input: string;
|
|
90
90
|
readonly acceptedPrefixes: typeof ACCEPTED_LIVE_PREFIXES;
|
|
91
91
|
}
|
|
92
|
+
| {
|
|
93
|
+
readonly ok: false;
|
|
94
|
+
readonly code: 'invalid-css-selector';
|
|
95
|
+
readonly input: string;
|
|
96
|
+
}
|
|
92
97
|
| {
|
|
93
98
|
readonly ok: false;
|
|
94
99
|
readonly code: 'no-match' | 'ambiguous';
|
|
@@ -222,10 +227,21 @@ async function resolveLiveCss(
|
|
|
222
227
|
): Promise<ResolvedTarget | ResolutionFailure> {
|
|
223
228
|
await client.send('DOM.enable');
|
|
224
229
|
const { root } = (await client.send('DOM.getDocument', { depth: 0 })) as { root: { nodeId: number } };
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
230
|
+
let nodeIds: number[];
|
|
231
|
+
try {
|
|
232
|
+
({ nodeIds } = (await client.send('DOM.querySelectorAll', {
|
|
233
|
+
nodeId: root.nodeId,
|
|
234
|
+
selector,
|
|
235
|
+
})) as { nodeIds: number[] });
|
|
236
|
+
} catch (error) {
|
|
237
|
+
// Chrome reports a malformed CSS selector from this CDP method as the
|
|
238
|
+
// otherwise opaque "DOM Error while querying". The bare grammar is CSS;
|
|
239
|
+
// make the accessible-name retry explicit instead of leaking that error.
|
|
240
|
+
if (error instanceof Error && error.message.includes('DOM Error while querying')) {
|
|
241
|
+
return { ok: false, code: 'invalid-css-selector', input };
|
|
242
|
+
}
|
|
243
|
+
throw error;
|
|
244
|
+
}
|
|
229
245
|
|
|
230
246
|
if (nodeIds.length === 1) {
|
|
231
247
|
const backendNodeId = await backendNodeIdFor(client, nodeIds[0]);
|
package/src/output/render.ts
CHANGED
package/src/registry.ts
CHANGED
|
@@ -87,7 +87,7 @@ const ref = 'css:<selector> | backend:<id> | axid:<id> | ax:<name> | text:<text>
|
|
|
87
87
|
|
|
88
88
|
export const CAPTURE_REGISTRY: RegistryBranch = branch('', 'Capture — browser evidence and automation over CDP.', [
|
|
89
89
|
branch('session', 'persistent browser-work lifecycle and artifact bundles.', [
|
|
90
|
-
leaf('session start', '
|
|
90
|
+
leaf('session start', 'open or prepare a tab for a persistent capture session.', { flags: [value('--url', 'absolute-http(s)-url'), bool('--hold'), port()], effects: effects(true, true, true), constraints: 'Optional singleton --url <absolute-http(s)-url> opens a fresh tab through Target.createTarget; endpoints such as Electron that reject Target.createTarget cannot use --url. Optional singleton --hold retains a browser connection; optional singleton --port <1..65535> selects its endpoint. To work against an existing tab, omit --url, run `capture session start --hold --port <port>`, list it with `capture tab list --port <port>`, then run `capture page navigate <url> --target <target-id> --port <port>`; that navigate command adopts the target and later page commands auto-target it. No target, prefix, positional URL, or ambient CDP selection on session start.', orderedOutput: 'Exact session ID; open lifecycle; full target ID or null; endpoint or null; observed URL or null; target_live=true when a tab was opened; scope key; active generation.', artifactOwnership: 'Exclusively allocates the session directory and atomically publishes the canonical session record before its scoped pointer.' }),
|
|
91
91
|
leaf('session stop', 'stop and bundle one exact capture session.', { positionals: [positional('session-id', 'exact-session-id')], effects: effects(true, true, true), constraints: 'One exact case-sensitive session ID; no prefixes. Only matching open/stopping/completed lifecycle records are accepted before bundle effects.', orderedOutput: 'Exact ID; stop-operation ID; stopped lifecycle; final bundle access; pointer outcome deleted|absent|superseded.', artifactOwnership: 'Resumes the recorded operation and owns bundle staging/publication; never rolls lifecycle back to open.', recovery: 'session_stop_state_invalid: inspect capture session view <exact-session-id> and preserve the session directory; never delete or allocate a new stop. session_stop_staging_cleanup_failed: correct access at the reported staging path and retry the identical capture session stop <exact-session-id>. session_stop_bundle_failed: correct the reported bundle/filesystem cause and retry that identical command. Both retries resume the recorded operation.' }),
|
|
92
92
|
leaf('session list', 'list same-user host capture sessions.', { effects: effects(false, true), constraints: 'No target/session filters or ambient defaults.', orderedOutput: 'Rows sorted by exact session ID: ID, lifecycle, active_in_current_scope, full target ID or null, endpoint or null, target_live.' }),
|
|
93
93
|
leaf('session view', 'view one exact capture session.', { positionals: [positional('session-id', 'exact-session-id')], effects: effects(false, true), constraints: 'One exact case-sensitive session ID; no prefix.' }),
|