@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/session/commands.ts
CHANGED
|
@@ -8,9 +8,12 @@ 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,
|
|
16
|
+
activeSessionScopeKey,
|
|
14
17
|
setActiveSession,
|
|
15
18
|
clearActiveSessionIf,
|
|
16
19
|
updateSessionState,
|
|
@@ -48,6 +51,7 @@ import {
|
|
|
48
51
|
} from './artifacts.js';
|
|
49
52
|
import { beginSessionStop, admitSessionOperation, withSessionLifecycle, withSessionScopeLifecycle } from './coordinator.js';
|
|
50
53
|
import { parseStatusFilter, type StatusPredicate } from './har-filter.js';
|
|
54
|
+
import { redactUrlCredentials } from './har-redact.js';
|
|
51
55
|
|
|
52
56
|
type Session = ActiveSessionState;
|
|
53
57
|
|
|
@@ -235,10 +239,38 @@ function sessionMetaPath(id: string): string {
|
|
|
235
239
|
return path.join(sessionDir(id), '.session.json');
|
|
236
240
|
}
|
|
237
241
|
|
|
242
|
+
/** Outcome of reading one persisted record without failing the caller: either
|
|
243
|
+
* the validated session or the short reason its `.session.json` is unreadable. */
|
|
244
|
+
type SessionRead =
|
|
245
|
+
| { ok: true; session: Session }
|
|
246
|
+
| { ok: false; id: string; metaPath: string; reason: string };
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Reads one record without throwing. A listing is what an agent reaches for
|
|
250
|
+
* precisely when it is working out what state it is in, so one corrupt or
|
|
251
|
+
* legacy-schema record on a shared capture root must not make every other
|
|
252
|
+
* session invisible — `list` renders the reason as its own unreadable row.
|
|
253
|
+
* A missing file is not a read outcome here; only `readSession` (targeted
|
|
254
|
+
* commands) turns that into `unknown_session`.
|
|
255
|
+
*/
|
|
256
|
+
function tryReadSession(id: string): SessionRead {
|
|
257
|
+
const metaPath = sessionMetaPath(id);
|
|
258
|
+
const bad = (reason: string): SessionRead => ({ ok: false, id, metaPath, reason });
|
|
259
|
+
let parsed: unknown;
|
|
260
|
+
try {
|
|
261
|
+
parsed = JSON.parse(readPrivateFile(metaPath).toString('utf-8'));
|
|
262
|
+
} catch (error) {
|
|
263
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') throw error;
|
|
264
|
+
return bad('not valid JSON; the record is corrupt');
|
|
265
|
+
}
|
|
266
|
+
if (!isActiveStateCandidate(parsed)) return bad('does not match the session record schema; written by an incompatible version or corrupted');
|
|
267
|
+
return { ok: true, session: parsed };
|
|
268
|
+
}
|
|
269
|
+
|
|
238
270
|
/**
|
|
239
|
-
* Reads and VALIDATES one persisted `.session.json
|
|
240
|
-
*
|
|
241
|
-
*
|
|
271
|
+
* Reads and VALIDATES one persisted `.session.json` for a command that named
|
|
272
|
+
* this session. A missing file throws a typed `unknown_session` precondition
|
|
273
|
+
* error; unparsable bytes or a record failing the shared
|
|
242
274
|
* `isActiveStateCandidate` schema throw a typed `invalid_session_record`
|
|
243
275
|
* artifact error instead of letting a structurally-trusted cast reach the
|
|
244
276
|
* renderer (where a legacy-schema record used to crash `session list`
|
|
@@ -246,23 +278,15 @@ function sessionMetaPath(id: string): string {
|
|
|
246
278
|
*/
|
|
247
279
|
function readSession(id: string): Session {
|
|
248
280
|
const metaPath = sessionMetaPath(id);
|
|
249
|
-
let
|
|
281
|
+
let read: SessionRead;
|
|
250
282
|
try {
|
|
251
|
-
|
|
283
|
+
read = tryReadSession(id);
|
|
252
284
|
} catch (error) {
|
|
253
285
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') throw captureError('precondition', 'unknown_session', `No capture session found: ${id}`);
|
|
254
286
|
throw error;
|
|
255
287
|
}
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
parsed = JSON.parse(raw);
|
|
259
|
-
} catch (cause) {
|
|
260
|
-
throw captureError('artifact', 'invalid_session_record', `session metadata at ${metaPath} is not valid JSON; the record is corrupt and cannot be read as a capture session.`, cause);
|
|
261
|
-
}
|
|
262
|
-
if (!isActiveStateCandidate(parsed)) {
|
|
263
|
-
throw captureError('artifact', 'invalid_session_record', `session metadata at ${metaPath} does not match the capture session record schema; it was written by an incompatible version or corrupted.`);
|
|
264
|
-
}
|
|
265
|
-
return parsed;
|
|
288
|
+
if (!read.ok) throw captureError('artifact', 'invalid_session_record', `session metadata at ${metaPath} ${read.reason}.`);
|
|
289
|
+
return read.session;
|
|
266
290
|
}
|
|
267
291
|
|
|
268
292
|
function generateId(): string {
|
|
@@ -306,32 +330,40 @@ export async function waitForPageLoad(
|
|
|
306
330
|
|
|
307
331
|
/** Root-help representation of this branch, assembled by `src/capture.ts`. */
|
|
308
332
|
export const COMMAND_BLOCK = `<command name="session">
|
|
309
|
-
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
|
|
310
334
|
use when starting scoped work against a page: start first, then every other capture command needs no --target/--port threading
|
|
311
335
|
start · stop · list · view · har · log — \`capture session -h\`
|
|
312
336
|
</command>`;
|
|
313
337
|
|
|
314
|
-
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.
|
|
315
339
|
|
|
316
340
|
input:
|
|
317
|
-
--url <url> Absolute URL to open in a fresh tab
|
|
318
|
-
|
|
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.
|
|
319
345
|
--hold Hold one CDP browser connection open for the session's
|
|
320
346
|
lifetime, so browser-level state (permission grants,
|
|
321
347
|
ServiceWorker enablement) survives across separate commands.
|
|
322
|
-
With no --url,
|
|
323
|
-
|
|
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.
|
|
324
355
|
--port <n> CDP port to attach to; default is auto-detected across localhost.
|
|
325
356
|
|
|
326
357
|
Output:
|
|
327
358
|
One <session id=… path=… [url=…]> block: the session id, bundle dir, opened
|
|
328
|
-
tab, HAR recording id, and held-bridge pid when
|
|
329
|
-
the same fields.
|
|
359
|
+
tab when --url succeeds, HAR recording id, and held-bridge pid when
|
|
360
|
+
applicable. --json mirrors the same fields.
|
|
330
361
|
|
|
331
362
|
effects:
|
|
332
|
-
Creates a private session dir with shots/, starts a HAR recording, opens
|
|
333
|
-
tab
|
|
334
|
-
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.`;
|
|
335
367
|
|
|
336
368
|
const STOP_USAGE = `capture session stop <session-id> — finalize a session and write its bundle manifest.
|
|
337
369
|
|
|
@@ -354,8 +386,11 @@ input:
|
|
|
354
386
|
(none)
|
|
355
387
|
|
|
356
388
|
output:
|
|
357
|
-
One <sessions count=…> block, one row per session: id, status
|
|
389
|
+
One <sessions count=… unreadable=…> block, one row per session: id, status
|
|
358
390
|
(active|stopped), start time, and URL when set. --json mirrors the same rows.
|
|
391
|
+
A record that is corrupt or written by an incompatible version lists as its
|
|
392
|
+
own "<id> — unreadable — <path> — <reason>" row and is excluded from count;
|
|
393
|
+
one bad record never hides the others.
|
|
359
394
|
|
|
360
395
|
effects:
|
|
361
396
|
None — reads session metadata only.`;
|
|
@@ -376,30 +411,45 @@ Output:
|
|
|
376
411
|
effects:
|
|
377
412
|
None — reads bundle.json only.`;
|
|
378
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
|
+
|
|
379
419
|
const HAR_USAGE = `capture session har [<session-id>] — read a session's recorded HTTP traffic as a selection list.
|
|
380
420
|
|
|
381
421
|
input:
|
|
382
422
|
<session-id> session to read; defaults to the active session. A
|
|
383
423
|
running session reads its live accumulating HAR; a
|
|
384
424
|
stopped one reads the bundled har.json.
|
|
385
|
-
--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)
|
|
386
427
|
--filter-status <spec> exact status (100-599), one-digit class prefix
|
|
387
428
|
(1-5, e.g. 4 = every 4xx), or ordered range
|
|
388
|
-
(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.
|
|
389
431
|
--filter-method <method> HTTP method (GET, POST, …)
|
|
390
|
-
--limit <n>
|
|
391
|
-
--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,
|
|
392
434
|
response body — escaped and capped); bodies are
|
|
393
435
|
never inlined without it
|
|
394
436
|
|
|
395
437
|
output:
|
|
396
|
-
One <session-har id=… path=… source=live|bundle entries=…
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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.
|
|
403
453
|
|
|
404
454
|
effects:
|
|
405
455
|
None — reads recorded HAR data only.`;
|
|
@@ -425,17 +475,17 @@ effects:
|
|
|
425
475
|
the identity-bearing tailer record is registered in the session metadata.`;
|
|
426
476
|
|
|
427
477
|
function printSessionHelp(): void {
|
|
428
|
-
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.
|
|
429
479
|
|
|
430
480
|
An active session auto-targets its tab and auto-appends recorded traffic — no
|
|
431
481
|
--target/--har threading. \`stop\` finalizes the session and writes its bundle
|
|
432
482
|
manifest; \`view\` reads that manifest back.
|
|
433
483
|
|
|
434
|
-
<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"/>
|
|
435
485
|
<subcommand name="stop" args="<session-id>" whenToUse="finalize the session and write its bundle manifest"/>
|
|
436
486
|
<subcommand name="list" args="" whenToUse="show active and stopped sessions"/>
|
|
437
487
|
<subcommand name="view" args="<session-id> [--filter shots|har|logs|measure|motion|other]" whenToUse="read back a stopped session's bundle manifest"/>
|
|
438
|
-
<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"/>
|
|
439
489
|
<subcommand name="log" args="<path> [--name <label>] [--session <id>]" whenToUse="tail an external log file into the session's logs/ dir"/>
|
|
440
490
|
|
|
441
491
|
capture session <leaf> -h Per-leaf usage`);
|
|
@@ -557,9 +607,9 @@ async function start(parsed: ParsedArgs): Promise<void> {
|
|
|
557
607
|
if (active && !isSessionStopped(active)) {
|
|
558
608
|
emitResult({
|
|
559
609
|
tag: 'error',
|
|
560
|
-
attrs: { command: 'session start', code: '
|
|
561
|
-
summary: fact`
|
|
562
|
-
followUp:
|
|
610
|
+
attrs: { command: 'session start', code: 'session_already_active' },
|
|
611
|
+
summary: fact`Session ${active.sessionId} is already live in this capture scope (${activeSessionScopeKey()}); one live session per scope.`,
|
|
612
|
+
followUp: fact`Scopes are per-agent (CRTR_NODE_ID), so this session is yours — a concurrent agent's session never blocks yours. Keep using it (every command auto-targets its tab), or run \`capture session stop ${active.sessionId}\` to start fresh. To drive a different tab without ending it, pass \`--target <target-id>\` from \`capture tab list\`.`,
|
|
563
613
|
}, { json: parsed.json });
|
|
564
614
|
process.exitCode = 1;
|
|
565
615
|
return;
|
|
@@ -594,7 +644,18 @@ async function start(parsed: ParsedArgs): Promise<void> {
|
|
|
594
644
|
}
|
|
595
645
|
|
|
596
646
|
if (url) {
|
|
597
|
-
|
|
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
|
+
}
|
|
598
659
|
const openedPort = cdpPort!;
|
|
599
660
|
const openedTargetId = target.id;
|
|
600
661
|
acquired.push({ label: 'opened target', release: () => startWorld.closeTarget(openedPort, openedTargetId) });
|
|
@@ -863,6 +924,89 @@ async function locateSessionHar(session: Session): Promise<HarSource | { unavail
|
|
|
863
924
|
}
|
|
864
925
|
}
|
|
865
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
|
+
|
|
866
1010
|
/** One selection-list row: method, status, URL, body size, start time. Body
|
|
867
1011
|
* content is NEVER inlined here (I-7) — `--full` is the only opt-in. */
|
|
868
1012
|
function harEntryRow(e: HAREntry): FactLine {
|
|
@@ -872,13 +1016,27 @@ function harEntryRow(e: HAREntry): FactLine {
|
|
|
872
1016
|
: text`body not captured`;
|
|
873
1017
|
return line(
|
|
874
1018
|
fact`${e.request.method} ${e.response.status} `,
|
|
875
|
-
|
|
1019
|
+
harUrl(e.request.url),
|
|
876
1020
|
text` — `,
|
|
877
1021
|
sizePart,
|
|
878
1022
|
fact` — started ${e.startedDateTime}`,
|
|
879
1023
|
);
|
|
880
1024
|
}
|
|
881
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
|
+
|
|
882
1040
|
/** `--full` inline detail for one entry: headers, post data, and response
|
|
883
1041
|
* body — every value escaped and capped through data()/fact. */
|
|
884
1042
|
function harEntryDetail(e: HAREntry, index: number): FactLine {
|
|
@@ -901,6 +1059,33 @@ function harEntryDetail(e: HAREntry, index: number): FactLine {
|
|
|
901
1059
|
return lineList(rows);
|
|
902
1060
|
}
|
|
903
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
|
+
|
|
904
1089
|
async function har(parsed: ParsedArgs): Promise<void> {
|
|
905
1090
|
if (parsed.help) {
|
|
906
1091
|
console.log(HAR_USAGE);
|
|
@@ -969,41 +1154,57 @@ async function har(parsed: ParsedArgs): Promise<void> {
|
|
|
969
1154
|
return;
|
|
970
1155
|
}
|
|
971
1156
|
|
|
972
|
-
let
|
|
973
|
-
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;
|
|
974
1160
|
const filters: string[] = [];
|
|
975
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.
|
|
976
1165
|
if (parsed.filterUrl) {
|
|
977
1166
|
const pattern = parsed.filterUrl;
|
|
978
1167
|
let re: RegExp | null = null;
|
|
979
1168
|
try { re = new RegExp(pattern, 'i'); } catch { re = null; }
|
|
980
|
-
|
|
981
|
-
re ? re.test(
|
|
1169
|
+
selection = selection.filter((item) =>
|
|
1170
|
+
re ? re.test(item.url) : item.url.toLowerCase().includes(pattern.toLowerCase()),
|
|
982
1171
|
);
|
|
983
1172
|
filters.push(`url~${pattern}`);
|
|
984
1173
|
}
|
|
985
1174
|
if (statusMatch) {
|
|
986
1175
|
const matches = statusMatch;
|
|
987
|
-
|
|
1176
|
+
// A record with no observed response cannot satisfy a status filter.
|
|
1177
|
+
selection = selection.filter((item) => item.status !== null && matches(item.status));
|
|
988
1178
|
filters.push(`status=${parsed.filterStatus}`);
|
|
989
1179
|
}
|
|
990
1180
|
if (parsed.filterMethod) {
|
|
991
1181
|
const m = parsed.filterMethod.toUpperCase();
|
|
992
|
-
|
|
1182
|
+
selection = selection.filter((item) => item.method.toUpperCase() === m);
|
|
993
1183
|
filters.push(`method=${m}`);
|
|
994
1184
|
}
|
|
995
|
-
if (typeof parsed.limit === 'number' && parsed.limit > 0) {
|
|
996
|
-
entries = entries.slice(0, parsed.limit);
|
|
997
|
-
filters.push(`limit=${parsed.limit}`);
|
|
998
|
-
}
|
|
999
1185
|
|
|
1000
|
-
const
|
|
1001
|
-
|
|
1002
|
-
|
|
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}`;
|
|
1003
1201
|
|
|
1004
1202
|
const sections = parsed.full
|
|
1005
|
-
?
|
|
1006
|
-
: [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
|
+
)))];
|
|
1007
1208
|
|
|
1008
1209
|
emitResult({
|
|
1009
1210
|
tag: 'session-har',
|
|
@@ -1011,8 +1212,11 @@ async function har(parsed: ParsedArgs): Promise<void> {
|
|
|
1011
1212
|
id,
|
|
1012
1213
|
path: located.path,
|
|
1013
1214
|
source: located.source,
|
|
1014
|
-
entries:
|
|
1215
|
+
entries: shownEntries,
|
|
1216
|
+
incomplete: shownIncomplete,
|
|
1015
1217
|
total,
|
|
1218
|
+
'total-incomplete': totalIncomplete,
|
|
1219
|
+
truncated: truncated ? true : undefined,
|
|
1016
1220
|
},
|
|
1017
1221
|
summary,
|
|
1018
1222
|
sections,
|
|
@@ -1212,17 +1416,24 @@ function list(parsed: ParsedArgs): void {
|
|
|
1212
1416
|
|
|
1213
1417
|
if (rejectSurplusPositionals(parsed, 'list', 0, 'capture session list')) return;
|
|
1214
1418
|
|
|
1215
|
-
const
|
|
1419
|
+
const reads = fs.existsSync(CAPTURE_ROOT)
|
|
1216
1420
|
? fs.readdirSync(CAPTURE_ROOT)
|
|
1217
1421
|
.filter((d) => fs.existsSync(sessionMetaPath(d)))
|
|
1218
|
-
.
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
return { id: session.sessionId, startedAt: session.startedAt ?? '', url: session.url ?? null, status: hasBundled ? 'stopped' : 'active' };
|
|
1422
|
+
.flatMap((d) => {
|
|
1423
|
+
// A record deleted between readdir and read is simply gone, not unreadable.
|
|
1424
|
+
try { return [tryReadSession(d)]; } catch { return []; }
|
|
1222
1425
|
})
|
|
1223
1426
|
: [];
|
|
1224
1427
|
|
|
1225
|
-
|
|
1428
|
+
const sessions = reads.flatMap((read) => {
|
|
1429
|
+
if (!read.ok) return [];
|
|
1430
|
+
const session = read.session;
|
|
1431
|
+
const hasBundled = fs.existsSync(path.join(session.dir, 'bundle.json'));
|
|
1432
|
+
return [{ id: session.sessionId, startedAt: session.startedAt ?? '', url: session.url ?? null, status: hasBundled ? 'stopped' : 'active' }];
|
|
1433
|
+
});
|
|
1434
|
+
const unreadable = reads.flatMap((read) => (read.ok ? [] : [read]));
|
|
1435
|
+
|
|
1436
|
+
if (reads.length === 0) {
|
|
1226
1437
|
emitResult({
|
|
1227
1438
|
tag: 'sessions',
|
|
1228
1439
|
attrs: { count: 0 },
|
|
@@ -1237,11 +1448,17 @@ function list(parsed: ParsedArgs): void {
|
|
|
1237
1448
|
s.url ? fact` — ${s.url}` : text``,
|
|
1238
1449
|
),
|
|
1239
1450
|
);
|
|
1451
|
+
const unreadableRows = unreadable.map((u) =>
|
|
1452
|
+
line(fact`${u.id} — unreadable — ${u.metaPath} — ${u.reason}`),
|
|
1453
|
+
);
|
|
1240
1454
|
|
|
1241
1455
|
emitResult({
|
|
1242
1456
|
tag: 'sessions',
|
|
1243
|
-
attrs: { count: sessions.length },
|
|
1244
|
-
sections: [lineList(rows)],
|
|
1457
|
+
attrs: { count: sessions.length, unreadable: unreadable.length },
|
|
1458
|
+
sections: [lineList([...rows, ...unreadableRows])],
|
|
1459
|
+
...(unreadable.length > 0
|
|
1460
|
+
? { followUp: text`Unreadable records are corrupt or written by an incompatible version; they are inert. Delete the named .session.json (or its session dir) to drop the row.` }
|
|
1461
|
+
: {}),
|
|
1245
1462
|
}, { json: parsed.json });
|
|
1246
1463
|
}
|
|
1247
1464
|
|