@crouton-kit/capture 1.4.2 → 1.4.10
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/README.md +178 -0
- package/bin/capture +31278 -7312
- package/package.json +3 -1
- package/src/capture.ts +21 -18
- package/src/cdp/args.ts +269 -49
- package/src/cdp/bridge/server.ts +6 -5
- package/src/cdp/bridge/spawn.ts +41 -97
- package/src/cdp/commands/bridge-serve.ts +11 -21
- package/src/cdp/commands/cdp.ts +44 -22
- package/src/cdp/commands/heap/census.ts +61 -0
- package/src/cdp/commands/heap/common.ts +77 -0
- package/src/cdp/commands/heap/diff.ts +92 -0
- package/src/cdp/commands/heap/index.ts +38 -0
- package/src/cdp/commands/heap/objects.ts +42 -0
- package/src/cdp/commands/heap/retainers.ts +52 -0
- package/src/cdp/commands/heap/snapshot.ts +94 -0
- package/src/cdp/commands/lib.ts +9 -17
- package/src/cdp/commands/lighthouse.ts +414 -0
- package/src/cdp/commands/measure/census.ts +2 -1
- package/src/cdp/commands/measure/check.ts +70 -16
- package/src/cdp/commands/measure/diff.ts +1 -2
- package/src/cdp/commands/measure/explain.ts +44 -7
- package/src/cdp/commands/measure/index.ts +24 -32
- package/src/cdp/commands/measure/map-ax.ts +2 -1
- package/src/cdp/commands/measure/map-focus.ts +2 -1
- package/src/cdp/commands/measure/map-layers.ts +5 -3
- package/src/cdp/commands/measure/map-scroll.ts +2 -1
- package/src/cdp/commands/measure/snap.ts +5 -4
- package/src/cdp/commands/measure/sweep.ts +16 -11
- package/src/cdp/commands/measure/text.ts +147 -0
- package/src/cdp/commands/motion/index.ts +10 -18
- package/src/cdp/commands/motion/jank.ts +13 -22
- package/src/cdp/commands/motion/mask.ts +10 -9
- package/src/cdp/commands/motion/rec.ts +228 -26
- package/src/cdp/commands/motion/timeline.ts +1 -1
- package/src/cdp/commands/page/click.ts +15 -5
- package/src/cdp/commands/page/elements.ts +43 -11
- package/src/cdp/commands/page/exec.ts +53 -18
- package/src/cdp/commands/page/index.ts +20 -20
- package/src/cdp/commands/page/inspect.ts +338 -0
- package/src/cdp/commands/page/navigate.ts +83 -38
- package/src/cdp/commands/page/repeat.ts +176 -0
- package/src/cdp/commands/page/scroll.ts +10 -5
- package/src/cdp/commands/page/shot.ts +66 -31
- package/src/cdp/commands/page/type.ts +6 -2
- package/src/cdp/commands/perf/index.ts +32 -0
- package/src/cdp/commands/perf/insights.ts +214 -0
- package/src/cdp/commands/perf/trace.ts +235 -0
- package/src/cdp/commands/perf/vitals.ts +76 -0
- package/src/cdp/commands/session/collectors.ts +54 -0
- package/src/cdp/commands/stub.ts +10 -0
- package/src/cdp/commands/tab/close.ts +2 -2
- package/src/cdp/commands/tab/index.ts +16 -23
- package/src/cdp/commands/tab/launch.ts +1 -1
- package/src/cdp/commands/tab/mock/index.ts +24 -0
- package/src/cdp/commands/tab/mock/lifecycle.ts +88 -0
- package/src/cdp/commands/tab/mock/start.ts +65 -0
- package/src/cdp/commands/tab/mock/stop.ts +39 -0
- package/src/cdp/commands/tab/network.ts +22 -13
- package/src/cdp/commands/tab/reset.ts +11 -12
- package/src/cdp/connection.ts +19 -39
- package/src/cdp/detect.ts +27 -7
- package/src/cdp/dispatch.ts +25 -4
- package/src/cdp/domains.ts +2 -2
- package/src/cdp/har-recorder.ts +0 -1
- package/src/cdp/heap-snapshot.ts +647 -0
- package/src/cdp/host/client.ts +22 -0
- package/src/cdp/host/collector.ts +74 -0
- package/src/cdp/host/collectors/heap.ts +79 -0
- package/src/cdp/host/collectors/intercept.ts +247 -0
- package/src/cdp/{recorder-bridge.ts → host/collectors/motion.ts} +388 -473
- package/src/cdp/host/collectors/trace.ts +108 -0
- package/src/cdp/host/core.ts +217 -0
- package/src/cdp/host/handle.ts +47 -0
- package/src/cdp/host/held-client.ts +91 -0
- package/src/cdp/host/kinds.ts +97 -0
- package/src/cdp/host/lifecycle.ts +123 -0
- package/src/cdp/host/server.ts +88 -0
- package/src/cdp/measure/check.ts +181 -16
- package/src/cdp/measure/collectors/hittest.ts +16 -10
- package/src/cdp/measure/collectors/scroll.ts +15 -7
- package/src/cdp/measure/collectors/style-provenance.ts +11 -0
- package/src/cdp/measure/collectors/styles.ts +4 -4
- package/src/cdp/measure/explain.ts +14 -2
- package/src/cdp/measure/map-layers.ts +52 -8
- package/src/cdp/measure/snapshot.ts +5 -3
- package/src/cdp/measure/sweep.ts +29 -6
- package/src/cdp/measure/text.ts +227 -0
- package/src/cdp/motion/jank.ts +29 -83
- package/src/cdp/motion/mask.ts +18 -9
- package/src/cdp/motion/recorder.ts +86 -731
- package/src/cdp/scope-lock.ts +9 -9
- package/src/cdp/screenshot.ts +51 -21
- package/src/cdp/timing.ts +5 -5
- package/src/cdp/trace-analysis.ts +320 -0
- package/src/cdp/types.ts +31 -1
- package/src/cdp.ts +4 -6
- package/src/har-manager.ts +2 -4
- package/src/interact.ts +50 -23
- package/src/output/artifact.ts +49 -5
- package/src/output/render.ts +60 -9
- package/src/output/selection.ts +6 -0
- package/src/session/artifacts.ts +122 -12
- package/src/session/commands.ts +348 -113
- package/src/session/coordinator.ts +0 -93
- package/src/session/har-redact.ts +29 -23
- package/src/session-context.ts +13 -9
- package/src/cdp/bridge/protocol.ts +0 -224
- package/src/cdp/recorder-client.ts +0 -326
- package/src/cdp/tab-lock.ts +0 -132
package/src/session/commands.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
validateHarFile,
|
|
9
9
|
type HarFile,
|
|
10
10
|
type HAREntry,
|
|
11
|
+
type HttpHAREntry,
|
|
11
12
|
type Header,
|
|
12
13
|
type IncompleteLifecycle,
|
|
13
14
|
} from '../har-manager.js';
|
|
@@ -27,7 +28,7 @@ import {
|
|
|
27
28
|
startSessionLogTailer,
|
|
28
29
|
stopSessionLogTailers,
|
|
29
30
|
} from './log-tailer.js';
|
|
30
|
-
import {
|
|
31
|
+
import { stopAndReapCollectorHostAtSessionStop } from '../cdp/host/lifecycle.js';
|
|
31
32
|
import {
|
|
32
33
|
emitResult,
|
|
33
34
|
fact,
|
|
@@ -45,13 +46,15 @@ import {
|
|
|
45
46
|
rejectLogLabel,
|
|
46
47
|
writeJsonPrivate,
|
|
47
48
|
readPrivateFile,
|
|
49
|
+
registerArtifactRoot,
|
|
50
|
+
registeredArtifactRoots,
|
|
48
51
|
removeArtifactTree,
|
|
49
52
|
type SnapMeta,
|
|
50
53
|
type RecMeta,
|
|
51
54
|
} from './artifacts.js';
|
|
52
55
|
import { beginSessionStop, admitSessionOperation, withSessionLifecycle, withSessionScopeLifecycle } from './coordinator.js';
|
|
53
56
|
import { parseStatusFilter, type StatusPredicate } from './har-filter.js';
|
|
54
|
-
import { redactUrlCredentials } from './har-redact.js';
|
|
57
|
+
import { redactHeaderCredential, redactUrlCredentials } from './har-redact.js';
|
|
55
58
|
|
|
56
59
|
type Session = ActiveSessionState;
|
|
57
60
|
|
|
@@ -64,11 +67,14 @@ interface BundleManifest {
|
|
|
64
67
|
shots: Array<{ name: string; path: string }>;
|
|
65
68
|
har: { id: string; path: string; entryCount: number } | null;
|
|
66
69
|
logs: Array<{ name: string; path: string; lines: number }>;
|
|
70
|
+
reports: Array<{ id: string; path: string; url: string; finalUrl: string; preset: 'mobile' | 'desktop'; categories: string[]; lighthouse: string | null; axe: string | null }>;
|
|
67
71
|
other: Array<{ name: string; path: string }>;
|
|
68
72
|
/** `measure snap` artifacts collected from `measure/snaps/{id}/meta.json`. */
|
|
69
73
|
snaps: Array<{ id: string; path: string; url: string | null; viewport: string | null; settled: boolean; capturedAt: string }>;
|
|
70
74
|
/** `motion rec` artifacts collected from `motion/recs/{id}/meta.json`. */
|
|
71
75
|
recs: Array<{ id: string; path: string; action: string | null; frames: number; durationMs: number; state: string; viewportRestored: boolean | null }>;
|
|
76
|
+
/** Finalized network mocks collected from `network/mocks/{id}/meta.json`. */
|
|
77
|
+
mocks?: Array<{ id: string; path: string; completion: string; rules: number; paused: number; matched: number; releasedUnmatched: number }>;
|
|
72
78
|
/** Retry outcomes for viewport obligations retained by failed recorder starts. */
|
|
73
79
|
pendingViewportRestorations: Array<{ recId: string; viewportRestored: boolean | null }>;
|
|
74
80
|
}
|
|
@@ -99,9 +105,19 @@ function isBundleManifest(value: unknown): value is BundleManifest {
|
|
|
99
105
|
&& Array.isArray(record.shots) && record.shots.every(isNamedPathEntry)
|
|
100
106
|
&& validHar
|
|
101
107
|
&& Array.isArray(record.logs) && record.logs.every(item => isNamedPathEntry(item) && typeof (item as unknown as Record<string, unknown>).lines === 'number')
|
|
108
|
+
&& Array.isArray(record.reports) && record.reports.every(item => !!item && typeof item === 'object'
|
|
109
|
+
&& typeof (item as Record<string, unknown>).id === 'string'
|
|
110
|
+
&& typeof (item as Record<string, unknown>).path === 'string'
|
|
111
|
+
&& typeof (item as Record<string, unknown>).url === 'string'
|
|
112
|
+
&& typeof (item as Record<string, unknown>).finalUrl === 'string'
|
|
113
|
+
&& ((item as Record<string, unknown>).preset === 'mobile' || (item as Record<string, unknown>).preset === 'desktop')
|
|
114
|
+
&& Array.isArray((item as Record<string, unknown>).categories) && ((item as Record<string, unknown>).categories as unknown[]).every(category => typeof category === 'string')
|
|
115
|
+
&& ((item as Record<string, unknown>).lighthouse === null || typeof (item as Record<string, unknown>).lighthouse === 'string')
|
|
116
|
+
&& ((item as Record<string, unknown>).axe === null || typeof (item as Record<string, unknown>).axe === 'string'))
|
|
102
117
|
&& Array.isArray(record.other) && record.other.every(isNamedPathEntry)
|
|
103
118
|
&& Array.isArray(record.snaps) && record.snaps.every(item => !!item && typeof item === 'object' && typeof (item as Record<string, unknown>).id === 'string' && typeof (item as Record<string, unknown>).path === 'string')
|
|
104
119
|
&& Array.isArray(record.recs) && record.recs.every(item => !!item && typeof item === 'object' && typeof (item as Record<string, unknown>).id === 'string' && typeof (item as Record<string, unknown>).path === 'string')
|
|
120
|
+
&& (record.mocks === undefined || Array.isArray(record.mocks) && record.mocks.every(item => !!item && typeof item === 'object' && typeof (item as Record<string, unknown>).id === 'string' && typeof (item as Record<string, unknown>).path === 'string' && typeof (item as Record<string, unknown>).completion === 'string'))
|
|
105
121
|
&& Array.isArray(record.pendingViewportRestorations);
|
|
106
122
|
}
|
|
107
123
|
|
|
@@ -125,7 +141,7 @@ function readBundleManifest(bundlePath: string): BundleManifest {
|
|
|
125
141
|
}
|
|
126
142
|
|
|
127
143
|
/** The bundle-manifest section keys `session view --filter` can address. */
|
|
128
|
-
type SectionKey = 'shots' | 'har' | 'logs' | 'snaps' | 'recs' | 'other';
|
|
144
|
+
type SectionKey = 'shots' | 'har' | 'logs' | 'reports' | 'snaps' | 'recs' | 'mocks' | 'other';
|
|
129
145
|
|
|
130
146
|
// The `--filter <name>` query names map onto manifest section keys. Most are
|
|
131
147
|
// identical; `measure`/`motion` are the query-facing names for the `snaps`/
|
|
@@ -135,8 +151,10 @@ const VIEW_FILTERS: Record<string, SectionKey> = {
|
|
|
135
151
|
shots: 'shots',
|
|
136
152
|
har: 'har',
|
|
137
153
|
logs: 'logs',
|
|
154
|
+
lighthouse: 'reports',
|
|
138
155
|
measure: 'snaps',
|
|
139
156
|
motion: 'recs',
|
|
157
|
+
mocks: 'mocks',
|
|
140
158
|
other: 'other',
|
|
141
159
|
};
|
|
142
160
|
|
|
@@ -145,13 +163,15 @@ const SECTION_LABELS: Record<SectionKey, string> = {
|
|
|
145
163
|
shots: 'shots',
|
|
146
164
|
har: 'har',
|
|
147
165
|
logs: 'logs',
|
|
166
|
+
reports: 'lighthouse',
|
|
148
167
|
snaps: 'measure',
|
|
149
168
|
recs: 'motion',
|
|
169
|
+
mocks: 'mocks',
|
|
150
170
|
other: 'other',
|
|
151
171
|
};
|
|
152
172
|
|
|
153
|
-
function sessionDir(id: string): string {
|
|
154
|
-
return path.join(
|
|
173
|
+
function sessionDir(id: string, artifactRoot = CAPTURE_ROOT): string {
|
|
174
|
+
return path.join(artifactRoot, id);
|
|
155
175
|
}
|
|
156
176
|
|
|
157
177
|
function isSessionStopped(session: Session): boolean {
|
|
@@ -178,9 +198,9 @@ export interface OneshotSession {
|
|
|
178
198
|
* bundled/torn down by `session stop`; it accumulates under `/tmp` the same as
|
|
179
199
|
* any other session dir until the OS reaps `/tmp`.
|
|
180
200
|
*/
|
|
181
|
-
export function createOneshotSession(kind: 'measure' | 'motion' | 'page'): OneshotSession {
|
|
201
|
+
export function createOneshotSession(kind: 'measure' | 'motion' | 'page', artifactDir?: string): OneshotSession {
|
|
182
202
|
const id = `oneshot-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
183
|
-
const dir = sessionDir(id);
|
|
203
|
+
const dir = sessionDir(id, artifactDir === undefined ? CAPTURE_ROOT : registerArtifactRoot(artifactDir));
|
|
184
204
|
const artifactsDir = kind === 'measure'
|
|
185
205
|
? path.join(dir, 'measure', 'snaps')
|
|
186
206
|
: kind === 'motion'
|
|
@@ -235,8 +255,36 @@ function collectRecs(dir: string): BundleManifest['recs'] {
|
|
|
235
255
|
.map((name) => readRecMeta(path.join(recsRoot, name), name));
|
|
236
256
|
}
|
|
237
257
|
|
|
238
|
-
function
|
|
239
|
-
|
|
258
|
+
function collectMocks(dir: string): NonNullable<BundleManifest['mocks']> {
|
|
259
|
+
const mocksRoot = path.join(dir, 'network', 'mocks');
|
|
260
|
+
if (!fs.existsSync(mocksRoot)) return [];
|
|
261
|
+
return fs.readdirSync(mocksRoot).flatMap((name) => {
|
|
262
|
+
const mockDir = path.join(mocksRoot, name);
|
|
263
|
+
const metaPath = path.join(mockDir, 'meta.json');
|
|
264
|
+
if (!fs.existsSync(metaPath)) return [];
|
|
265
|
+
const meta = JSON.parse(fs.readFileSync(metaPath, 'utf8')) as Record<string, unknown>;
|
|
266
|
+
return [{ id: typeof meta.id === 'string' ? meta.id : name, path: mockDir, completion: typeof meta.completion === 'string' ? meta.completion : 'partial', rules: typeof meta.rules === 'number' ? meta.rules : 0, paused: typeof meta.paused === 'number' ? meta.paused : 0, matched: typeof meta.matched === 'number' ? meta.matched : 0, releasedUnmatched: typeof meta.releasedUnmatched === 'number' ? meta.releasedUnmatched : 0 }];
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function readLighthouseReportMeta(reportDir: string, fallbackId: string): BundleManifest['reports'][number] {
|
|
271
|
+
const meta = JSON.parse(fs.readFileSync(path.join(reportDir, 'meta.json'), 'utf-8')) as Partial<BundleManifest['reports'][number]>;
|
|
272
|
+
if (typeof meta.url !== 'string' || typeof meta.finalUrl !== 'string' || (meta.preset !== 'mobile' && meta.preset !== 'desktop') || !Array.isArray(meta.categories) || !meta.categories.every(category => typeof category === 'string') || (meta.lighthouse !== null && typeof meta.lighthouse !== 'string') || (meta.axe !== null && typeof meta.axe !== 'string')) {
|
|
273
|
+
throw captureError('artifact', 'invalid_lighthouse_report_metadata', `Lighthouse report metadata at ${reportDir} is invalid.`);
|
|
274
|
+
}
|
|
275
|
+
return { id: typeof meta.id === 'string' ? meta.id : fallbackId, path: reportDir, url: meta.url, finalUrl: meta.finalUrl, preset: meta.preset, categories: meta.categories, lighthouse: meta.lighthouse, axe: meta.axe };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function collectLighthouseReports(dir: string): BundleManifest['reports'] {
|
|
279
|
+
const reportsRoot = path.join(dir, 'lighthouse', 'reports');
|
|
280
|
+
if (!fs.existsSync(reportsRoot)) return [];
|
|
281
|
+
return fs.readdirSync(reportsRoot)
|
|
282
|
+
.filter((name) => fs.existsSync(path.join(reportsRoot, name, 'meta.json')))
|
|
283
|
+
.map((name) => readLighthouseReportMeta(path.join(reportsRoot, name), name));
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function sessionMetaPath(id: string, artifactRoot = CAPTURE_ROOT): string {
|
|
287
|
+
return path.join(sessionDir(id, artifactRoot), '.session.json');
|
|
240
288
|
}
|
|
241
289
|
|
|
242
290
|
/** Outcome of reading one persisted record without failing the caller: either
|
|
@@ -253,8 +301,8 @@ type SessionRead =
|
|
|
253
301
|
* A missing file is not a read outcome here; only `readSession` (targeted
|
|
254
302
|
* commands) turns that into `unknown_session`.
|
|
255
303
|
*/
|
|
256
|
-
function tryReadSession(id: string): SessionRead {
|
|
257
|
-
const metaPath = sessionMetaPath(id);
|
|
304
|
+
function tryReadSession(id: string, artifactRoot = CAPTURE_ROOT): SessionRead {
|
|
305
|
+
const metaPath = sessionMetaPath(id, artifactRoot);
|
|
258
306
|
const bad = (reason: string): SessionRead => ({ ok: false, id, metaPath, reason });
|
|
259
307
|
let parsed: unknown;
|
|
260
308
|
try {
|
|
@@ -277,16 +325,21 @@ function tryReadSession(id: string): SessionRead {
|
|
|
277
325
|
* unbranded via `fact` interpolation of undefined fields).
|
|
278
326
|
*/
|
|
279
327
|
function readSession(id: string): Session {
|
|
280
|
-
const
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
328
|
+
const active = getActiveSession();
|
|
329
|
+
if (active?.sessionId === id) return active;
|
|
330
|
+
for (const artifactRoot of registeredArtifactRoots()) {
|
|
331
|
+
const metaPath = sessionMetaPath(id, artifactRoot);
|
|
332
|
+
let read: SessionRead;
|
|
333
|
+
try {
|
|
334
|
+
read = tryReadSession(id, artifactRoot);
|
|
335
|
+
} catch (error) {
|
|
336
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue;
|
|
337
|
+
throw error;
|
|
338
|
+
}
|
|
339
|
+
if (!read.ok) throw captureError('artifact', 'invalid_session_record', `session metadata at ${metaPath} ${read.reason}.`);
|
|
340
|
+
return read.session;
|
|
287
341
|
}
|
|
288
|
-
|
|
289
|
-
return read.session;
|
|
342
|
+
throw captureError('precondition', 'unknown_session', `No capture session found: ${id}`);
|
|
290
343
|
}
|
|
291
344
|
|
|
292
345
|
function generateId(): string {
|
|
@@ -330,12 +383,11 @@ export async function waitForPageLoad(
|
|
|
330
383
|
|
|
331
384
|
/** Root-help representation of this branch, assembled by `src/capture.ts`. */
|
|
332
385
|
export const COMMAND_BLOCK = `<command name="session">
|
|
333
|
-
the artifact container — a session records HAR and bundles every artifact; it may open or adopt a tab
|
|
334
|
-
use when
|
|
335
|
-
start · stop · list · view · har · log — \`capture session -h\`
|
|
386
|
+
the artifact container — a session records HAR and bundles every artifact; it may open or adopt a tab, and once it has one, every command auto-targets it
|
|
387
|
+
use when collecting or reading the traffic, logs, and artifacts of scoped work against one page
|
|
336
388
|
</command>`;
|
|
337
389
|
|
|
338
|
-
const START_USAGE = `capture session start [--url <url> | --target <tab-id>] [--hold] [--port <n>] — open or adopt a tab, record HAR, and set the active capture context.
|
|
390
|
+
const START_USAGE = `capture session start [--url <url> | --target <tab-id>] [--hold] [--port <n>] [--artifact-dir <path>] — open or adopt a tab, record HAR, and set the active capture context.
|
|
339
391
|
|
|
340
392
|
input:
|
|
341
393
|
--url <url> Absolute URL to open in a fresh tab through CDP's
|
|
@@ -352,6 +404,7 @@ input:
|
|
|
352
404
|
lifetime, so browser-level state (permission grants,
|
|
353
405
|
ServiceWorker enablement) survives across separate commands.
|
|
354
406
|
--port <n> CDP port to attach to; default is auto-detected across localhost.
|
|
407
|
+
--artifact-dir <path> Absolute root for this session bundle and every later session-owned artifact.
|
|
355
408
|
|
|
356
409
|
Output:
|
|
357
410
|
One <session id=… path=… [url=…] [target=… port=…]> block: the session id,
|
|
@@ -359,7 +412,7 @@ Output:
|
|
|
359
412
|
applicable. --json mirrors the same fields.
|
|
360
413
|
|
|
361
414
|
effects:
|
|
362
|
-
Creates a private session dir with shots/, starts a HAR recording, opens a
|
|
415
|
+
Creates a private session dir under --artifact-dir when supplied (otherwise the configured capture root), with shots/, starts a HAR recording, opens a
|
|
363
416
|
fresh tab only when --url is supplied or adopts the resolved --target tab,
|
|
364
417
|
optionally holds a CDP bridge, and registers the session as the active capture
|
|
365
418
|
context so subsequent commands auto-target its tab.`;
|
|
@@ -371,7 +424,7 @@ input:
|
|
|
371
424
|
|
|
372
425
|
Output:
|
|
373
426
|
One <session-stopped id=… path=…> block: the bundle manifest path plus
|
|
374
|
-
counts (shots, HAR entries, logs, measure snaps, motion recs, other). --json
|
|
427
|
+
counts (shots, HAR entries, logs, lighthouse reports, measure snaps, motion recs, mocks, other). --json
|
|
375
428
|
mirrors the same fields.
|
|
376
429
|
|
|
377
430
|
effects:
|
|
@@ -398,13 +451,10 @@ const VIEW_USAGE = `capture session view <session-id> [--filter <section>] — r
|
|
|
398
451
|
|
|
399
452
|
input:
|
|
400
453
|
<session-id> A stopped session (from \`capture session list\`).
|
|
401
|
-
--filter <section> Show only one section: shots, har, logs, measure,
|
|
402
|
-
motion, or other. measure -> manifest snaps, motion ->
|
|
403
|
-
manifest recs.
|
|
454
|
+
--filter <section> Show only one section: shots, har, logs, lighthouse, measure, motion, mocks, or other. lighthouse -> manifest reports, measure -> manifest snaps, motion -> manifest recs.
|
|
404
455
|
|
|
405
456
|
Output:
|
|
406
|
-
One <session id=… path=… [filter=…]> block: the manifest sections (shots,
|
|
407
|
-
har, logs, measure snaps, motion recs, other), or a single section under
|
|
457
|
+
One <session id=… path=… [filter=…]> block: the manifest sections (shots, har, logs, lighthouse reports, measure snaps, motion recs, mocks, other), or a single section under
|
|
408
458
|
--filter. --json mirrors the same fields.
|
|
409
459
|
|
|
410
460
|
effects:
|
|
@@ -414,6 +464,8 @@ effects:
|
|
|
414
464
|
* thousands of records; an unbounded list buries the row the agent came for.
|
|
415
465
|
* `--limit` overrides it in either direction. */
|
|
416
466
|
const DEFAULT_HAR_ROWS = 100;
|
|
467
|
+
/** `--full` includes capped body detail, so its unfiltered selection has a lower bound. */
|
|
468
|
+
const DEFAULT_FULL_HAR_ROWS = 10;
|
|
417
469
|
|
|
418
470
|
const HAR_USAGE = `capture session har [<session-id>] — read a session's recorded HTTP traffic as a selection list.
|
|
419
471
|
|
|
@@ -423,33 +475,42 @@ input:
|
|
|
423
475
|
stopped one reads the bundled har.json.
|
|
424
476
|
--filter-url <pattern> substring or regex match on the request URL (matched
|
|
425
477
|
against the URL as captured, before redaction)
|
|
478
|
+
--body <url> --out <path> write one exact, complete captured response body to
|
|
479
|
+
the caller-owned file. <url> matches exactly one
|
|
480
|
+
completed transaction by its captured URL; the result
|
|
481
|
+
reports its transaction id, byte count, and encoding.
|
|
426
482
|
--filter-status <spec> exact status (100-599), one-digit class prefix
|
|
427
483
|
(1-5, e.g. 4 = every 4xx), or ordered range
|
|
428
484
|
(e.g. 400-499); any other token is invalid_filter.
|
|
429
485
|
A record with no observed response never matches.
|
|
430
486
|
--filter-method <method> HTTP method (GET, POST, …)
|
|
431
|
-
--limit <n> list at most n matching rows; default ${DEFAULT_HAR_ROWS}
|
|
432
|
-
--full inline
|
|
433
|
-
response body — escaped and capped); bodies are
|
|
434
|
-
never inlined without it
|
|
487
|
+
--limit <n> list at most n matching rows; default ${DEFAULT_HAR_ROWS} (${DEFAULT_FULL_HAR_ROWS} with --full)
|
|
488
|
+
--full inline full detail (headers, post data, response body — escaped and capped); filters resolve first, then structured responses sort before unstructured records so the bounded selection reaches API-shaped bodies before page assets; identical headers shared by every rendered record are listed once, while differing headers stay with their record; bodies are never inlined without it
|
|
435
489
|
|
|
436
490
|
output:
|
|
437
491
|
One <session-har id=… path=… source=live|bundle entries=… incomplete=…
|
|
438
492
|
total=… total-incomplete=… [truncated=true]> block listing BOTH populations
|
|
439
|
-
in
|
|
440
|
-
size, start time
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
493
|
+
in chronological order by default (or structured responses first under \`--full\`): completed entries (method, status, URL, body
|
|
494
|
+
size, start time, HAR duration, response-end time) and the incomplete
|
|
495
|
+
lifecycle records the recorder retained for requests whose response never
|
|
496
|
+
completed — rendered as "incomplete: <kind>" plus their honest unavailable
|
|
497
|
+
timing state (an out-of-order request clock also names its violation, e.g.
|
|
498
|
+
invalid_clock_order/terminal_before_request). The path attribute is the HAR
|
|
444
499
|
file's absolute path — the full-fidelity pointer, which holds every record
|
|
445
|
-
including the ones this list bounds away.
|
|
500
|
+
including the ones this list bounds away. \`--body\` writes one complete body to
|
|
501
|
+
\`--out\` instead of listing rows; it refuses an ambiguous, absent, or truncated
|
|
502
|
+
body rather than writing a partial result. --json mirrors the same fields.
|
|
446
503
|
Credential-like query parameter values (key, token, secret, signature, auth,
|
|
447
|
-
password, oauth code, …) render as REDACTED;
|
|
448
|
-
|
|
504
|
+
password, oauth code, …) render as REDACTED; credential-valued headers render
|
|
505
|
+
as redacted · <N> chars. The HAR file keeps the real values. WebSockets opened
|
|
506
|
+
while a command was recording appear as entries with
|
|
449
507
|
_resourceType "websocket" and their frames in _webSocketMessages (capped at
|
|
450
508
|
200 frames/socket, 4KB/frame); sockets opened before recording started are
|
|
451
509
|
not visible.
|
|
452
510
|
|
|
511
|
+
boundary:
|
|
512
|
+
Reports recorded transactions and does not change responses. For a counterfactual response, use \`capture tab mock start\`.
|
|
513
|
+
|
|
453
514
|
effects:
|
|
454
515
|
None — reads recorded HAR data only.`;
|
|
455
516
|
|
|
@@ -474,20 +535,16 @@ effects:
|
|
|
474
535
|
the identity-bearing tailer record is registered in the session metadata.`;
|
|
475
536
|
|
|
476
537
|
function printSessionHelp(): void {
|
|
477
|
-
console.log(
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
<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"/>
|
|
488
|
-
<subcommand name="log" args="<path> [--name <label>] [--session <id>]" whenToUse="tail an external log file into the session's logs/ dir"/>
|
|
489
|
-
|
|
490
|
-
capture session <leaf> -h Per-leaf usage`);
|
|
538
|
+
console.log(`<command name="session" description="the artifact container: records HTTP traffic and bundles artifacts for one page">
|
|
539
|
+
<model>An active session with a tab auto-targets it and appends recorded traffic; its finalized bundle remains available for readback.</model>
|
|
540
|
+
<subcommand name="start" description="open or adopt a tab and start a session" whenToUse="Use when beginning scoped work against one page and collecting its artifacts."/>
|
|
541
|
+
<subcommand name="stop" description="finalize a session bundle" whenToUse="Use when scoped capture is complete and its artifacts must become a bundle."/>
|
|
542
|
+
<subcommand name="list" description="active and stopped sessions" whenToUse="Use to find a session to inspect or stop."/>
|
|
543
|
+
<subcommand name="view" description="one stopped session's bundle" whenToUse="Use to inspect the artifacts collected by a finalized session."/>
|
|
544
|
+
<subcommand name="har" description="recorded HTTP traffic" whenToUse="Use to inspect requests from a running session or a finalized bundle."/>
|
|
545
|
+
<subcommand name="log" description="tail an external log into a session" whenToUse="Use to add external process output to a session's bundled artifacts."/>
|
|
546
|
+
<subcommand name="collectors" description="what is collecting right now" whenToUse="Use to see live traces, recordings, heap snapshots, and mocks before starting one that would conflict."/>
|
|
547
|
+
</command>`);
|
|
491
548
|
}
|
|
492
549
|
|
|
493
550
|
// ============================================================================
|
|
@@ -503,7 +560,7 @@ manifest; \`view\` reads that manifest back.
|
|
|
503
560
|
export interface SessionStartWorld {
|
|
504
561
|
createHar(dir: string): Promise<string>;
|
|
505
562
|
deleteHar(harId: string): Promise<void>;
|
|
506
|
-
detectCdpPort(): Promise<number>;
|
|
563
|
+
detectCdpPort(): Promise<number | { port: number; app: string; selectionReason: string }>;
|
|
507
564
|
openTab(port: number, url: string): Promise<CDPTarget>;
|
|
508
565
|
findTabById(port: number, targetId: string): Promise<CDPTarget | null>;
|
|
509
566
|
closeTarget(port: number, targetId: string): Promise<void>;
|
|
@@ -520,8 +577,8 @@ const productionStartWorld: SessionStartWorld = {
|
|
|
520
577
|
},
|
|
521
578
|
deleteHar: deleteHarRecording,
|
|
522
579
|
async detectCdpPort() {
|
|
523
|
-
const {
|
|
524
|
-
return
|
|
580
|
+
const { detectCdpEndpoint } = await import('../cdp/detect.js');
|
|
581
|
+
return detectCdpEndpoint();
|
|
525
582
|
},
|
|
526
583
|
async openTab(port, url) {
|
|
527
584
|
const { openTab } = await import('../cdp.js');
|
|
@@ -604,7 +661,7 @@ async function start(parsed: ParsedArgs): Promise<void> {
|
|
|
604
661
|
return;
|
|
605
662
|
}
|
|
606
663
|
|
|
607
|
-
if (rejectSurplusPositionals(parsed, 'start', 0, 'capture session start [--url <url> | --target <tab-id>] [--hold] [--port <n>]')) return;
|
|
664
|
+
if (rejectSurplusPositionals(parsed, 'start', 0, 'capture session start [--url <url> | --target <tab-id>] [--hold] [--port <n>] [--artifact-dir <path>]')) return;
|
|
608
665
|
|
|
609
666
|
return withSessionScopeLifecycle(async () => {
|
|
610
667
|
const active = getActiveSession();
|
|
@@ -623,7 +680,7 @@ async function start(parsed: ParsedArgs): Promise<void> {
|
|
|
623
680
|
const requestedTarget = parsed.target ?? null;
|
|
624
681
|
const hold = parsed.hold === true;
|
|
625
682
|
const id = generateId();
|
|
626
|
-
const dir = sessionDir(id);
|
|
683
|
+
const dir = sessionDir(id, parsed.artifactDir === undefined ? CAPTURE_ROOT : registerArtifactRoot(parsed.artifactDir));
|
|
627
684
|
|
|
628
685
|
// Every effect below is owned the instant it succeeds and released in exact
|
|
629
686
|
// reverse order if any later step throws. Nothing is committed outside this
|
|
@@ -635,6 +692,8 @@ async function start(parsed: ParsedArgs): Promise<void> {
|
|
|
635
692
|
let bridgeSocket: string | null = null;
|
|
636
693
|
let bridgePid: number | null = null;
|
|
637
694
|
let cdpPort: number | null = null;
|
|
695
|
+
let selectedBrowser: string | null = null;
|
|
696
|
+
let endpointSelectionReason: string | null = null;
|
|
638
697
|
|
|
639
698
|
try {
|
|
640
699
|
ensurePrivateDir(path.join(dir, 'shots'));
|
|
@@ -644,8 +703,13 @@ async function start(parsed: ParsedArgs): Promise<void> {
|
|
|
644
703
|
const acquiredHarId = harId;
|
|
645
704
|
acquired.push({ label: 'HAR recording', release: () => startWorld.deleteHar(acquiredHarId) });
|
|
646
705
|
|
|
647
|
-
if (
|
|
648
|
-
cdpPort = parsed.port
|
|
706
|
+
if (parsed.port !== undefined) {
|
|
707
|
+
cdpPort = parsed.port;
|
|
708
|
+
} else if (url || requestedTarget || hold) {
|
|
709
|
+
const detected = await startWorld.detectCdpPort();
|
|
710
|
+
cdpPort = typeof detected === 'number' ? detected : detected.port;
|
|
711
|
+
selectedBrowser = typeof detected === 'number' ? null : detected.app;
|
|
712
|
+
endpointSelectionReason = typeof detected === 'number' ? null : detected.selectionReason;
|
|
649
713
|
}
|
|
650
714
|
|
|
651
715
|
if (requestedTarget) {
|
|
@@ -715,6 +779,13 @@ async function start(parsed: ParsedArgs): Promise<void> {
|
|
|
715
779
|
|
|
716
780
|
const rows: FactLine[] = [fact`bundle dir: ${dir}`];
|
|
717
781
|
if (target) rows.push(url ? fact`tab ${target.id} opened at ${url}` : fact`tab ${target.id} adopted at ${target.url}`);
|
|
782
|
+
if (cdpPort !== null) rows.push(
|
|
783
|
+
parsed.portSource === 'env'
|
|
784
|
+
? fact`CDP endpoint on port ${cdpPort} selected from ambient CDP_PORT; --port overrides endpoint selection and --target overrides tab selection.`
|
|
785
|
+
: parsed.port !== undefined
|
|
786
|
+
? fact`CDP endpoint on port ${cdpPort} selected by explicit --port; --port overrides endpoint selection and --target overrides tab selection.`
|
|
787
|
+
: fact`${selectedBrowser ?? 'CDP endpoint'} on port ${cdpPort} auto-selected because it ${endpointSelectionReason ?? 'was selected by automatic endpoint detection'}; capture-launched browser recency does not affect selection. --port overrides endpoint selection and --target overrides tab selection.`,
|
|
788
|
+
);
|
|
718
789
|
if (pageLoadTimedOut) rows.push(fact`page load timed out after 10000ms; the session stays attached to target ${target?.id ?? ''}`);
|
|
719
790
|
if (harId) rows.push(fact`HAR recording ${harId} — traffic auto-appends while the session is active`);
|
|
720
791
|
if (hold) rows.push(fact`held CDP bridge: pid ${bridgePid ?? 0}`);
|
|
@@ -1022,8 +1093,44 @@ function harUrl(url: string): FactLine {
|
|
|
1022
1093
|
return data(redactUrlCredentials(url), 300);
|
|
1023
1094
|
}
|
|
1024
1095
|
|
|
1025
|
-
|
|
1026
|
-
|
|
1096
|
+
function responseEndDateTime(
|
|
1097
|
+
requestWallTime: number,
|
|
1098
|
+
requestMonotonic: number,
|
|
1099
|
+
terminalMonotonic: number,
|
|
1100
|
+
): string | null {
|
|
1101
|
+
const end = new Date((requestWallTime + terminalMonotonic - requestMonotonic) * 1000);
|
|
1102
|
+
return Number.isNaN(end.getTime()) ? null : end.toISOString();
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
function completedTiming(e: HAREntry): FactLine {
|
|
1106
|
+
const { clocks, terminal } = e._capture;
|
|
1107
|
+
if (terminal.kind === 'failed') {
|
|
1108
|
+
return fact`duration ${e.time} ms — response incomplete: failed`;
|
|
1109
|
+
}
|
|
1110
|
+
if (terminal.kind === 'redirect' && e.response.status === 0) {
|
|
1111
|
+
return fact`duration ${e.time} ms — response incomplete: redirect without HTTP response`;
|
|
1112
|
+
}
|
|
1113
|
+
const ended = responseEndDateTime(clocks.requestWallTime, clocks.requestMonotonic, clocks.terminalMonotonic);
|
|
1114
|
+
return ended === null
|
|
1115
|
+
? fact`duration ${e.time} ms — response end unavailable`
|
|
1116
|
+
: fact`duration ${e.time} ms — response ended ${ended}`;
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
function incompleteTiming(record: IncompleteLifecycle): FactLine {
|
|
1120
|
+
if (record.kind === 'stopped_during_body') {
|
|
1121
|
+
const { requestWallTime, requestMonotonic, terminalMonotonic } = record._capture;
|
|
1122
|
+
const duration = (terminalMonotonic - requestMonotonic) * 1000;
|
|
1123
|
+
const ended = responseEndDateTime(requestWallTime, requestMonotonic, terminalMonotonic);
|
|
1124
|
+
return ended === null
|
|
1125
|
+
? fact`duration ${duration} ms — response end unavailable`
|
|
1126
|
+
: fact`duration ${duration} ms — response ended ${ended}`;
|
|
1127
|
+
}
|
|
1128
|
+
if (record.kind === 'invalid_clock_order') {
|
|
1129
|
+
return fact`timing unavailable: ${incompleteLabel(record)}`;
|
|
1130
|
+
}
|
|
1131
|
+
return text`response incomplete: terminal event never observed`;
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1027
1134
|
function harEntryRow(e: HAREntry): FactLine {
|
|
1028
1135
|
const bodyText = e.response.content?.text;
|
|
1029
1136
|
const sizePart = typeof bodyText === 'string'
|
|
@@ -1034,13 +1141,11 @@ function harEntryRow(e: HAREntry): FactLine {
|
|
|
1034
1141
|
harUrl(e.request.url),
|
|
1035
1142
|
text` — `,
|
|
1036
1143
|
sizePart,
|
|
1037
|
-
fact` — started ${e.startedDateTime}`,
|
|
1144
|
+
fact` — started ${e.startedDateTime} — `,
|
|
1145
|
+
completedTiming(e),
|
|
1038
1146
|
);
|
|
1039
1147
|
}
|
|
1040
1148
|
|
|
1041
|
-
/** One selection-list row for an incomplete lifecycle record: it names the
|
|
1042
|
-
* kind (and clock violation) in place of the body size a completed entry
|
|
1043
|
-
* reports, so a row is never mistaken for finished traffic. */
|
|
1044
1149
|
function harIncompleteRow(record: IncompleteLifecycle): FactLine {
|
|
1045
1150
|
const response = incompleteResponse(record);
|
|
1046
1151
|
return line(
|
|
@@ -1048,22 +1153,76 @@ function harIncompleteRow(record: IncompleteLifecycle): FactLine {
|
|
|
1048
1153
|
harUrl(record.request.url),
|
|
1049
1154
|
text` — `,
|
|
1050
1155
|
fact`incomplete: ${incompleteLabel(record)}`,
|
|
1051
|
-
fact` — started ${record.startedDateTime}`,
|
|
1156
|
+
fact` — started ${record.startedDateTime} — `,
|
|
1157
|
+
incompleteTiming(record),
|
|
1052
1158
|
);
|
|
1053
1159
|
}
|
|
1054
1160
|
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1161
|
+
function harHeader(prefix: 'req' | 'res', header: Header): FactLine {
|
|
1162
|
+
return fact` ${prefix} ${header.name}: ${redactHeaderCredential(header.name, header.value)}`;
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
interface SharedHarHeaders {
|
|
1166
|
+
request?: readonly Header[];
|
|
1167
|
+
response?: readonly Header[];
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
function headersEqual(left: readonly Header[], right: readonly Header[]): boolean {
|
|
1171
|
+
return left.length === right.length && left.every((header, index) => header.name === right[index]?.name && header.value === right[index]?.value);
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
function requestHeaders(item: HarSelection): readonly Header[] {
|
|
1175
|
+
return item.kind === 'complete' ? item.entry.request.headers ?? [] : item.record.request.headers ?? [];
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
function responseHeaders(item: HarSelection): readonly Header[] | undefined {
|
|
1179
|
+
if (item.kind === 'complete') return item.entry.response.headers ?? [];
|
|
1180
|
+
return incompleteResponse(item.record)?.headers;
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
function isStructuredResponse(item: HarSelection): boolean {
|
|
1184
|
+
if (item.kind !== 'complete') return false;
|
|
1185
|
+
const contentType = responseHeaders(item)?.find((header) => header.name.toLowerCase() === 'content-type')?.value.toLowerCase().split(';', 1)[0]?.trim();
|
|
1186
|
+
return contentType === 'application/json' || contentType === 'text/json' || contentType === 'application/x-ndjson' || contentType === 'application/xml' || contentType === 'text/xml' || contentType?.endsWith('+json') === true || contentType?.endsWith('+xml') === true;
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
/** `--full` resolves filters before this ordering, then selects its bounded record set. */
|
|
1190
|
+
function prioritizeFullHarSelection(items: readonly HarSelection[]): HarSelection[] {
|
|
1191
|
+
return [...items].sort((left, right) => Number(isStructuredResponse(right)) - Number(isStructuredResponse(left)));
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
/** Hoist only complete identical header lists shared by every displayed row; every differing value remains next to its record. */
|
|
1195
|
+
function sharedHarHeaders(shown: readonly HarSelection[]): SharedHarHeaders {
|
|
1196
|
+
if (shown.length < 2) return {};
|
|
1197
|
+
const request = requestHeaders(shown[0]!);
|
|
1198
|
+
const response = responseHeaders(shown[0]!);
|
|
1199
|
+
return {
|
|
1200
|
+
...(request.length > 0 && shown.every((item) => headersEqual(request, requestHeaders(item))) ? { request } : {}),
|
|
1201
|
+
...(response && response.length > 0 && shown.every((item) => {
|
|
1202
|
+
const candidate = responseHeaders(item);
|
|
1203
|
+
return candidate !== undefined && headersEqual(response, candidate);
|
|
1204
|
+
}) ? { response } : {}),
|
|
1205
|
+
};
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
function sharedHeaderDetail(prefix: 'req' | 'res', headers: readonly Header[], shown: number): FactLine {
|
|
1209
|
+
return lineList([
|
|
1210
|
+
fact`shared ${prefix === 'req' ? 'request' : 'response'} headers (all ${shown} rendered records):`,
|
|
1211
|
+
...headers.map((header) => harHeader(prefix, header)),
|
|
1212
|
+
]);
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
/** `--full` inline detail for one entry: record-specific headers, post data, and response body — every value escaped and capped through data()/fact. */
|
|
1216
|
+
function harEntryDetail(e: HAREntry, index: number, shared: SharedHarHeaders): FactLine {
|
|
1058
1217
|
const rows: FactLine[] = [line(fact`${index + 1}. `, harEntryRow(e))];
|
|
1059
|
-
|
|
1060
|
-
rows.push(
|
|
1218
|
+
if (!shared.request) {
|
|
1219
|
+
for (const h of e.request.headers ?? []) rows.push(harHeader('req', h));
|
|
1061
1220
|
}
|
|
1062
1221
|
if (e.request.postData?.text !== undefined) {
|
|
1063
1222
|
rows.push(fact` post data: ${capped(e.request.postData.text, 2000)}`);
|
|
1064
1223
|
}
|
|
1065
|
-
|
|
1066
|
-
rows.push(
|
|
1224
|
+
if (!shared.response) {
|
|
1225
|
+
for (const h of e.response.headers ?? []) rows.push(harHeader('res', h));
|
|
1067
1226
|
}
|
|
1068
1227
|
const bodyText = e.response.content?.text;
|
|
1069
1228
|
rows.push(
|
|
@@ -1074,21 +1233,19 @@ function harEntryDetail(e: HAREntry, index: number): FactLine {
|
|
|
1074
1233
|
return lineList(rows);
|
|
1075
1234
|
}
|
|
1076
1235
|
|
|
1077
|
-
/** `--full` inline detail for one incomplete lifecycle record: the
|
|
1078
|
-
|
|
1079
|
-
* why the lifecycle never completed. */
|
|
1080
|
-
function harIncompleteDetail(record: IncompleteLifecycle, index: number): FactLine {
|
|
1236
|
+
/** `--full` inline detail for one incomplete lifecycle record: record-specific headers/post data plus the lifecycle provenance. */
|
|
1237
|
+
function harIncompleteDetail(record: IncompleteLifecycle, index: number, shared: SharedHarHeaders): FactLine {
|
|
1081
1238
|
const rows: FactLine[] = [line(fact`${index + 1}. `, harIncompleteRow(record))];
|
|
1082
1239
|
rows.push(fact` request ${record.requestId} (generation ${record.generation})`);
|
|
1083
|
-
|
|
1084
|
-
rows.push(
|
|
1240
|
+
if (!shared.request) {
|
|
1241
|
+
for (const h of record.request.headers ?? []) rows.push(harHeader('req', h));
|
|
1085
1242
|
}
|
|
1086
1243
|
if (record.request.postData?.text !== undefined) {
|
|
1087
1244
|
rows.push(fact` post data: ${capped(record.request.postData.text, 2000)}`);
|
|
1088
1245
|
}
|
|
1089
1246
|
const response = incompleteResponse(record);
|
|
1090
|
-
|
|
1091
|
-
rows.push(
|
|
1247
|
+
if (!shared.response) {
|
|
1248
|
+
for (const h of response?.headers ?? []) rows.push(harHeader('res', h));
|
|
1092
1249
|
}
|
|
1093
1250
|
if (record.kind === 'invalid_clock_order') {
|
|
1094
1251
|
rows.push(fact` terminal: ${record.terminal.kind}`);
|
|
@@ -1101,6 +1258,67 @@ function harIncompleteDetail(record: IncompleteLifecycle, index: number): FactLi
|
|
|
1101
1258
|
return lineList(rows);
|
|
1102
1259
|
}
|
|
1103
1260
|
|
|
1261
|
+
function completeBodyBytes(entry: HttpHAREntry): Buffer | { code: string; message: string } {
|
|
1262
|
+
const provenance = entry._capture.body;
|
|
1263
|
+
if (provenance.state !== 'captured') {
|
|
1264
|
+
return { code: 'body_unavailable', message: `The selected transaction has no captured response body (${provenance.state === 'fetch_failed' ? provenance.error : provenance.reason}).` };
|
|
1265
|
+
}
|
|
1266
|
+
if (provenance.truncated) {
|
|
1267
|
+
return { code: 'body_truncated', message: `The selected transaction retained ${provenance.capturedByteLength} of ${provenance.decodedByteLength} response bytes, so capture cannot export it as a complete body.` };
|
|
1268
|
+
}
|
|
1269
|
+
const content = entry.response.content.text;
|
|
1270
|
+
if (content === undefined) {
|
|
1271
|
+
return { code: 'body_unavailable', message: 'The selected transaction claims a captured response body but its HAR content is absent.' };
|
|
1272
|
+
}
|
|
1273
|
+
return provenance.sourceEncoding === 'base64' ? Buffer.from(content, 'base64') : Buffer.from(content, 'utf8');
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
function exportHarBody(id: string, located: HarSource, parsed: ParsedArgs): boolean {
|
|
1277
|
+
if (parsed.body === undefined) return false;
|
|
1278
|
+
if (!parsed.out) {
|
|
1279
|
+
emitResult({ tag: 'error', attrs: { command: 'session har', code: 'body_out_required' }, summary: fact`--body requires --out <path>; response bytes are written only to a caller-named file.` }, { json: parsed.json });
|
|
1280
|
+
process.exitCode = 1;
|
|
1281
|
+
return true;
|
|
1282
|
+
}
|
|
1283
|
+
if (parsed.filterUrl !== undefined || parsed.filterStatus !== undefined || parsed.filterMethod !== undefined || parsed.limit !== undefined || parsed.full) {
|
|
1284
|
+
emitResult({ tag: 'error', attrs: { command: 'session har', code: 'body_selection_conflict' }, summary: fact`--body selects one exact captured URL and cannot be combined with --filter-url, --filter-status, --filter-method, --limit, or --full.` }, { json: parsed.json });
|
|
1285
|
+
process.exitCode = 1;
|
|
1286
|
+
return true;
|
|
1287
|
+
}
|
|
1288
|
+
const matches = located.har.log.entries.filter((entry): entry is HttpHAREntry => entry.request.url === parsed.body && '_capture' in entry);
|
|
1289
|
+
if (matches.length !== 1) {
|
|
1290
|
+
const code = matches.length === 0 ? 'body_not_found' : 'body_ambiguous';
|
|
1291
|
+
const message = matches.length === 0
|
|
1292
|
+
? line(text`No completed captured transaction has the exact URL `, harUrl(parsed.body), text`.`)
|
|
1293
|
+
: line(fact`${matches.length} completed captured transactions have the exact URL `, harUrl(parsed.body), text`; --body requires one recorded transaction.`);
|
|
1294
|
+
emitResult({ tag: 'error', attrs: { command: 'session har', code }, summary: message }, { json: parsed.json });
|
|
1295
|
+
process.exitCode = 1;
|
|
1296
|
+
return true;
|
|
1297
|
+
}
|
|
1298
|
+
const entry = matches[0]!;
|
|
1299
|
+
const bytes = completeBodyBytes(entry);
|
|
1300
|
+
if (!(bytes instanceof Buffer)) {
|
|
1301
|
+
emitResult({ tag: 'error', attrs: { command: 'session har', code: bytes.code }, summary: fact`${bytes.message}` }, { json: parsed.json });
|
|
1302
|
+
process.exitCode = 1;
|
|
1303
|
+
return true;
|
|
1304
|
+
}
|
|
1305
|
+
try {
|
|
1306
|
+
fs.writeFileSync(parsed.out, bytes);
|
|
1307
|
+
} catch (cause) {
|
|
1308
|
+
emitResult({ tag: 'error', attrs: { command: 'session har', code: 'body_write_failed' }, summary: fact`The selected response body was not written to ${parsed.out}: ${cause instanceof Error ? cause.message : String(cause)}` }, { json: parsed.json });
|
|
1309
|
+
process.exitCode = 1;
|
|
1310
|
+
return true;
|
|
1311
|
+
}
|
|
1312
|
+
const provenance = entry._capture.body;
|
|
1313
|
+
emitResult({
|
|
1314
|
+
tag: 'session-har',
|
|
1315
|
+
attrs: { id, path: located.path, source: located.source, 'body-path': parsed.out, bytes: bytes.length, encoding: provenance.sourceEncoding, transaction: entry._capture.requestId, generation: entry._capture.generation, status: entry.response.status },
|
|
1316
|
+
summary: line(fact`Wrote the complete recorded response body for ${entry.request.method} `, harUrl(entry.request.url), fact` to ${parsed.out}.`),
|
|
1317
|
+
sections: [fact`Transaction ${entry._capture.requestId} (generation ${entry._capture.generation}) started ${entry.startedDateTime}; capture recorded ${provenance.capturedByteLength} of ${provenance.decodedByteLength} decoded bytes with ${provenance.sourceEncoding} encoding (truncated=${String(provenance.truncated)}).`],
|
|
1318
|
+
}, { json: parsed.json });
|
|
1319
|
+
return true;
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1104
1322
|
async function har(parsed: ParsedArgs): Promise<void> {
|
|
1105
1323
|
if (parsed.help) {
|
|
1106
1324
|
console.log(HAR_USAGE);
|
|
@@ -1169,6 +1387,13 @@ async function har(parsed: ParsedArgs): Promise<void> {
|
|
|
1169
1387
|
return;
|
|
1170
1388
|
}
|
|
1171
1389
|
|
|
1390
|
+
if (exportHarBody(id, located, parsed)) return;
|
|
1391
|
+
if (parsed.out) {
|
|
1392
|
+
emitResult({ tag: 'error', attrs: { command: 'session har', code: 'out_requires_body' }, summary: fact`--out is only available with --body <url>; it names the caller-owned response-body file.` }, { json: parsed.json });
|
|
1393
|
+
process.exitCode = 1;
|
|
1394
|
+
return;
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1172
1397
|
let selection = buildHarSelection(located.har);
|
|
1173
1398
|
const total = located.har.log.entries.length;
|
|
1174
1399
|
const totalIncomplete = located.har.incompleteLifecycles.length;
|
|
@@ -1200,7 +1425,8 @@ async function har(parsed: ParsedArgs): Promise<void> {
|
|
|
1200
1425
|
|
|
1201
1426
|
const requestedLimit = typeof parsed.limit === 'number' && parsed.limit > 0 ? parsed.limit : null;
|
|
1202
1427
|
if (requestedLimit !== null) filters.push(`limit=${requestedLimit}`);
|
|
1203
|
-
|
|
1428
|
+
if (parsed.full) selection = prioritizeFullHarSelection(selection);
|
|
1429
|
+
const bound = requestedLimit ?? (parsed.full ? DEFAULT_FULL_HAR_ROWS : DEFAULT_HAR_ROWS);
|
|
1204
1430
|
const matched = selection.length;
|
|
1205
1431
|
const shown = selection.slice(0, bound);
|
|
1206
1432
|
const truncated = matched > shown.length;
|
|
@@ -1215,7 +1441,14 @@ async function har(parsed: ParsedArgs): Promise<void> {
|
|
|
1215
1441
|
const summary = fact`${shownEntries} of ${total} complete entries and ${shownIncomplete} of ${totalIncomplete} incomplete lifecycle records listed${filterNote}.${truncationNote}`;
|
|
1216
1442
|
|
|
1217
1443
|
const sections = parsed.full
|
|
1218
|
-
?
|
|
1444
|
+
? (() => {
|
|
1445
|
+
const shared = sharedHarHeaders(shown);
|
|
1446
|
+
return [
|
|
1447
|
+
...(shared.request ? [sharedHeaderDetail('req', shared.request, shown.length)] : []),
|
|
1448
|
+
...(shared.response ? [sharedHeaderDetail('res', shared.response, shown.length)] : []),
|
|
1449
|
+
...shown.map((item, i) => (item.kind === 'complete' ? harEntryDetail(item.entry, i, shared) : harIncompleteDetail(item.record, i, shared))),
|
|
1450
|
+
];
|
|
1451
|
+
})()
|
|
1219
1452
|
: [lineList(shown.map((item, i) => line(
|
|
1220
1453
|
fact`${i + 1}. `,
|
|
1221
1454
|
item.kind === 'complete' ? harEntryRow(item.entry) : harIncompleteRow(item.record),
|
|
@@ -1234,7 +1467,10 @@ async function har(parsed: ParsedArgs): Promise<void> {
|
|
|
1234
1467
|
truncated: truncated ? true : undefined,
|
|
1235
1468
|
},
|
|
1236
1469
|
summary,
|
|
1237
|
-
sections
|
|
1470
|
+
sections: [
|
|
1471
|
+
...sections,
|
|
1472
|
+
fact`Recorded transactions only; this read does not change responses. \`capture tab mock start\` serves a counterfactual response.`,
|
|
1473
|
+
],
|
|
1238
1474
|
}, { json: parsed.json });
|
|
1239
1475
|
}
|
|
1240
1476
|
|
|
@@ -1243,8 +1479,10 @@ function bundleRows(manifest: BundleManifest): FactLine[] {
|
|
|
1243
1479
|
fact`shots: ${manifest.shots.length}`,
|
|
1244
1480
|
manifest.har ? fact`har: ${manifest.har.entryCount} entries — ${manifest.har.path}` : fact`har: 0 entries`,
|
|
1245
1481
|
fact`logs: ${manifest.logs.length}`,
|
|
1482
|
+
fact`lighthouse reports: ${manifest.reports.length}`,
|
|
1246
1483
|
fact`measure snaps: ${manifest.snaps.length}`,
|
|
1247
1484
|
fact`motion recs: ${manifest.recs.length}`,
|
|
1485
|
+
fact`mocks: ${manifest.mocks?.length ?? 0}`,
|
|
1248
1486
|
fact`other: ${manifest.other.length}`,
|
|
1249
1487
|
];
|
|
1250
1488
|
}
|
|
@@ -1332,19 +1570,9 @@ async function stop(parsed: ParsedArgs): Promise<void> {
|
|
|
1332
1570
|
await stopSessionLogTailers(session.logPids ?? []);
|
|
1333
1571
|
if (session.bridgePid || session.bridgeSocket) stopBridge(session.bridgePid, session.bridgeSocket);
|
|
1334
1572
|
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
} catch (error) {
|
|
1339
|
-
// A terminal `rec-stop` failure (the bridge authenticated the request
|
|
1340
|
-
// and explicitly refused — e.g. a fatal HAR drain) must NOT be
|
|
1341
|
-
// swallowed into a warning: it escapes to the outer `stop_failed`
|
|
1342
|
-
// lane below, so no bundle is committed and no live HAR/recorder
|
|
1343
|
-
// handle is deleted while admitted append work may still be lost.
|
|
1344
|
-
// Non-fatal teardown noise (dead handle, liveness-unknown, malformed
|
|
1345
|
-
// handle) keeps the existing warn-and-continue policy.
|
|
1346
|
-
if (isTerminalRecStopFailure(error)) throw error;
|
|
1347
|
-
console.error(`Warning: could not finalize active recording: ${error instanceof Error ? error.message : error}`);
|
|
1573
|
+
const hostTeardown = await stopAndReapCollectorHostAtSessionStop(session.dir);
|
|
1574
|
+
if (hostTeardown.status === 'terminal') {
|
|
1575
|
+
throw new Error(`collector host teardown failed: ${hostTeardown.error}`);
|
|
1348
1576
|
}
|
|
1349
1577
|
|
|
1350
1578
|
const shotsDir = path.join(session.dir, 'shots');
|
|
@@ -1370,7 +1598,9 @@ async function stop(parsed: ParsedArgs): Promise<void> {
|
|
|
1370
1598
|
: [];
|
|
1371
1599
|
const snaps = collectSnaps(session.dir);
|
|
1372
1600
|
const recs = collectRecs(session.dir);
|
|
1373
|
-
const
|
|
1601
|
+
const mocks = collectMocks(session.dir);
|
|
1602
|
+
const reports = collectLighthouseReports(session.dir);
|
|
1603
|
+
const knownDirs = new Set(['shots', 'logs', 'lighthouse', 'measure', 'motion', 'network', '.har', '.stop.lock', '.operations.lock']);
|
|
1374
1604
|
const knownFiles = new Set(['.session.json', '.operations.json', 'har.json', 'bundle.json', 'bridge.sock']);
|
|
1375
1605
|
const other = fs.readdirSync(session.dir)
|
|
1376
1606
|
.filter(name => !knownDirs.has(name) && !knownFiles.has(name))
|
|
@@ -1385,10 +1615,12 @@ async function stop(parsed: ParsedArgs): Promise<void> {
|
|
|
1385
1615
|
shots,
|
|
1386
1616
|
har,
|
|
1387
1617
|
logs,
|
|
1618
|
+
reports,
|
|
1388
1619
|
other,
|
|
1389
1620
|
snaps,
|
|
1390
1621
|
recs,
|
|
1391
|
-
|
|
1622
|
+
mocks,
|
|
1623
|
+
pendingViewportRestorations: [],
|
|
1392
1624
|
};
|
|
1393
1625
|
|
|
1394
1626
|
// The immutable bundle is the commit point. Live HAR deletion, stopped
|
|
@@ -1400,8 +1632,6 @@ async function stop(parsed: ParsedArgs): Promise<void> {
|
|
|
1400
1632
|
committed = true;
|
|
1401
1633
|
|
|
1402
1634
|
const rows = bundleRows(manifest);
|
|
1403
|
-
if (recorderTeardown && 'recId' in recorderTeardown) rows.push(fact`recorder ${recorderTeardown.recId} finalized — state ${recorderTeardown.state}, viewport restored ${String(recorderTeardown.viewportRestored)}`);
|
|
1404
|
-
if (recorderTeardown?.pendingViewportRestorations.length) rows.push(fact`pending viewport restorations: ${recorderTeardown.pendingViewportRestorations.length}`);
|
|
1405
1635
|
emitResult({
|
|
1406
1636
|
tag: 'session-stopped',
|
|
1407
1637
|
attrs: { id, path: bundlePath },
|
|
@@ -1431,14 +1661,14 @@ function list(parsed: ParsedArgs): void {
|
|
|
1431
1661
|
|
|
1432
1662
|
if (rejectSurplusPositionals(parsed, 'list', 0, 'capture session list')) return;
|
|
1433
1663
|
|
|
1434
|
-
const reads = fs.existsSync(
|
|
1435
|
-
? fs.readdirSync(
|
|
1436
|
-
.filter((d) => fs.existsSync(sessionMetaPath(d)))
|
|
1664
|
+
const reads = registeredArtifactRoots().flatMap((artifactRoot) => fs.existsSync(artifactRoot)
|
|
1665
|
+
? fs.readdirSync(artifactRoot)
|
|
1666
|
+
.filter((d) => fs.existsSync(sessionMetaPath(d, artifactRoot)))
|
|
1437
1667
|
.flatMap((d) => {
|
|
1438
1668
|
// A record deleted between readdir and read is simply gone, not unreadable.
|
|
1439
|
-
try { return [tryReadSession(d)]; } catch { return []; }
|
|
1669
|
+
try { return [tryReadSession(d, artifactRoot)]; } catch { return []; }
|
|
1440
1670
|
})
|
|
1441
|
-
: [];
|
|
1671
|
+
: []);
|
|
1442
1672
|
|
|
1443
1673
|
const sessions = reads.flatMap((read) => {
|
|
1444
1674
|
if (!read.ok) return [];
|
|
@@ -1488,12 +1718,17 @@ function sectionRows(manifest: BundleManifest, key: SectionKey): FactLine[] {
|
|
|
1488
1718
|
: [];
|
|
1489
1719
|
case 'logs':
|
|
1490
1720
|
return manifest.logs.map((l) => fact`${l.name} — ${l.lines} lines — ${l.path}`);
|
|
1721
|
+
case 'reports':
|
|
1722
|
+
return manifest.reports.map((r) => fact`${r.id} — url ${r.url} — final URL ${r.finalUrl} — ${r.preset} — categories ${r.categories.join(',')} — ${r.path}`);
|
|
1491
1723
|
case 'snaps':
|
|
1492
1724
|
return manifest.snaps.map((s) =>
|
|
1493
1725
|
fact`${s.id} — url ${s.url ?? '(none)'} — viewport ${s.viewport ?? '(none)'} — settled ${String(s.settled)} — ${s.path}`);
|
|
1494
1726
|
case 'recs':
|
|
1495
1727
|
return manifest.recs.map((r) =>
|
|
1496
1728
|
fact`${r.id} — ${r.action ?? '(none)'} — ${r.frames} frames, ${r.durationMs}ms, ${r.state} — ${r.path}`);
|
|
1729
|
+
case 'mocks':
|
|
1730
|
+
return (manifest.mocks ?? []).map((m) =>
|
|
1731
|
+
fact`${m.id} — ${m.completion} — ${m.matched}/${m.paused} matched/paused, ${m.releasedUnmatched} released unmatched — ${m.path}`);
|
|
1497
1732
|
case 'other':
|
|
1498
1733
|
return manifest.other.map((o) => fact`${o.name} — ${o.path}`);
|
|
1499
1734
|
}
|
|
@@ -1557,7 +1792,7 @@ function view(parsed: ParsedArgs): void {
|
|
|
1557
1792
|
emitResult({
|
|
1558
1793
|
tag: 'error',
|
|
1559
1794
|
attrs: { command: 'session view', code: 'invalid_filter' },
|
|
1560
|
-
summary: fact`received: --filter ${parsed.filter}; expected one of: shots, har, logs, measure, motion, other.`,
|
|
1795
|
+
summary: fact`received: --filter ${parsed.filter}; expected one of: shots, har, logs, lighthouse, measure, motion, mocks, other.`,
|
|
1561
1796
|
followUp: text`Re-run \`capture session view <id> --filter <section>\` with a valid section.`,
|
|
1562
1797
|
}, { json: parsed.json });
|
|
1563
1798
|
process.exitCode = 1;
|
|
@@ -1574,7 +1809,7 @@ function view(parsed: ParsedArgs): void {
|
|
|
1574
1809
|
return;
|
|
1575
1810
|
}
|
|
1576
1811
|
|
|
1577
|
-
const keys: SectionKey[] = ['shots', 'har', 'logs', 'snaps', 'recs', 'other'];
|
|
1812
|
+
const keys: SectionKey[] = ['shots', 'har', 'logs', 'reports', 'snaps', 'recs', 'mocks', 'other'];
|
|
1578
1813
|
const sections = keys.map((key) => {
|
|
1579
1814
|
const rows = sectionRows(manifest, key);
|
|
1580
1815
|
return lineList([
|