@crouton-kit/capture 1.3.18 → 1.3.20
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 +4204 -3244
- package/package.json +1 -1
- package/src/capture.ts +4 -1
- package/src/cdp/args.ts +22 -3
- package/src/cdp/browser-process.ts +510 -0
- package/src/cdp/client.ts +10 -1
- package/src/cdp/color-scheme.ts +47 -0
- package/src/cdp/commands/measure/index.ts +1 -1
- package/src/cdp/commands/measure/snap.ts +50 -8
- 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/index.ts +1 -1
- package/src/cdp/commands/page/shot.ts +58 -13
- 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 +197 -29
- package/src/cdp/measure/types.ts +36 -2
- package/src/cdp/motion/jank.ts +8 -1
- package/src/cdp/screenshot.ts +83 -8
- package/src/cdp/targets.ts +5 -0
- package/src/cdp/types.ts +6 -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 +223 -43
- package/src/session/har-redact.ts +125 -0
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
|
|
@@ -36,6 +38,8 @@ export interface ParsedArgs {
|
|
|
36
38
|
into?: string;
|
|
37
39
|
noScreenshot?: boolean;
|
|
38
40
|
viewport?: string;
|
|
41
|
+
/** `page shot|measure snap --color-scheme dark|light` — transient media override. */
|
|
42
|
+
colorScheme?: string;
|
|
39
43
|
fullPage?: boolean;
|
|
40
44
|
help?: boolean;
|
|
41
45
|
/** `--all` — boolean scope-widener (per-leaf semantics). */
|
|
@@ -68,6 +72,8 @@ export interface ParsedArgs {
|
|
|
68
72
|
settleTimeout?: number;
|
|
69
73
|
/** `snap --capture-unsettled` — write full substrate despite non-settlement. */
|
|
70
74
|
captureUnsettled?: boolean;
|
|
75
|
+
/** `snap --skip-collector <name>` — repeatable; one entry per occurrence. */
|
|
76
|
+
skipCollector?: string[];
|
|
71
77
|
/** `snap|diff|mask --pixels` — include raster crop/diff facts. */
|
|
72
78
|
pixels?: boolean;
|
|
73
79
|
/** `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.' }),
|
package/src/session/commands.ts
CHANGED
|
@@ -8,6 +8,8 @@ import {
|
|
|
8
8
|
validateHarFile,
|
|
9
9
|
type HarFile,
|
|
10
10
|
type HAREntry,
|
|
11
|
+
type Header,
|
|
12
|
+
type IncompleteLifecycle,
|
|
11
13
|
} from '../har-manager.js';
|
|
12
14
|
import {
|
|
13
15
|
getActiveSession,
|
|
@@ -49,6 +51,7 @@ import {
|
|
|
49
51
|
} from './artifacts.js';
|
|
50
52
|
import { beginSessionStop, admitSessionOperation, withSessionLifecycle, withSessionScopeLifecycle } from './coordinator.js';
|
|
51
53
|
import { parseStatusFilter, type StatusPredicate } from './har-filter.js';
|
|
54
|
+
import { redactUrlCredentials } from './har-redact.js';
|
|
52
55
|
|
|
53
56
|
type Session = ActiveSessionState;
|
|
54
57
|
|
|
@@ -327,32 +330,40 @@ export async function waitForPageLoad(
|
|
|
327
330
|
|
|
328
331
|
/** Root-help representation of this branch, assembled by `src/capture.ts`. */
|
|
329
332
|
export const COMMAND_BLOCK = `<command name="session">
|
|
330
|
-
the artifact container — a session
|
|
333
|
+
the artifact container — a session records HAR and bundles every artifact; it may open a tab or adopt one later; while active, every command auto-targets its tab
|
|
331
334
|
use when starting scoped work against a page: start first, then every other capture command needs no --target/--port threading
|
|
332
335
|
start · stop · list · view · har · log — \`capture session -h\`
|
|
333
336
|
</command>`;
|
|
334
337
|
|
|
335
|
-
const START_USAGE = `capture session start [--url <url>] [--hold] — open a tab, record HAR, and set the active capture context.
|
|
338
|
+
const START_USAGE = `capture session start [--url <url>] [--hold] — open or prepare a tab, record HAR, and set the active capture context.
|
|
336
339
|
|
|
337
340
|
input:
|
|
338
|
-
--url <url> Absolute URL to open in a fresh tab
|
|
339
|
-
|
|
341
|
+
--url <url> Absolute URL to open in a fresh tab through CDP's
|
|
342
|
+
Target.createTarget. Some endpoints, including Electron,
|
|
343
|
+
expose existing tabs but reject Target.createTarget; on those
|
|
344
|
+
endpoints --url cannot be used.
|
|
340
345
|
--hold Hold one CDP browser connection open for the session's
|
|
341
346
|
lifetime, so browser-level state (permission grants,
|
|
342
347
|
ServiceWorker enablement) survives across separate commands.
|
|
343
|
-
With no --url,
|
|
344
|
-
|
|
348
|
+
With no --url, start a held session and adopt an existing tab
|
|
349
|
+
with this supported workflow:
|
|
350
|
+
capture session start --hold --port <port>
|
|
351
|
+
capture tab list --port <port>
|
|
352
|
+
capture page navigate <url> --target <target-id> --port <port>
|
|
353
|
+
The navigate command adopts that target for the session; later
|
|
354
|
+
page commands then auto-target it.
|
|
345
355
|
--port <n> CDP port to attach to; default is auto-detected across localhost.
|
|
346
356
|
|
|
347
357
|
Output:
|
|
348
358
|
One <session id=… path=… [url=…]> block: the session id, bundle dir, opened
|
|
349
|
-
tab, HAR recording id, and held-bridge pid when
|
|
350
|
-
the same fields.
|
|
359
|
+
tab when --url succeeds, HAR recording id, and held-bridge pid when
|
|
360
|
+
applicable. --json mirrors the same fields.
|
|
351
361
|
|
|
352
362
|
effects:
|
|
353
|
-
Creates a private session dir with shots/, starts a HAR recording, opens
|
|
354
|
-
tab
|
|
355
|
-
the active capture context so subsequent commands
|
|
363
|
+
Creates a private session dir with shots/, starts a HAR recording, opens a
|
|
364
|
+
fresh tab only when --url is supplied, optionally holds a CDP bridge, and
|
|
365
|
+
registers the session as the active capture context so subsequent commands
|
|
366
|
+
auto-target it.`;
|
|
356
367
|
|
|
357
368
|
const STOP_USAGE = `capture session stop <session-id> — finalize a session and write its bundle manifest.
|
|
358
369
|
|
|
@@ -400,30 +411,45 @@ Output:
|
|
|
400
411
|
effects:
|
|
401
412
|
None — reads bundle.json only.`;
|
|
402
413
|
|
|
414
|
+
/** Default row bound for one `session har` read. A busy page produces
|
|
415
|
+
* thousands of records; an unbounded list buries the row the agent came for.
|
|
416
|
+
* `--limit` overrides it in either direction. */
|
|
417
|
+
const DEFAULT_HAR_ROWS = 100;
|
|
418
|
+
|
|
403
419
|
const HAR_USAGE = `capture session har [<session-id>] — read a session's recorded HTTP traffic as a selection list.
|
|
404
420
|
|
|
405
421
|
input:
|
|
406
422
|
<session-id> session to read; defaults to the active session. A
|
|
407
423
|
running session reads its live accumulating HAR; a
|
|
408
424
|
stopped one reads the bundled har.json.
|
|
409
|
-
--filter-url <pattern> substring or regex match on the request URL
|
|
425
|
+
--filter-url <pattern> substring or regex match on the request URL (matched
|
|
426
|
+
against the URL as captured, before redaction)
|
|
410
427
|
--filter-status <spec> exact status (100-599), one-digit class prefix
|
|
411
428
|
(1-5, e.g. 4 = every 4xx), or ordered range
|
|
412
|
-
(e.g. 400-499); any other token is invalid_filter
|
|
429
|
+
(e.g. 400-499); any other token is invalid_filter.
|
|
430
|
+
A record with no observed response never matches.
|
|
413
431
|
--filter-method <method> HTTP method (GET, POST, …)
|
|
414
|
-
--limit <n>
|
|
415
|
-
--full inline per-
|
|
432
|
+
--limit <n> list at most n matching rows; default ${DEFAULT_HAR_ROWS}
|
|
433
|
+
--full inline per-record detail (headers, post data,
|
|
416
434
|
response body — escaped and capped); bodies are
|
|
417
435
|
never inlined without it
|
|
418
436
|
|
|
419
437
|
output:
|
|
420
|
-
One <session-har id=… path=… source=live|bundle entries=…
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
438
|
+
One <session-har id=… path=… source=live|bundle entries=… incomplete=…
|
|
439
|
+
total=… total-incomplete=… [truncated=true]> block listing BOTH populations
|
|
440
|
+
in one chronological selection: completed entries (method, status, URL, body
|
|
441
|
+
size, start time) and the incomplete lifecycle records the recorder retained
|
|
442
|
+
for requests that never completed — rendered as "incomplete: <kind>" (an
|
|
443
|
+
out-of-order clock also names its violation, e.g.
|
|
444
|
+
invalid_clock_order/terminal_before_response). The path attribute is the HAR
|
|
445
|
+
file's absolute path — the full-fidelity pointer, which holds every record
|
|
446
|
+
including the ones this list bounds away. --json mirrors the same fields.
|
|
447
|
+
Credential-like query parameter values (key, token, secret, signature, auth,
|
|
448
|
+
password, oauth code, …) render as REDACTED; the HAR file keeps the real
|
|
449
|
+
value. WebSockets opened while a command was recording appear as entries with
|
|
450
|
+
_resourceType "websocket" and their frames in _webSocketMessages (capped at
|
|
451
|
+
200 frames/socket, 4KB/frame); sockets opened before recording started are
|
|
452
|
+
not visible.
|
|
427
453
|
|
|
428
454
|
effects:
|
|
429
455
|
None — reads recorded HAR data only.`;
|
|
@@ -449,17 +475,17 @@ effects:
|
|
|
449
475
|
the identity-bearing tailer record is registered in the session metadata.`;
|
|
450
476
|
|
|
451
477
|
function printSessionHelp(): void {
|
|
452
|
-
console.log(`capture session — the artifact container:
|
|
478
|
+
console.log(`capture session — the artifact container: records HAR and bundles artifacts; it may open a tab or adopt one later.
|
|
453
479
|
|
|
454
480
|
An active session auto-targets its tab and auto-appends recorded traffic — no
|
|
455
481
|
--target/--har threading. \`stop\` finalizes the session and writes its bundle
|
|
456
482
|
manifest; \`view\` reads that manifest back.
|
|
457
483
|
|
|
458
|
-
<subcommand name="start" args="[--url <url>] [--hold]" whenToUse="open a tab, start HAR, and set the active capture context"/>
|
|
484
|
+
<subcommand name="start" args="[--url <url>] [--hold]" whenToUse="open or prepare a tab, start HAR, and set the active capture context"/>
|
|
459
485
|
<subcommand name="stop" args="<session-id>" whenToUse="finalize the session and write its bundle manifest"/>
|
|
460
486
|
<subcommand name="list" args="" whenToUse="show active and stopped sessions"/>
|
|
461
487
|
<subcommand name="view" args="<session-id> [--filter shots|har|logs|measure|motion|other]" whenToUse="read back a stopped session's bundle manifest"/>
|
|
462
|
-
<subcommand name="har" args="[<session-id>] [--filter-url <pattern>] [--filter-status <code>] [--filter-method <method>] [--limit <n>] [--full]" whenToUse="inspect recorded traffic — the live accumulating HAR of a running session or a stopped session's bundled har.json"/>
|
|
488
|
+
<subcommand name="har" args="[<session-id>] [--filter-url <pattern>] [--filter-status <code>] [--filter-method <method>] [--limit <n>] [--full]" whenToUse="inspect recorded traffic — completed entries and retained incomplete lifecycle records, from the live accumulating HAR of a running session or a stopped session's bundled har.json"/>
|
|
463
489
|
<subcommand name="log" args="<path> [--name <label>] [--session <id>]" whenToUse="tail an external log file into the session's logs/ dir"/>
|
|
464
490
|
|
|
465
491
|
capture session <leaf> -h Per-leaf usage`);
|
|
@@ -618,7 +644,18 @@ async function start(parsed: ParsedArgs): Promise<void> {
|
|
|
618
644
|
}
|
|
619
645
|
|
|
620
646
|
if (url) {
|
|
621
|
-
|
|
647
|
+
try {
|
|
648
|
+
target = await startWorld.openTab(cdpPort!, url);
|
|
649
|
+
} catch (error) {
|
|
650
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
651
|
+
if (message.toLowerCase().includes('not supported')) {
|
|
652
|
+
throw worldFailure(
|
|
653
|
+
`Target.createTarget is unsupported on port ${cdpPort}. Use existing tab: \`capture page navigate <url> --target <target-id> --port ${cdpPort}\` after \`capture session start --hold --port ${cdpPort}\` and \`capture tab list\`.`,
|
|
654
|
+
error,
|
|
655
|
+
);
|
|
656
|
+
}
|
|
657
|
+
throw error;
|
|
658
|
+
}
|
|
622
659
|
const openedPort = cdpPort!;
|
|
623
660
|
const openedTargetId = target.id;
|
|
624
661
|
acquired.push({ label: 'opened target', release: () => startWorld.closeTarget(openedPort, openedTargetId) });
|
|
@@ -887,6 +924,89 @@ async function locateSessionHar(session: Session): Promise<HarSource | { unavail
|
|
|
887
924
|
}
|
|
888
925
|
}
|
|
889
926
|
|
|
927
|
+
/**
|
|
928
|
+
* The `session har` selection population is BOTH of the HAR file's records:
|
|
929
|
+
* the completed entries under `log.entries` and the incomplete lifecycle
|
|
930
|
+
* records under `incompleteLifecycles`. A request whose lifecycle the recorder
|
|
931
|
+
* could not complete (the page was still in flight at session stop, or Chrome
|
|
932
|
+
* reported clocks out of order) is retained evidence, not lost traffic —
|
|
933
|
+
* rendering only `log.entries` hid it from every read, which is exactly when an
|
|
934
|
+
* agent is looking for the request that did not finish.
|
|
935
|
+
*/
|
|
936
|
+
type HarSelection = {
|
|
937
|
+
/** Original captured URL — what every filter matches against. */
|
|
938
|
+
url: string;
|
|
939
|
+
method: string;
|
|
940
|
+
/** Response status when one was observed; null when the record has no response. */
|
|
941
|
+
status: number | null;
|
|
942
|
+
startedDateTime: string;
|
|
943
|
+
/** Capture order, the deterministic tie-breaker for equal start times. */
|
|
944
|
+
order: number;
|
|
945
|
+
} & (
|
|
946
|
+
| { kind: 'complete'; entry: HAREntry }
|
|
947
|
+
| { kind: 'incomplete'; record: IncompleteLifecycle }
|
|
948
|
+
);
|
|
949
|
+
|
|
950
|
+
/** The response an incomplete record observed before it was cut short, if any. */
|
|
951
|
+
function incompleteResponse(record: IncompleteLifecycle): { status: number; headers: Header[] } | null {
|
|
952
|
+
switch (record.kind) {
|
|
953
|
+
case 'stopped_before_terminal':
|
|
954
|
+
return record._capture.response;
|
|
955
|
+
case 'invalid_clock_order':
|
|
956
|
+
return record.response;
|
|
957
|
+
case 'stopped_during_body':
|
|
958
|
+
return record.response;
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
/** The incomplete record's kind, qualified by the clock violation when it has one. */
|
|
963
|
+
function incompleteLabel(record: IncompleteLifecycle): string {
|
|
964
|
+
return record.kind === 'invalid_clock_order' ? `${record.kind}/${record.violation}` : record.kind;
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
/**
|
|
968
|
+
* Merges completed entries and incomplete lifecycle records into one
|
|
969
|
+
* chronologically ordered population. Ordering is deterministic for a given
|
|
970
|
+
* HAR file: start time first, then capture order (entries before incomplete
|
|
971
|
+
* records at an identical timestamp).
|
|
972
|
+
*/
|
|
973
|
+
function buildHarSelection(har: HarFile): HarSelection[] {
|
|
974
|
+
const items: HarSelection[] = [];
|
|
975
|
+
for (const entry of har.log.entries) {
|
|
976
|
+
items.push({
|
|
977
|
+
kind: 'complete',
|
|
978
|
+
entry,
|
|
979
|
+
url: entry.request.url,
|
|
980
|
+
method: entry.request.method,
|
|
981
|
+
status: entry.response.status,
|
|
982
|
+
startedDateTime: entry.startedDateTime,
|
|
983
|
+
order: items.length,
|
|
984
|
+
});
|
|
985
|
+
}
|
|
986
|
+
for (const record of har.incompleteLifecycles) {
|
|
987
|
+
items.push({
|
|
988
|
+
kind: 'incomplete',
|
|
989
|
+
record,
|
|
990
|
+
url: record.request.url,
|
|
991
|
+
method: record.request.method,
|
|
992
|
+
status: incompleteResponse(record)?.status ?? null,
|
|
993
|
+
startedDateTime: record.startedDateTime,
|
|
994
|
+
order: items.length,
|
|
995
|
+
});
|
|
996
|
+
}
|
|
997
|
+
return items.sort((a, b) =>
|
|
998
|
+
a.startedDateTime === b.startedDateTime
|
|
999
|
+
? a.order - b.order
|
|
1000
|
+
: (a.startedDateTime < b.startedDateTime ? -1 : 1),
|
|
1001
|
+
);
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
/** Every rendered URL passes through render-time credential redaction; the HAR
|
|
1005
|
+
* artifact and the `--filter-url` match target keep the original value. */
|
|
1006
|
+
function harUrl(url: string): FactLine {
|
|
1007
|
+
return data(redactUrlCredentials(url), 300);
|
|
1008
|
+
}
|
|
1009
|
+
|
|
890
1010
|
/** One selection-list row: method, status, URL, body size, start time. Body
|
|
891
1011
|
* content is NEVER inlined here (I-7) — `--full` is the only opt-in. */
|
|
892
1012
|
function harEntryRow(e: HAREntry): FactLine {
|
|
@@ -896,13 +1016,27 @@ function harEntryRow(e: HAREntry): FactLine {
|
|
|
896
1016
|
: text`body not captured`;
|
|
897
1017
|
return line(
|
|
898
1018
|
fact`${e.request.method} ${e.response.status} `,
|
|
899
|
-
|
|
1019
|
+
harUrl(e.request.url),
|
|
900
1020
|
text` — `,
|
|
901
1021
|
sizePart,
|
|
902
1022
|
fact` — started ${e.startedDateTime}`,
|
|
903
1023
|
);
|
|
904
1024
|
}
|
|
905
1025
|
|
|
1026
|
+
/** One selection-list row for an incomplete lifecycle record: it names the
|
|
1027
|
+
* kind (and clock violation) in place of the body size a completed entry
|
|
1028
|
+
* reports, so a row is never mistaken for finished traffic. */
|
|
1029
|
+
function harIncompleteRow(record: IncompleteLifecycle): FactLine {
|
|
1030
|
+
const response = incompleteResponse(record);
|
|
1031
|
+
return line(
|
|
1032
|
+
fact`${record.request.method} ${response ? response.status : 'no-response'} `,
|
|
1033
|
+
harUrl(record.request.url),
|
|
1034
|
+
text` — `,
|
|
1035
|
+
fact`incomplete: ${incompleteLabel(record)}`,
|
|
1036
|
+
fact` — started ${record.startedDateTime}`,
|
|
1037
|
+
);
|
|
1038
|
+
}
|
|
1039
|
+
|
|
906
1040
|
/** `--full` inline detail for one entry: headers, post data, and response
|
|
907
1041
|
* body — every value escaped and capped through data()/fact. */
|
|
908
1042
|
function harEntryDetail(e: HAREntry, index: number): FactLine {
|
|
@@ -925,6 +1059,33 @@ function harEntryDetail(e: HAREntry, index: number): FactLine {
|
|
|
925
1059
|
return lineList(rows);
|
|
926
1060
|
}
|
|
927
1061
|
|
|
1062
|
+
/** `--full` inline detail for one incomplete lifecycle record: the same
|
|
1063
|
+
* headers/post data a completed entry shows, plus the provenance that explains
|
|
1064
|
+
* why the lifecycle never completed. */
|
|
1065
|
+
function harIncompleteDetail(record: IncompleteLifecycle, index: number): FactLine {
|
|
1066
|
+
const rows: FactLine[] = [line(fact`${index + 1}. `, harIncompleteRow(record))];
|
|
1067
|
+
rows.push(fact` request ${record.requestId} (generation ${record.generation})`);
|
|
1068
|
+
for (const h of record.request.headers ?? []) {
|
|
1069
|
+
rows.push(fact` req ${h.name}: ${h.value}`);
|
|
1070
|
+
}
|
|
1071
|
+
if (record.request.postData?.text !== undefined) {
|
|
1072
|
+
rows.push(fact` post data: ${capped(record.request.postData.text, 2000)}`);
|
|
1073
|
+
}
|
|
1074
|
+
const response = incompleteResponse(record);
|
|
1075
|
+
for (const h of response?.headers ?? []) {
|
|
1076
|
+
rows.push(fact` res ${h.name}: ${h.value}`);
|
|
1077
|
+
}
|
|
1078
|
+
if (record.kind === 'invalid_clock_order') {
|
|
1079
|
+
rows.push(fact` terminal: ${record.terminal.kind}`);
|
|
1080
|
+
if (record.terminal.kind === 'failed') {
|
|
1081
|
+
rows.push(fact` error: ${record.terminal.errorText} (canceled=${String(record.terminal.canceled)})`);
|
|
1082
|
+
}
|
|
1083
|
+
} else {
|
|
1084
|
+
rows.push(text` terminal: never observed`);
|
|
1085
|
+
}
|
|
1086
|
+
return lineList(rows);
|
|
1087
|
+
}
|
|
1088
|
+
|
|
928
1089
|
async function har(parsed: ParsedArgs): Promise<void> {
|
|
929
1090
|
if (parsed.help) {
|
|
930
1091
|
console.log(HAR_USAGE);
|
|
@@ -993,41 +1154,57 @@ async function har(parsed: ParsedArgs): Promise<void> {
|
|
|
993
1154
|
return;
|
|
994
1155
|
}
|
|
995
1156
|
|
|
996
|
-
let
|
|
997
|
-
const total = entries.length;
|
|
1157
|
+
let selection = buildHarSelection(located.har);
|
|
1158
|
+
const total = located.har.log.entries.length;
|
|
1159
|
+
const totalIncomplete = located.har.incompleteLifecycles.length;
|
|
998
1160
|
const filters: string[] = [];
|
|
999
1161
|
|
|
1162
|
+
// Every filter matches the ORIGINAL captured URL/method/status — redaction is
|
|
1163
|
+
// applied at render time only, so a `--filter-url` pattern still selects on
|
|
1164
|
+
// what the browser actually requested.
|
|
1000
1165
|
if (parsed.filterUrl) {
|
|
1001
1166
|
const pattern = parsed.filterUrl;
|
|
1002
1167
|
let re: RegExp | null = null;
|
|
1003
1168
|
try { re = new RegExp(pattern, 'i'); } catch { re = null; }
|
|
1004
|
-
|
|
1005
|
-
re ? re.test(
|
|
1169
|
+
selection = selection.filter((item) =>
|
|
1170
|
+
re ? re.test(item.url) : item.url.toLowerCase().includes(pattern.toLowerCase()),
|
|
1006
1171
|
);
|
|
1007
1172
|
filters.push(`url~${pattern}`);
|
|
1008
1173
|
}
|
|
1009
1174
|
if (statusMatch) {
|
|
1010
1175
|
const matches = statusMatch;
|
|
1011
|
-
|
|
1176
|
+
// A record with no observed response cannot satisfy a status filter.
|
|
1177
|
+
selection = selection.filter((item) => item.status !== null && matches(item.status));
|
|
1012
1178
|
filters.push(`status=${parsed.filterStatus}`);
|
|
1013
1179
|
}
|
|
1014
1180
|
if (parsed.filterMethod) {
|
|
1015
1181
|
const m = parsed.filterMethod.toUpperCase();
|
|
1016
|
-
|
|
1182
|
+
selection = selection.filter((item) => item.method.toUpperCase() === m);
|
|
1017
1183
|
filters.push(`method=${m}`);
|
|
1018
1184
|
}
|
|
1019
|
-
if (typeof parsed.limit === 'number' && parsed.limit > 0) {
|
|
1020
|
-
entries = entries.slice(0, parsed.limit);
|
|
1021
|
-
filters.push(`limit=${parsed.limit}`);
|
|
1022
|
-
}
|
|
1023
1185
|
|
|
1024
|
-
const
|
|
1025
|
-
|
|
1026
|
-
|
|
1186
|
+
const requestedLimit = typeof parsed.limit === 'number' && parsed.limit > 0 ? parsed.limit : null;
|
|
1187
|
+
if (requestedLimit !== null) filters.push(`limit=${requestedLimit}`);
|
|
1188
|
+
const bound = requestedLimit ?? DEFAULT_HAR_ROWS;
|
|
1189
|
+
const matched = selection.length;
|
|
1190
|
+
const shown = selection.slice(0, bound);
|
|
1191
|
+
const truncated = matched > shown.length;
|
|
1192
|
+
|
|
1193
|
+
const shownEntries = shown.filter((item) => item.kind === 'complete').length;
|
|
1194
|
+
const shownIncomplete = shown.length - shownEntries;
|
|
1195
|
+
|
|
1196
|
+
const filterNote = filters.length > 0 ? ` (${filters.join(', ')})` : '';
|
|
1197
|
+
const truncationNote = truncated
|
|
1198
|
+
? ` ${matched - shown.length} further matching rows are not listed — raise the bound with --limit.`
|
|
1199
|
+
: '';
|
|
1200
|
+
const summary = fact`${shownEntries} of ${total} complete entries and ${shownIncomplete} of ${totalIncomplete} incomplete lifecycle records listed${filterNote}.${truncationNote}`;
|
|
1027
1201
|
|
|
1028
1202
|
const sections = parsed.full
|
|
1029
|
-
?
|
|
1030
|
-
: [lineList(
|
|
1203
|
+
? shown.map((item, i) => (item.kind === 'complete' ? harEntryDetail(item.entry, i) : harIncompleteDetail(item.record, i)))
|
|
1204
|
+
: [lineList(shown.map((item, i) => line(
|
|
1205
|
+
fact`${i + 1}. `,
|
|
1206
|
+
item.kind === 'complete' ? harEntryRow(item.entry) : harIncompleteRow(item.record),
|
|
1207
|
+
)))];
|
|
1031
1208
|
|
|
1032
1209
|
emitResult({
|
|
1033
1210
|
tag: 'session-har',
|
|
@@ -1035,8 +1212,11 @@ async function har(parsed: ParsedArgs): Promise<void> {
|
|
|
1035
1212
|
id,
|
|
1036
1213
|
path: located.path,
|
|
1037
1214
|
source: located.source,
|
|
1038
|
-
entries:
|
|
1215
|
+
entries: shownEntries,
|
|
1216
|
+
incomplete: shownIncomplete,
|
|
1039
1217
|
total,
|
|
1218
|
+
'total-incomplete': totalIncomplete,
|
|
1219
|
+
truncated: truncated ? true : undefined,
|
|
1040
1220
|
},
|
|
1041
1221
|
summary,
|
|
1042
1222
|
sections,
|