@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
|
@@ -6,7 +6,8 @@ import { type ParsedArgs } from '../../types.js';
|
|
|
6
6
|
import { withConnection } from '../../connection.js';
|
|
7
7
|
import { isRecorderHeldClient } from '../../recorder-client.js';
|
|
8
8
|
import { parseViewport, type Viewport } from '../../viewport.js';
|
|
9
|
-
import { captureSnapshotSubstrate } from '../../measure/snapshot.js';
|
|
9
|
+
import { captureSnapshotSubstrate, COLLECTOR_NAMES, SnapshotCaptureTimeout } from '../../measure/snapshot.js';
|
|
10
|
+
import type { CollectorOutcome } from '../../measure/types.js';
|
|
10
11
|
import { sanitizeString } from '../../measure/redaction.js';
|
|
11
12
|
import { DEFAULT_SETTLE_TIMEOUT_MS } from '../../measure/settle.js';
|
|
12
13
|
import { createOneshotSession } from '../../../session/commands.js';
|
|
@@ -31,6 +32,7 @@ input:
|
|
|
31
32
|
--freeze-animations pause CSS/WAAPI animation before capture
|
|
32
33
|
--settle-timeout <ms> override the default 5000ms settle wait
|
|
33
34
|
--capture-unsettled write the full substrate despite non-settlement, marking unstable regions
|
|
35
|
+
--skip-collector <name> leave one collector out entirely (repeatable): ax, styles, queries, animation, layers, media, geometry, hittest, text, forms, focus, scroll, states, pixels. A collector that fails is recorded and skipped; every other collector's substrate is still written
|
|
34
36
|
--pixels also write per-element raster crops
|
|
35
37
|
--state <state[:selector]> force a pseudo-state or real control state (repeatable)
|
|
36
38
|
--viewport <WxH> temporarily capture at a CSS-pixel viewport (repeatable); exact <positive-safe-int>x<positive-safe-int> grammar with lowercase x and no whitespace
|
|
@@ -188,6 +190,17 @@ export async function withAppliedViewport<T>(client: ViewportClient, viewport: V
|
|
|
188
190
|
}
|
|
189
191
|
}
|
|
190
192
|
|
|
193
|
+
/** One line naming every collector whose substrate is absent or degraded, and what to do about it. */
|
|
194
|
+
function collectorNote(incomplete: readonly CollectorOutcome[]) {
|
|
195
|
+
const described = incomplete
|
|
196
|
+
.map((c) => `${sanitizeString(c.name)} (${c.status}${c.requestTimeouts ? `, ${c.requestTimeouts} timed-out request(s)` : ''})`)
|
|
197
|
+
.join(', ');
|
|
198
|
+
const failed = incomplete.some((c) => c.status === 'error' || c.status === 'degraded');
|
|
199
|
+
return fact`collectors incomplete: ${described}. Every other collector's substrate is valid and queryable.${
|
|
200
|
+
failed ? ` Re-run with \`--skip-collector <name>\` to omit one deliberately rather than have it fail.` : ''
|
|
201
|
+
}`;
|
|
202
|
+
}
|
|
203
|
+
|
|
191
204
|
function settledNote(settled: boolean, captured: boolean, settleMs: number, settleTimeoutMs: number, unstableCount: number) {
|
|
192
205
|
if (settled) {
|
|
193
206
|
return fact`Settled after ${settleMs}ms (two consecutive identical captures; DOM quiet ≥300ms).`;
|
|
@@ -210,6 +223,11 @@ export async function captureMeasureSnap(parsed: ParsedArgs, targetRef = parsed.
|
|
|
210
223
|
if (parsed.settleTimeout !== undefined && (!Number.isFinite(parsed.settleTimeout) || parsed.settleTimeout <= 0)) {
|
|
211
224
|
throw invalidInput('--settle-timeout must be a positive number of milliseconds');
|
|
212
225
|
}
|
|
226
|
+
for (const name of parsed.skipCollector ?? []) {
|
|
227
|
+
if (!COLLECTOR_NAMES.includes(name)) {
|
|
228
|
+
throw invalidInput(`--skip-collector must name a collector: ${COLLECTOR_NAMES.join(', ')} (got ${sanitizeString(name)})`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
213
231
|
const viewport: ViewportSpec | undefined = viewportValue === undefined
|
|
214
232
|
? undefined
|
|
215
233
|
: { label: viewportValue, ...parseViewport(viewportValue) };
|
|
@@ -254,6 +272,7 @@ export async function captureMeasureSnap(parsed: ParsedArgs, targetRef = parsed.
|
|
|
254
272
|
pixels: parsed.pixels,
|
|
255
273
|
state: parsed.state ?? [],
|
|
256
274
|
viewport: viewport?.label ?? null,
|
|
275
|
+
skipCollectors: parsed.skipCollector ?? [],
|
|
257
276
|
});
|
|
258
277
|
artifacts = result.artifacts;
|
|
259
278
|
});
|
|
@@ -262,7 +281,7 @@ export async function captureMeasureSnap(parsed: ParsedArgs, targetRef = parsed.
|
|
|
262
281
|
if (!snapDir) throw new Error('measure snap did not allocate an artifact directory');
|
|
263
282
|
return { id: snapId, dir: snapDir, artifacts, ...(base ? { base } : {}) };
|
|
264
283
|
} catch (err) {
|
|
265
|
-
if (allocatedRoot) removeArtifactTree(allocatedRoot);
|
|
284
|
+
if (allocatedRoot && !(err instanceof SnapshotCaptureTimeout)) removeArtifactTree(allocatedRoot);
|
|
266
285
|
throw err;
|
|
267
286
|
}
|
|
268
287
|
}
|
|
@@ -276,7 +295,9 @@ function buildSnapshotResult(captured: MeasureSnapCapture): RenderableResult {
|
|
|
276
295
|
settleTimeoutMs: number;
|
|
277
296
|
unstableRegionCount: number;
|
|
278
297
|
states: readonly string[];
|
|
298
|
+
collectors?: readonly CollectorOutcome[];
|
|
279
299
|
}>({ kind: 'snap', id: captured.id, dir: captured.dir });
|
|
300
|
+
const incomplete = (meta.collectors ?? []).filter((c) => c.status !== 'ok');
|
|
280
301
|
const elements = readArrayCount(path.join(captured.dir, 'geometry.json'), 'elements');
|
|
281
302
|
const artifacts = artifactEntries(captured.dir, captured.artifacts);
|
|
282
303
|
const capturedFullSubstrate = fs.existsSync(path.join(captured.dir, 'geometry.json'));
|
|
@@ -301,6 +322,11 @@ function buildSnapshotResult(captured: MeasureSnapCapture): RenderableResult {
|
|
|
301
322
|
summary: meta.settled || capturedFullSubstrate
|
|
302
323
|
? text`Snapshot substrate captured.`
|
|
303
324
|
: text`The evidence snapshot is not queryable because the settledness requirement was not met.`,
|
|
325
|
+
// A collector that did not complete cleanly must be visible in the block,
|
|
326
|
+
// not only in meta.json — its artifact is absent (timeout/error/skipped)
|
|
327
|
+
// or partly built from timed-out reads (degraded), and a caller who reads
|
|
328
|
+
// the remaining substrate as a whole page would be reading a hole.
|
|
329
|
+
...(incomplete.length ? { sections: [collectorNote(incomplete)] } : {}),
|
|
304
330
|
artifacts: formatArtifactList(artifacts),
|
|
305
331
|
followUp: meta.settled || capturedFullSubstrate
|
|
306
332
|
? fact`Query snapshot ${captured.id} with \`capture measure check ${captured.id}\`, \`capture measure census --snap ${captured.id} --axis color\`, or \`capture measure map focus ${captured.id}\`.`
|
|
@@ -312,6 +338,9 @@ interface SnapErrorDetails {
|
|
|
312
338
|
readonly status: string;
|
|
313
339
|
readonly message: string;
|
|
314
340
|
readonly resolution?: ArtifactResolutionError;
|
|
341
|
+
readonly phase?: string;
|
|
342
|
+
readonly method?: string;
|
|
343
|
+
readonly partialPath?: string;
|
|
315
344
|
}
|
|
316
345
|
|
|
317
346
|
function safeErrorDetail(value: unknown): string {
|
|
@@ -319,6 +348,9 @@ function safeErrorDetail(value: unknown): string {
|
|
|
319
348
|
}
|
|
320
349
|
|
|
321
350
|
export function classifySnapError(err: unknown): SnapErrorDetails {
|
|
351
|
+
if (err instanceof SnapshotCaptureTimeout) {
|
|
352
|
+
return { status: 'snapshot_capture_timeout', message: safeErrorDetail(err), phase: err.phase, method: err.method, partialPath: err.partialPath };
|
|
353
|
+
}
|
|
322
354
|
if (err instanceof ArtifactResolutionError) {
|
|
323
355
|
return { status: 'snapshot_ref_unavailable', message: safeErrorDetail(err), resolution: err };
|
|
324
356
|
}
|
|
@@ -399,6 +431,9 @@ export async function cmdMeasureSnap(parsed: ParsedArgs, _args: string[]): Promi
|
|
|
399
431
|
attrs: {
|
|
400
432
|
command: 'measure snap',
|
|
401
433
|
status: classified.status,
|
|
434
|
+
...(classified.phase ? { phase: classified.phase } : {}),
|
|
435
|
+
...(classified.method ? { method: classified.method } : {}),
|
|
436
|
+
...(classified.partialPath ? { 'partial-path': classified.partialPath } : {}),
|
|
402
437
|
...(classified.resolution ? {
|
|
403
438
|
recovery: 'artifact-resolution-error',
|
|
404
439
|
ref: sanitizeString(classified.resolution.ref),
|
|
@@ -24,9 +24,15 @@ export async function cmdMotionMask(parsed: ParsedArgs, _args: string[]): Promis
|
|
|
24
24
|
|
|
25
25
|
try {
|
|
26
26
|
const ref = resolveRecRef(parsed.positional[0]);
|
|
27
|
-
const meta = readMeta<{ state?: unknown }>(ref);
|
|
27
|
+
const meta = readMeta<{ state?: unknown; reason?: unknown }>(ref);
|
|
28
28
|
const state = typeof meta.state === 'string' ? meta.state : 'unknown';
|
|
29
|
-
|
|
29
|
+
// A one-shot recording that captured zero screencast frames is a completed
|
|
30
|
+
// recording, not one awaiting `--stop` (which only finalizes an active
|
|
31
|
+
// composed lifecycle recording) — let it through and rely on
|
|
32
|
+
// createMotionMask's own accurate "needs at least two frames" rejection
|
|
33
|
+
// below, matching the same exception in response.ts's loadResponseTimeline.
|
|
34
|
+
const readablePartial = state === 'partial' && meta.reason === 'no_frames';
|
|
35
|
+
if (state !== 'finalized' && state !== 'orphaned-finalized' && !readablePartial) {
|
|
30
36
|
return emitCommandError(parsed, 'recording_not_finalized', `Recording ${ref.id} has state ${state}; finalize it with \`capture motion rec --stop\` before creating a mask.`);
|
|
31
37
|
}
|
|
32
38
|
const mask = createMotionMask(ref);
|
|
@@ -94,6 +94,20 @@ export function emitResolutionError(parsed: ParsedArgs, command: string, failure
|
|
|
94
94
|
return;
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
if (failure.code === 'invalid-css-selector') {
|
|
98
|
+
emitResult(
|
|
99
|
+
{
|
|
100
|
+
tag: 'error',
|
|
101
|
+
attrs: { command, code: 'invalid_css_selector' },
|
|
102
|
+
summary: fact`received: bare target \`${failure.input}\` was rejected by DOM.querySelectorAll as a CSS selector.`,
|
|
103
|
+
followUp: fact`Retry \`capture ${command} ax:${failure.input}\` to target that accessible name, or use \`backend:<id>\` from \`capture page elements\`.`,
|
|
104
|
+
},
|
|
105
|
+
{ json: parsed.json },
|
|
106
|
+
);
|
|
107
|
+
process.exitCode = 1;
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
97
111
|
const code = failure.code === 'no-match' ? 'no_match' : 'ambiguous_target';
|
|
98
112
|
const candidateRows: FactLine[] = failure.candidates.map((c) =>
|
|
99
113
|
line(data(c.role ?? 'unknown'), text` "`, data(c.name ?? ''), text`" — backend:`, data(c.backendNodeId)),
|
|
@@ -42,7 +42,7 @@ Input:
|
|
|
42
42
|
--json mirror the result as JSON
|
|
43
43
|
|
|
44
44
|
Output:
|
|
45
|
-
<elements scope="interactive|all" count=<total>> — one row per element: role "name" backend:<id
|
|
45
|
+
<elements scope="interactive|all" count=<total>> — one row per element: role "name" target:ax:<name> backend:<id>; copy the target value into a driving verb
|
|
46
46
|
|
|
47
47
|
Effects: read-only — the accessibility fetch is CDP-side, no page-observable writes; routes unmarked through an active composed recording.`;
|
|
48
48
|
|
|
@@ -106,7 +106,7 @@ export function buildElementsResult(
|
|
|
106
106
|
|
|
107
107
|
const rows = shown.map((r) =>
|
|
108
108
|
r.backendNodeId !== null
|
|
109
|
-
? fact`${r.role} "${r.name}" backend:${r.backendNodeId}`
|
|
109
|
+
? fact`${r.role} "${r.name}" target:ax:${r.name} backend:${r.backendNodeId}`
|
|
110
110
|
: fact`${r.role} "${r.name}"`,
|
|
111
111
|
);
|
|
112
112
|
|
|
@@ -18,7 +18,7 @@ import * as fs from 'fs';
|
|
|
18
18
|
import * as path from 'path';
|
|
19
19
|
import { type ParsedArgs } from '../../types.js';
|
|
20
20
|
import { withConnection } from '../../connection.js';
|
|
21
|
-
import {
|
|
21
|
+
import { captureScreenshotWithCssViewport, type CapturedRegion } from '../../screenshot.js';
|
|
22
22
|
import { parseViewport, type Viewport } from '../../viewport.js';
|
|
23
23
|
import { nextStepPath } from '../../../session-context.js';
|
|
24
24
|
import { createOneshotSession } from '../../../session/commands.js';
|
|
@@ -40,7 +40,7 @@ input:
|
|
|
40
40
|
--target <tabId> | --url <pattern> | --port <n> tab targeting; defaults to the active session tab
|
|
41
41
|
--json mirror the result as JSON
|
|
42
42
|
output:
|
|
43
|
-
<screenshot path=… width=… height=… emulation=none|viewport|full-page> — saved path, PNG pixel dimensions, byte size, and the emulation fact (whether a transient override was applied, or its absence)
|
|
43
|
+
<screenshot path=… width=… height=… css-width=… css-height=… css-x=… css-y=… css-to-image-x=… css-to-image-y=… emulation=none|viewport|full-page> — saved path, PNG pixel dimensions, the CSS-pixel region the image covers (its page-coordinate origin and size) with the CSS-to-image scale derived from it, byte size, and the emulation fact (whether a transient override was applied, or its absence)
|
|
44
44
|
effects:
|
|
45
45
|
no flags: none — the capture reads the viewport as-is, with zero Emulation.* calls. With --viewport/--full-page: applies a transient Emulation.setDeviceMetricsOverride (~150ms re-layout wait) and clears it after the capture — two page-observable resizes (resize events fire; media queries flip and flip back)`;
|
|
46
46
|
|
|
@@ -116,14 +116,29 @@ function writeScreenshot(outPath: string, png: Buffer): void {
|
|
|
116
116
|
|
|
117
117
|
export type EmulationMode = 'none' | 'viewport' | 'full-page';
|
|
118
118
|
|
|
119
|
+
/** Renders a CSS-pixel measurement without inventing precision: an integer stays an integer, a fractional zoomed viewport keeps six decimals. */
|
|
120
|
+
function round6(value: number): number {
|
|
121
|
+
return Math.round(value * 1e6) / 1e6;
|
|
122
|
+
}
|
|
123
|
+
|
|
119
124
|
export function buildScreenshotResult(f: {
|
|
120
125
|
path: string;
|
|
121
126
|
bytes: number;
|
|
122
127
|
dimensions: { width: number; height: number } | null;
|
|
128
|
+
cssViewport?: CapturedRegion;
|
|
123
129
|
emulation: EmulationMode;
|
|
124
130
|
viewport?: { width: number; height: number };
|
|
125
131
|
}): RenderableResult {
|
|
132
|
+
// The denominator is the CSS region the image was actually cut from, so the
|
|
133
|
+
// ratio stays true under page zoom, device pixel ratio, and the local
|
|
134
|
+
// downscale — the layout viewport is a different space and is never used here.
|
|
135
|
+
const scale = f.dimensions && f.cssViewport
|
|
136
|
+
? { x: f.dimensions.width / f.cssViewport.width, y: f.dimensions.height / f.cssViewport.height }
|
|
137
|
+
: undefined;
|
|
126
138
|
const sections: FactLine[] = [
|
|
139
|
+
scale && f.cssViewport
|
|
140
|
+
? fact`CSS-to-image scale: ${scale.x.toFixed(6)} image px/CSS px horizontally and ${scale.y.toFixed(6)} image px/CSS px vertically (captured CSS region ${round6(f.cssViewport.width)}×${round6(f.cssViewport.height)} at page origin ${round6(f.cssViewport.x)},${round6(f.cssViewport.y)}).`
|
|
141
|
+
: text`CSS-to-image scale unavailable: the browser did not return the CSS region this capture covers.`,
|
|
127
142
|
f.emulation === 'none'
|
|
128
143
|
? text`emulation: none — the browser's actual current viewport was captured; no Emulation call was made.`
|
|
129
144
|
: f.emulation === 'viewport'
|
|
@@ -138,6 +153,12 @@ export function buildScreenshotResult(f: {
|
|
|
138
153
|
path: f.path,
|
|
139
154
|
width: f.dimensions?.width,
|
|
140
155
|
height: f.dimensions?.height,
|
|
156
|
+
'css-width': f.cssViewport === undefined ? undefined : round6(f.cssViewport.width),
|
|
157
|
+
'css-height': f.cssViewport === undefined ? undefined : round6(f.cssViewport.height),
|
|
158
|
+
'css-x': f.cssViewport === undefined ? undefined : round6(f.cssViewport.x),
|
|
159
|
+
'css-y': f.cssViewport === undefined ? undefined : round6(f.cssViewport.y),
|
|
160
|
+
'css-to-image-x': scale?.x.toFixed(6),
|
|
161
|
+
'css-to-image-y': scale?.y.toFixed(6),
|
|
141
162
|
emulation: f.emulation,
|
|
142
163
|
},
|
|
143
164
|
summary: f.dimensions
|
|
@@ -196,10 +217,10 @@ export async function cmdPageShot(parsed: ParsedArgs, _args: string[]): Promise<
|
|
|
196
217
|
const result = await deps.withConnection(
|
|
197
218
|
{ ...parsed, command: 'shot' },
|
|
198
219
|
async (client) => {
|
|
199
|
-
const
|
|
220
|
+
const screenshot = await captureScreenshotWithCssViewport(client, viewport, { fullPage: parsed.fullPage });
|
|
200
221
|
const outPath = await resolveOutPath(parsed);
|
|
201
|
-
writeScreenshot(outPath, png);
|
|
202
|
-
return { path: outPath, bytes: png.length, dimensions: pngDimensions(png) };
|
|
222
|
+
writeScreenshot(outPath, screenshot.png);
|
|
223
|
+
return { path: outPath, bytes: screenshot.png.length, dimensions: pngDimensions(screenshot.png), cssViewport: screenshot.cssViewport };
|
|
203
224
|
},
|
|
204
225
|
{ settle: 0 },
|
|
205
226
|
);
|
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { invalidInput } from '../../../errors.js';
|
|
12
12
|
import { type ParsedArgs } from '../../types.js';
|
|
13
|
+
import { cmdTabLaunch } from './launch.js';
|
|
14
|
+
import { cmdTabQuit } from './quit.js';
|
|
13
15
|
import { cmdTabList } from './list.js';
|
|
14
16
|
import { cmdTabOpen } from './open.js';
|
|
15
17
|
import { cmdTabClose } from './close.js';
|
|
@@ -18,19 +20,24 @@ import { cmdTabNetwork } from './network.js';
|
|
|
18
20
|
|
|
19
21
|
/** Root-help representation of this branch, assembled by `src/capture.ts`. */
|
|
20
22
|
export const COMMAND_BLOCK = `<command name="tab">
|
|
21
|
-
|
|
22
|
-
use when finding a CDP endpoint or tab, opening/closing/replacing a tab, or toggling connectivity; \`tab list\` is the probe for a running browser
|
|
23
|
-
list · open · close · reset · network — \`capture tab -h\`
|
|
23
|
+
browser and tab plumbing — starting/stopping a browser capture owns, endpoint/tab discovery, tab lifecycle, connection-level network emulation
|
|
24
|
+
use when nothing is running yet (\`tab launch\` starts a browser capture owns and reaps), when finding a CDP endpoint or tab, opening/closing/replacing a tab, or toggling connectivity; \`tab list\` is the probe for a running browser
|
|
25
|
+
launch · quit · list · open · close · reset · network — \`capture tab -h\`
|
|
24
26
|
</command>`;
|
|
25
27
|
|
|
26
|
-
export const TAB_USAGE = `capture tab —
|
|
28
|
+
export const TAB_USAGE = `capture tab — browser and tab plumbing: browser lifecycle, discovery, tab lifecycle, network emulation.
|
|
27
29
|
|
|
28
30
|
\`tab list\` with no --port performs full endpoint discovery and is the probe
|
|
29
|
-
for whether a CDP-enabled browser is running at all
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
for whether a CDP-enabled browser is running at all; when nothing is running,
|
|
32
|
+
\`tab launch\` starts one capture owns — never hand-roll a detached browser,
|
|
33
|
+
since nothing reaps that. Capture signals only browsers it launched itself
|
|
34
|
+
(verified by process-birth identity): an endpoint you reach with --port stays
|
|
35
|
+
untouched. \`tab reset\` replaces a stuck tab with a fresh one; under an active
|
|
36
|
+
session it refuses while a recording is active (stop it first), reaps dead
|
|
37
|
+
recorder handles, and updates the session's {target, port} pair together.
|
|
33
38
|
|
|
39
|
+
<subcommand name="launch" args="[--url <url>] [--port <port>] [--headed]" whenToUse="start a browser capture owns (and reaps) when none is running"/>
|
|
40
|
+
<subcommand name="quit" args="[<port>] [--all]" whenToUse="stop a browser capture launched; never touches a browser capture did not start"/>
|
|
34
41
|
<subcommand name="list" args="[--port <port>]" whenToUse="discover CDP endpoints and the tabs open on them"/>
|
|
35
42
|
<subcommand name="open" args="<url> [--new] [--port <port>]" whenToUse="open a URL and get its tab id"/>
|
|
36
43
|
<subcommand name="close" args="<target> [--port <port>]" whenToUse="close one explicitly identified background tab"/>
|
|
@@ -44,6 +51,10 @@ export async function tabMain(parsed: ParsedArgs, args: string[]): Promise<void>
|
|
|
44
51
|
const rest: ParsedArgs = { ...parsed, positional: parsed.positional.slice(1) };
|
|
45
52
|
|
|
46
53
|
switch (leaf) {
|
|
54
|
+
case 'launch':
|
|
55
|
+
return cmdTabLaunch(rest, args);
|
|
56
|
+
case 'quit':
|
|
57
|
+
return cmdTabQuit(rest, args);
|
|
47
58
|
case 'list':
|
|
48
59
|
return cmdTabList(rest, args);
|
|
49
60
|
case 'open':
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `capture tab launch [--url <url>] [--port <port>] [--headed]` — start a
|
|
3
|
+
* browser capture owns, and report the endpoint every other capture command can
|
|
4
|
+
* now be pointed at.
|
|
5
|
+
*
|
|
6
|
+
* This is the answer to "a CDP-enabled browser must be running": without it the
|
|
7
|
+
* only way to satisfy that precondition was a hand-rolled detached Chrome with
|
|
8
|
+
* an invented port and an invented scratch profile, which nothing ever reaped.
|
|
9
|
+
* Ownership, registry, and reaping all live in `../../browser-process.ts`; this
|
|
10
|
+
* leaf is the surface over them.
|
|
11
|
+
*/
|
|
12
|
+
import { launchOwnedBrowser, sweepOwnedBrowsers, IDLE_REAP_MS } from '../../browser-process.js';
|
|
13
|
+
import { type ParsedArgs } from '../../types.js';
|
|
14
|
+
import { data, emitResult, fact, line, lineList, text, type RenderableResult } from '../../../output/render.js';
|
|
15
|
+
|
|
16
|
+
const USAGE = `capture tab launch — start a browser capture owns and report its CDP endpoint.
|
|
17
|
+
|
|
18
|
+
input:
|
|
19
|
+
--url <url> first page to open (default: about:blank)
|
|
20
|
+
--port <port> pin the debugging port (default: a free port chosen by the kernel)
|
|
21
|
+
--headed run with a visible window instead of headless
|
|
22
|
+
|
|
23
|
+
output: <browser-launched port=… pid=…> — the port to pass as --port (or CDP_PORT), the pid capture owns, and the executable that answered with its provenance (CAPTURE_BROWSER, puppeteer-cache, or system).
|
|
24
|
+
effects: spawns a detached browser with a private profile under CAPTURE_ROOT, and registers it as capture-owned. Capture stops it on \`capture tab quit\`, or on the first later capture invocation once it has been unused for ${IDLE_REAP_MS / 60000} minutes; a browser capture did not start is never signalled. Also sweeps previously-launched browsers that have exited, stopped answering CDP, or gone idle.`;
|
|
25
|
+
|
|
26
|
+
/** Pure `<browser-launched>` result builder — exported for tests. */
|
|
27
|
+
export function buildBrowserLaunchedResult(
|
|
28
|
+
browser: { port: number; pid: number; executablePath: string; source: string; headless: boolean },
|
|
29
|
+
swept: number,
|
|
30
|
+
): RenderableResult {
|
|
31
|
+
const sections = [
|
|
32
|
+
lineList([
|
|
33
|
+
line(text`executable `, data(browser.executablePath, 300), text` (`, data(browser.source), text`)`),
|
|
34
|
+
line(text`mode `, data(browser.headless ? 'headless' : 'headed')),
|
|
35
|
+
]),
|
|
36
|
+
];
|
|
37
|
+
if (swept > 0) sections.push(fact`swept ${swept} previously-launched browser(s) that had exited, stopped answering CDP, or gone idle.`);
|
|
38
|
+
return {
|
|
39
|
+
tag: 'browser-launched',
|
|
40
|
+
attrs: { port: browser.port, pid: browser.pid, headless: browser.headless },
|
|
41
|
+
summary: line(
|
|
42
|
+
text`capture-owned browser on port `,
|
|
43
|
+
data(browser.port),
|
|
44
|
+
fact` (pid ${browser.pid}) is answering CDP.`,
|
|
45
|
+
),
|
|
46
|
+
sections,
|
|
47
|
+
followUp: line(text`capture tab quit `, data(browser.port)),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function cmdTabLaunch(parsed: ParsedArgs, _args: string[]): Promise<void> {
|
|
52
|
+
if (parsed.help) {
|
|
53
|
+
console.log(USAGE);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const swept = await sweepOwnedBrowsers();
|
|
58
|
+
const browser = await launchOwnedBrowser({
|
|
59
|
+
url: parsed.url,
|
|
60
|
+
port: parsed.port,
|
|
61
|
+
headless: !parsed.headed,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
emitResult(
|
|
65
|
+
buildBrowserLaunchedResult(
|
|
66
|
+
// A launched browser always reports a port — `launchOwnedBrowser` only
|
|
67
|
+
// returns once its endpoint answered on one.
|
|
68
|
+
{ ...browser, port: browser.port! },
|
|
69
|
+
swept.length,
|
|
70
|
+
),
|
|
71
|
+
{ json: parsed.json },
|
|
72
|
+
);
|
|
73
|
+
}
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
pickPreferredEndpoint,
|
|
14
14
|
} from '../../detect.js';
|
|
15
15
|
import { listTargets } from '../../targets.js';
|
|
16
|
+
import { ownedBrowsersByPort, sweepOwnedBrowsers } from '../../browser-process.js';
|
|
16
17
|
import { type ParsedArgs } from '../../types.js';
|
|
17
18
|
import {
|
|
18
19
|
data,
|
|
@@ -30,8 +31,8 @@ const USAGE = `capture tab list — discover CDP endpoints and list the page tab
|
|
|
30
31
|
input:
|
|
31
32
|
--port <port> probe one endpoint only, instead of discovering every localhost CDP endpoint
|
|
32
33
|
|
|
33
|
-
output: <tabs endpoints=… tabs=…> — one section per reachable endpoint (port, app, page-tab count, preferred marker) listing each page tab's target id (first 8 chars, enough for --target), title, and url; an endpoint that failed the tab fetch is an endpoints-unreachable fact, never a silent omission.
|
|
34
|
-
effects:
|
|
34
|
+
output: <tabs endpoints=… tabs=…> — one section per reachable endpoint (port, app, page-tab count, preferred marker, and a capture-owned pid marker on any browser \`tab launch\` started) listing each page tab's target id (first 8 chars, enough for --target), title, and url; an endpoint that failed the tab fetch is an endpoints-unreachable fact, never a silent omission.
|
|
35
|
+
effects: probes local CDP endpoints over HTTP/WebSocket; no page-observable writes. Also sweeps capture-launched browsers, stopping any that have exited, stopped answering CDP, or gone idle — browsers capture did not start are never signalled.`;
|
|
35
36
|
|
|
36
37
|
export interface TabRow {
|
|
37
38
|
id: string;
|
|
@@ -45,6 +46,9 @@ export interface EndpointTabs {
|
|
|
45
46
|
app?: string;
|
|
46
47
|
/** Marked on the endpoint auto-discovery would pick for port-less commands. */
|
|
47
48
|
preferred: boolean;
|
|
49
|
+
/** The pid of the capture-launched browser serving this endpoint, when this
|
|
50
|
+
* is one capture owns — absent for every foreign/user-owned endpoint. */
|
|
51
|
+
ownedPid?: number;
|
|
48
52
|
pages: TabRow[];
|
|
49
53
|
}
|
|
50
54
|
|
|
@@ -76,6 +80,7 @@ export function buildTabsResult(
|
|
|
76
80
|
...(ep.app !== undefined ? [line(text` — `, data(ep.app, 80))] : []),
|
|
77
81
|
fact` — ${ep.pages.length} page tab(s)`,
|
|
78
82
|
...(ep.preferred ? [text` [preferred]`] : []),
|
|
83
|
+
...(ep.ownedPid !== undefined ? [line(fact` [capture-owned pid=${ep.ownedPid}]`)] : []),
|
|
79
84
|
);
|
|
80
85
|
const rows = ep.pages.map((t) =>
|
|
81
86
|
line(text` `, data(t.id.slice(0, 8)), text` "`, data(t.title, 120), text`" `, data(t.url, 300)),
|
|
@@ -121,6 +126,11 @@ export async function cmdTabList(parsed: ParsedArgs, _args: string[]): Promise<v
|
|
|
121
126
|
return;
|
|
122
127
|
}
|
|
123
128
|
|
|
129
|
+
// The probe agents run constantly is also where abandoned owned browsers
|
|
130
|
+
// die: sweeping here is what keeps a launch from outliving the agent that
|
|
131
|
+
// made it, without any daemon in the picture.
|
|
132
|
+
await sweepOwnedBrowsers();
|
|
133
|
+
const owned = ownedBrowsersByPort();
|
|
124
134
|
const reachable: EndpointTabs[] = [];
|
|
125
135
|
const unreachable: UnreachableEndpoint[] = [];
|
|
126
136
|
|
|
@@ -130,6 +140,7 @@ export async function cmdTabList(parsed: ParsedArgs, _args: string[]): Promise<v
|
|
|
130
140
|
reachable.push({
|
|
131
141
|
port: parsed.port,
|
|
132
142
|
preferred: false,
|
|
143
|
+
...(owned.has(parsed.port) ? { ownedPid: owned.get(parsed.port)!.pid } : {}),
|
|
133
144
|
pages: targets
|
|
134
145
|
.filter((t) => t.type === 'page')
|
|
135
146
|
.map((t) => ({ id: t.id, title: t.title, url: t.url })),
|
|
@@ -149,6 +160,7 @@ export async function cmdTabList(parsed: ParsedArgs, _args: string[]): Promise<v
|
|
|
149
160
|
port: ep.port,
|
|
150
161
|
app: ep.app,
|
|
151
162
|
preferred: ep.port === preferredPort,
|
|
163
|
+
...(owned.has(ep.port) ? { ownedPid: owned.get(ep.port)!.pid } : {}),
|
|
152
164
|
pages: targets
|
|
153
165
|
.filter((t) => t.type === 'page')
|
|
154
166
|
.map((t) => ({ id: t.id, title: t.title, url: t.url })),
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `capture tab quit [<port>] [--all]` — stop browsers capture started.
|
|
3
|
+
*
|
|
4
|
+
* Named `quit` rather than `close` deliberately: `capture tab close` closes one
|
|
5
|
+
* TAB inside a browser, this ends the browser process itself. It reaches only
|
|
6
|
+
* browsers in capture's own registry, verified by PID birth — a port capture
|
|
7
|
+
* never launched (the user's own browser, or one an agent started by hand)
|
|
8
|
+
* matches nothing here and is never signalled.
|
|
9
|
+
*/
|
|
10
|
+
import { quitOwnedBrowsers, sweepOwnedBrowsers, type ReapedBrowser } from '../../browser-process.js';
|
|
11
|
+
import { invalidInput } from '../../../errors.js';
|
|
12
|
+
import { type ParsedArgs } from '../../types.js';
|
|
13
|
+
import { data, emitResult, fact, line, lineList, text, type RenderableResult } from '../../../output/render.js';
|
|
14
|
+
|
|
15
|
+
const USAGE = `capture tab quit — stop a browser capture started.
|
|
16
|
+
|
|
17
|
+
input:
|
|
18
|
+
<port> the port of a capture-launched browser (as reported by tab launch / tab list)
|
|
19
|
+
--all stop every browser capture launched
|
|
20
|
+
|
|
21
|
+
output: <browser-quit stopped=…> — one line per stopped browser (port, pid) and the reason it ended.
|
|
22
|
+
effects: SIGTERM (then SIGKILL if needed) to capture-launched browsers only, verified by process-birth identity, and removes their private profiles. A browser capture did not start is never signalled — a port with no capture record stops nothing.`;
|
|
23
|
+
|
|
24
|
+
/** Pure `<browser-quit>` result builder — exported for tests. */
|
|
25
|
+
export function buildBrowserQuitResult(stopped: readonly ReapedBrowser[], selector: string): RenderableResult {
|
|
26
|
+
return {
|
|
27
|
+
tag: 'browser-quit',
|
|
28
|
+
attrs: { stopped: stopped.length },
|
|
29
|
+
summary:
|
|
30
|
+
stopped.length === 0
|
|
31
|
+
? line(text`no capture-launched browser matched `, data(selector), text`; nothing was signalled.`)
|
|
32
|
+
: fact`stopped ${stopped.length} capture-launched browser(s).`,
|
|
33
|
+
sections:
|
|
34
|
+
stopped.length === 0
|
|
35
|
+
? []
|
|
36
|
+
: [
|
|
37
|
+
lineList(
|
|
38
|
+
stopped.map((browser) =>
|
|
39
|
+
line(
|
|
40
|
+
text`port `,
|
|
41
|
+
data(browser.port ?? 'unstarted'),
|
|
42
|
+
fact` pid ${browser.pid} `,
|
|
43
|
+
data(browser.reason),
|
|
44
|
+
),
|
|
45
|
+
),
|
|
46
|
+
),
|
|
47
|
+
],
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function cmdTabQuit(parsed: ParsedArgs, _args: string[]): Promise<void> {
|
|
52
|
+
if (parsed.help) {
|
|
53
|
+
console.log(USAGE);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const token = parsed.positional[0];
|
|
58
|
+
if (!parsed.all && token === undefined) {
|
|
59
|
+
throw invalidInput('received: no target; expected: capture tab quit <port> or capture tab quit --all.', 'missing_argument');
|
|
60
|
+
}
|
|
61
|
+
if (parsed.all && token !== undefined) {
|
|
62
|
+
throw invalidInput('received: both a port and --all; expected: one or the other.');
|
|
63
|
+
}
|
|
64
|
+
if (token !== undefined && !/^\d+$/.test(token)) {
|
|
65
|
+
throw invalidInput(`Invalid port: ${token}.`);
|
|
66
|
+
}
|
|
67
|
+
const port = token === undefined ? undefined : Number(token);
|
|
68
|
+
|
|
69
|
+
// The sweep runs first so a browser that already exited is reported as
|
|
70
|
+
// reaped rather than as "no match" for the port the caller remembered.
|
|
71
|
+
const swept = await sweepOwnedBrowsers();
|
|
72
|
+
const stopped = await quitOwnedBrowsers({ port, all: parsed.all });
|
|
73
|
+
const matchedSweep = swept.filter((browser) => parsed.all || browser.port === port);
|
|
74
|
+
|
|
75
|
+
emitResult(buildBrowserQuitResult([...stopped, ...matchedSweep], parsed.all ? '--all' : String(port)), {
|
|
76
|
+
json: parsed.json,
|
|
77
|
+
});
|
|
78
|
+
}
|
package/src/cdp/har-recorder.ts
CHANGED
|
@@ -164,8 +164,6 @@ export class HARRecorder {
|
|
|
164
164
|
private readonly recordedKeys = new Set<string>();
|
|
165
165
|
/** Snapshot mode only — streaming mode retains no materialized values. */
|
|
166
166
|
private readonly materializedByKey = new Map<string, Materialized>();
|
|
167
|
-
/** Last terminal remains comparable until this CDP id opens its next generation. */
|
|
168
|
-
private readonly closedTerminals = new Map<string, Terminal>();
|
|
169
167
|
/** Sockets opened while this recorder is attached — CDP does not replay creation events for pre-existing sockets. */
|
|
170
168
|
private readonly webSockets = new Map<string, WebSocketConnection>();
|
|
171
169
|
private readonly bodyTasks = new Set<Promise<void>>();
|
|
@@ -245,11 +243,13 @@ export class HARRecorder {
|
|
|
245
243
|
const hasRedirect = 'redirectResponse' in params && params.redirectResponse !== undefined;
|
|
246
244
|
if (previous && !hasRedirect) fail('duplicate active requestId without redirectResponse');
|
|
247
245
|
if (hasRedirect && previous) {
|
|
248
|
-
|
|
249
|
-
|
|
246
|
+
// CDP uses status 0 when a redirect response is observable but never
|
|
247
|
+
// reached HTTP (for example, an extension redirect during navigation).
|
|
248
|
+
// Preserve that factual response rather than aborting the whole session.
|
|
249
|
+
const redirect = this.parseResponse(params.redirectResponse, 'redirectResponse', true);
|
|
250
|
+
if (redirect.status !== 0 && (redirect.status < 300 || redirect.status > 399)) fail('redirectResponse.status');
|
|
250
251
|
this.assignTerminal(previous, { kind: 'redirect', timestamp: monotonic }, redirect);
|
|
251
252
|
}
|
|
252
|
-
this.closedTerminals.delete(requestId);
|
|
253
253
|
const generation = this.nextGenerationByRequestId.get(requestId) ?? 1;
|
|
254
254
|
this.nextGenerationByRequestId.set(requestId, generation + 1);
|
|
255
255
|
this.activeByRequestId.set(requestId, {
|
|
@@ -338,11 +338,11 @@ export class HARRecorder {
|
|
|
338
338
|
});
|
|
339
339
|
}
|
|
340
340
|
|
|
341
|
-
private parseResponse(value: unknown, name: string): RedirectResponse {
|
|
341
|
+
private parseResponse(value: unknown, name: string, allowNoHttpStatus = false): RedirectResponse {
|
|
342
342
|
if (!isPlainObject(value)) fail(name);
|
|
343
343
|
nonempty(value.url, `${name}.url`);
|
|
344
344
|
const status = finite(value.status, `${name}.status`);
|
|
345
|
-
if (!Number.isInteger(status) || status <
|
|
345
|
+
if (!Number.isInteger(status) || status < 0 || status > 599 || (status < 100 && !(allowNoHttpStatus && status === 0))) fail(`${name}.status`);
|
|
346
346
|
return { status, headers: headers(value.headers, `${name}.headers`) };
|
|
347
347
|
}
|
|
348
348
|
|
|
@@ -366,11 +366,13 @@ export class HARRecorder {
|
|
|
366
366
|
const requestId = this.eventRequestId(params);
|
|
367
367
|
if (!requestId) return;
|
|
368
368
|
const current = this.activeByRequestId.get(requestId);
|
|
369
|
-
|
|
370
|
-
|
|
369
|
+
// Chromium can follow a loadingFailed with loadingFinished for the same
|
|
370
|
+
// request (for example, a blocked iframe during navigation). Once the
|
|
371
|
+
// first terminal materializes its generation, a later terminal cannot be
|
|
372
|
+
// assigned safely, so the first factual terminal remains authoritative.
|
|
373
|
+
if (!current) return;
|
|
371
374
|
const event = params as Record<string, unknown>;
|
|
372
375
|
const terminal: Terminal = { kind: 'finished', timestamp: finite(event.timestamp, 'timestamp'), encodedDataLength: finite(event.encodedDataLength, 'encodedDataLength') };
|
|
373
|
-
if (!current) return this.compareClosedTerminal(requestId, terminal);
|
|
374
376
|
if (!current.response) fail('loadingFinished without responseReceived');
|
|
375
377
|
this.assignTerminal(current, terminal);
|
|
376
378
|
}
|
|
@@ -379,8 +381,7 @@ export class HARRecorder {
|
|
|
379
381
|
const requestId = this.eventRequestId(params);
|
|
380
382
|
if (!requestId) return;
|
|
381
383
|
const current = this.activeByRequestId.get(requestId);
|
|
382
|
-
|
|
383
|
-
if (!current && !closed) return;
|
|
384
|
+
if (!current) return;
|
|
384
385
|
const event = params as Record<string, unknown>;
|
|
385
386
|
const terminal: Terminal = {
|
|
386
387
|
kind: 'failed',
|
|
@@ -390,22 +391,15 @@ export class HARRecorder {
|
|
|
390
391
|
blockedReason: event.blockedReason === undefined ? null : typeof event.blockedReason === 'string' ? event.blockedReason : fail('blockedReason'),
|
|
391
392
|
resourceType: event.type === undefined ? null : typeof event.type === 'string' ? event.type : fail('type'),
|
|
392
393
|
};
|
|
393
|
-
if (!current) return this.compareClosedTerminal(requestId, terminal);
|
|
394
394
|
this.assignTerminal(current, terminal);
|
|
395
395
|
}
|
|
396
396
|
|
|
397
|
-
private compareClosedTerminal(requestId: string, terminal: Terminal): void {
|
|
398
|
-
const previous = this.closedTerminals.get(requestId);
|
|
399
|
-
if (previous && !same(previous, terminal)) fail('conflicting terminal event');
|
|
400
|
-
}
|
|
401
|
-
|
|
402
397
|
private assignTerminal(current: Generation, terminal: Terminal, redirectResponse?: RedirectResponse): void {
|
|
403
398
|
if (current.terminal) {
|
|
404
|
-
if (!same(current.terminal, terminal)) fail(
|
|
399
|
+
if (!same(current.terminal, terminal)) fail(`conflicting terminal event for requestId ${current.requestId} generation ${current.generation}`);
|
|
405
400
|
return;
|
|
406
401
|
}
|
|
407
402
|
current.terminal = terminal;
|
|
408
|
-
this.closedTerminals.set(current.requestId, terminal);
|
|
409
403
|
if (redirectResponse) {
|
|
410
404
|
current.redirectResponse = redirectResponse;
|
|
411
405
|
this.materialize(current, { state: 'not_applicable', reason: 'redirect' });
|
|
@@ -7,10 +7,9 @@
|
|
|
7
7
|
* shared with `styles.json`/`media.json` per the U04 contract — no
|
|
8
8
|
* coordination with those files is needed.
|
|
9
9
|
*
|
|
10
|
-
* "geometry ids ... when possible":
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* read at capture time. Read here as "attach a `rect` fact directly per AX
|
|
10
|
+
* "geometry ids ... when possible": `geometry.json` is a separate
|
|
11
|
+
* collector artifact that this file does not depend on or read at capture
|
|
12
|
+
* time. Read here as "attach a `rect` fact directly per AX
|
|
14
13
|
* node via a best-effort `DOM.getBoxModel` lookup" rather than a join
|
|
15
14
|
* against `geometry.json` — a deliberate, bounded reading documented in
|
|
16
15
|
* the U08 build-plan report, not a defect.
|