@crouton-kit/capture 1.3.8 → 1.3.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/bin/capture +29841 -8539
- package/package.json +6 -3
- package/src/capture.ts +86 -173
- package/src/cdp/a11y.ts +113 -14
- package/src/cdp/args.ts +300 -205
- package/src/cdp/bridge/protocol.ts +93 -6
- package/src/cdp/bridge/server.ts +118 -53
- package/src/cdp/bridge/spawn.ts +53 -6
- package/src/cdp/client.ts +13 -3
- package/src/cdp/commands/bridge-serve.ts +20 -14
- package/src/cdp/commands/cdp.ts +259 -90
- package/src/cdp/commands/gate-guard.ts +43 -0
- package/src/cdp/commands/lib.ts +339 -213
- package/src/cdp/commands/measure/census.ts +76 -20
- package/src/cdp/commands/measure/check.ts +100 -26
- package/src/cdp/commands/measure/diff.ts +109 -21
- package/src/cdp/commands/measure/explain.ts +121 -19
- package/src/cdp/commands/measure/index.ts +31 -39
- package/src/cdp/commands/measure/map-ax.ts +52 -0
- package/src/cdp/commands/measure/map-focus.ts +41 -13
- package/src/cdp/commands/measure/map-layers.ts +49 -15
- package/src/cdp/commands/measure/map-scroll.ts +85 -15
- package/src/cdp/commands/measure/snap.ts +405 -25
- package/src/cdp/commands/measure/sweep.ts +217 -20
- package/src/cdp/commands/motion/index.ts +18 -17
- package/src/cdp/commands/motion/jank.ts +119 -9
- package/src/cdp/commands/motion/mask.ts +81 -12
- package/src/cdp/commands/motion/rec.ts +520 -19
- package/src/cdp/commands/motion/response.ts +65 -15
- package/src/cdp/commands/motion/timeline.ts +139 -14
- package/src/cdp/commands/page/click.ts +194 -0
- package/src/cdp/commands/page/elements.ts +154 -0
- package/src/cdp/commands/page/exec.ts +317 -0
- package/src/cdp/commands/page/index.ts +72 -0
- package/src/cdp/commands/page/navigate.ts +330 -0
- package/src/cdp/commands/page/scroll.ts +107 -0
- package/src/cdp/commands/page/shot.ts +208 -0
- package/src/cdp/commands/page/type.ts +104 -0
- package/src/cdp/commands/tab/index.ts +59 -0
- package/src/cdp/commands/tab/list.ts +167 -0
- package/src/cdp/commands/tab/network.ts +162 -0
- package/src/cdp/commands/tab/open.ts +90 -0
- package/src/cdp/commands/tab/reset.ts +160 -0
- package/src/cdp/connection.ts +368 -37
- package/src/cdp/detect.ts +42 -13
- package/src/cdp/dispatch.ts +74 -28
- package/src/cdp/har-recorder.ts +648 -115
- package/src/cdp/leaf-grammar.ts +99 -0
- package/src/cdp/measure/census.ts +205 -0
- package/src/cdp/measure/check.ts +318 -0
- package/src/cdp/measure/collectors/animation.ts +385 -0
- package/src/cdp/measure/collectors/ax.ts +278 -0
- package/src/cdp/measure/collectors/focus.ts +1041 -0
- package/src/cdp/measure/collectors/forms.ts +905 -0
- package/src/cdp/measure/collectors/geometry.ts +1038 -0
- package/src/cdp/measure/collectors/hittest.ts +835 -0
- package/src/cdp/measure/collectors/layers.ts +697 -0
- package/src/cdp/measure/collectors/media.ts +522 -0
- package/src/cdp/measure/collectors/pixels.ts +1584 -0
- package/src/cdp/measure/collectors/queries.ts +464 -0
- package/src/cdp/measure/collectors/scroll.ts +599 -0
- package/src/cdp/measure/collectors/states.ts +992 -0
- package/src/cdp/measure/collectors/style-provenance.ts +643 -0
- package/src/cdp/measure/collectors/styles.ts +355 -0
- package/src/cdp/measure/collectors/text.ts +713 -0
- package/src/cdp/measure/diff.ts +470 -0
- package/src/cdp/measure/explain.ts +498 -0
- package/src/cdp/measure/map-ax.ts +633 -0
- package/src/cdp/measure/map-focus.ts +188 -0
- package/src/cdp/measure/map-layers.ts +177 -0
- package/src/cdp/measure/map-scroll.ts +317 -0
- package/src/cdp/measure/redaction.ts +51 -0
- package/src/cdp/measure/settle.ts +1051 -0
- package/src/cdp/measure/snapshot.ts +390 -0
- package/src/cdp/measure/sweep.ts +330 -0
- package/src/cdp/measure/types.ts +220 -0
- package/src/cdp/measure/v2/collectors.ts +19 -0
- package/src/cdp/measure/v2/orchestrator.ts +46 -0
- package/src/cdp/measure/v2/snap-selection.ts +33 -0
- package/src/cdp/measure/v2/target-lock.ts +15 -0
- package/src/cdp/measure/v2/target.ts +16 -0
- package/src/cdp/motion/jank.ts +433 -0
- package/src/cdp/motion/mask.ts +421 -0
- package/src/cdp/motion/recorder.ts +764 -0
- package/src/cdp/motion/response.ts +356 -0
- package/src/cdp/motion/timeline.ts +215 -0
- package/src/cdp/record.ts +5 -212
- package/src/cdp/recorder-bridge.ts +1478 -105
- package/src/cdp/recorder-client.ts +326 -0
- package/src/cdp/scope-lock.ts +73 -0
- package/src/cdp/screenshot.ts +199 -66
- package/src/cdp/targets.ts +21 -1
- package/src/cdp/timing.ts +15 -10
- package/src/cdp/types.ts +22 -7
- package/src/cdp/viewport.ts +21 -0
- package/src/cdp.ts +12 -58
- package/src/command-help.ts +29 -0
- package/src/command-resolution.ts +22 -0
- package/src/command-walker.ts +35 -0
- package/src/contracts/a11y.ts +33 -0
- package/src/contracts/cursor.ts +35 -0
- package/src/contracts/index.ts +15 -0
- package/src/contracts/motion.ts +19 -0
- package/src/contracts/neutral.ts +56 -0
- package/src/contracts/primitives.ts +205 -0
- package/src/contracts/release.ts +17 -0
- package/src/contracts/results.ts +208 -0
- package/src/contracts/routes.ts +140 -0
- package/src/contracts/snapshot.ts +92 -0
- package/src/cursor/index.ts +13 -0
- package/src/cursor/service.ts +310 -0
- package/src/errors.ts +71 -0
- package/src/har-manager.ts +992 -41
- package/src/interact.ts +410 -98
- package/src/output/artifact-lifecycle.ts +371 -0
- package/src/output/artifact.ts +6 -0
- package/src/output/exact-raw.ts +114 -0
- package/src/output/render.ts +396 -2
- package/src/output/structured.ts +211 -0
- package/src/registry.ts +208 -0
- package/src/session/artifacts.ts +452 -200
- package/src/session/commands.ts +1162 -397
- package/src/session/coordinator.ts +251 -0
- package/src/session/har-filter.ts +57 -0
- package/src/session/log-tailer.ts +653 -0
- package/src/session-context.ts +279 -51
- package/src/vault/bundle.ts +32 -17
- package/src/cdp/commands/exec.ts +0 -105
- package/src/cdp/commands/har.ts +0 -128
- package/src/cdp/commands/tabs.ts +0 -183
- package/src/cdp/commands/traffic.ts +0 -121
- package/src/cdp/commands/ui.ts +0 -172
- package/src/cdp/execute.ts +0 -113
- package/src/cdp/har-output.ts +0 -47
- package/src/cdp/usage.ts +0 -79
- package/src/tab-session.ts +0 -91
package/src/session/commands.ts
CHANGED
|
@@ -1,46 +1,56 @@
|
|
|
1
1
|
import * as fs from 'fs';
|
|
2
2
|
import * as path from 'path';
|
|
3
|
-
import { spawn } from 'child_process';
|
|
4
3
|
import {
|
|
5
4
|
createHarRecording,
|
|
6
5
|
readHarRecording,
|
|
7
6
|
deleteHarRecording,
|
|
7
|
+
harFilePath,
|
|
8
|
+
validateHarFile,
|
|
9
|
+
type HarFile,
|
|
10
|
+
type HAREntry,
|
|
8
11
|
} from '../har-manager.js';
|
|
9
12
|
import {
|
|
10
13
|
getActiveSession,
|
|
11
14
|
setActiveSession,
|
|
12
|
-
|
|
15
|
+
clearActiveSessionIf,
|
|
16
|
+
updateSessionState,
|
|
17
|
+
isActiveStateCandidate,
|
|
18
|
+
type ActiveSessionState,
|
|
13
19
|
} from '../session-context.js';
|
|
14
|
-
import {
|
|
20
|
+
import { type ParsedArgs, type CDPTarget } from '../cdp/types.js';
|
|
15
21
|
import { startBridge, stopBridge } from '../cdp/bridge/spawn.js';
|
|
22
|
+
import { normalizeFailure, failureResult, captureError, worldFailure } from '../errors.js';
|
|
23
|
+
import {
|
|
24
|
+
startSessionLogTailer,
|
|
25
|
+
stopSessionLogTailers,
|
|
26
|
+
} from './log-tailer.js';
|
|
27
|
+
import { teardownAnyLiveRecorderAtSessionStop, isTerminalRecStopFailure } from '../cdp/motion/recorder.js';
|
|
28
|
+
import {
|
|
29
|
+
emitResult,
|
|
30
|
+
fact,
|
|
31
|
+
text,
|
|
32
|
+
line,
|
|
33
|
+
lineList,
|
|
34
|
+
data,
|
|
35
|
+
capped,
|
|
36
|
+
type FactLine,
|
|
37
|
+
} from '../output/render.js';
|
|
16
38
|
import {
|
|
17
39
|
CAPTURE_ROOT,
|
|
18
|
-
|
|
40
|
+
MAX_LOG_LABEL_BYTES,
|
|
41
|
+
acquirePrivateLock,
|
|
19
42
|
ensurePrivateDir,
|
|
43
|
+
rejectLogLabel,
|
|
20
44
|
writeJsonPrivate,
|
|
45
|
+
readPrivateFile,
|
|
46
|
+
removeArtifactTree,
|
|
21
47
|
type SnapMeta,
|
|
22
48
|
type RecMeta,
|
|
23
49
|
} from './artifacts.js';
|
|
50
|
+
import { beginSessionStop, admitSessionOperation, withSessionLifecycle } from './coordinator.js';
|
|
51
|
+
import { parseStatusFilter, type StatusPredicate } from './har-filter.js';
|
|
24
52
|
|
|
25
|
-
|
|
26
|
-
pid: number;
|
|
27
|
-
name: string;
|
|
28
|
-
sourcePath: string;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
interface Session {
|
|
32
|
-
id: string;
|
|
33
|
-
dir: string;
|
|
34
|
-
harId: string | null;
|
|
35
|
-
startedAt: string;
|
|
36
|
-
url: string | null;
|
|
37
|
-
targetId: string | null;
|
|
38
|
-
stepCount: number;
|
|
39
|
-
logPids: LogPid[];
|
|
40
|
-
/** Set when started with --hold: one CDP browser connection held open for the session. */
|
|
41
|
-
bridgeSocket: string | null;
|
|
42
|
-
bridgePid: number | null;
|
|
43
|
-
}
|
|
53
|
+
type Session = ActiveSessionState;
|
|
44
54
|
|
|
45
55
|
interface BundleManifest {
|
|
46
56
|
id: string;
|
|
@@ -48,47 +58,147 @@ interface BundleManifest {
|
|
|
48
58
|
stoppedAt: string;
|
|
49
59
|
duration: number;
|
|
50
60
|
url: string | null;
|
|
51
|
-
|
|
61
|
+
shots: Array<{ name: string; path: string }>;
|
|
52
62
|
har: { id: string; path: string; entryCount: number } | null;
|
|
53
|
-
a11y: Array<{ name: string; path: string }>;
|
|
54
63
|
logs: Array<{ name: string; path: string; lines: number }>;
|
|
55
64
|
other: Array<{ name: string; path: string }>;
|
|
56
65
|
/** `measure snap` artifacts collected from `measure/snaps/{id}/meta.json`. */
|
|
57
66
|
snaps: Array<{ id: string; path: string; url: string | null; viewport: string | null; settled: boolean; capturedAt: string }>;
|
|
58
67
|
/** `motion rec` artifacts collected from `motion/recs/{id}/meta.json`. */
|
|
59
|
-
recs: Array<{ id: string; path: string; action: string | null; frames: number; durationMs: number; state: string }>;
|
|
68
|
+
recs: Array<{ id: string; path: string; action: string | null; frames: number; durationMs: number; state: string; viewportRestored: boolean | null }>;
|
|
69
|
+
/** Retry outcomes for viewport obligations retained by failed recorder starts. */
|
|
70
|
+
pendingViewportRestorations: Array<{ recId: string; viewportRestored: boolean | null }>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function isNamedPathEntry(value: unknown): value is { name: string; path: string } {
|
|
74
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
|
75
|
+
const record = value as Record<string, unknown>;
|
|
76
|
+
return typeof record.name === 'string' && typeof record.path === 'string';
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The one schema check for a persisted `bundle.json` manifest. A manifest is
|
|
80
|
+
* committed once and immutable, so any record failing this shape was written
|
|
81
|
+
* by an incompatible version or corrupted — never something to structurally
|
|
82
|
+
* trust into the renderer. */
|
|
83
|
+
function isBundleManifest(value: unknown): value is BundleManifest {
|
|
84
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
|
85
|
+
const record = value as Record<string, unknown>;
|
|
86
|
+
const validHar = record.har === null
|
|
87
|
+
|| (!!record.har && typeof record.har === 'object' && !Array.isArray(record.har)
|
|
88
|
+
&& typeof (record.har as Record<string, unknown>).id === 'string'
|
|
89
|
+
&& typeof (record.har as Record<string, unknown>).path === 'string'
|
|
90
|
+
&& typeof (record.har as Record<string, unknown>).entryCount === 'number');
|
|
91
|
+
return typeof record.id === 'string'
|
|
92
|
+
&& typeof record.startedAt === 'string'
|
|
93
|
+
&& typeof record.stoppedAt === 'string'
|
|
94
|
+
&& typeof record.duration === 'number'
|
|
95
|
+
&& (record.url === null || typeof record.url === 'string')
|
|
96
|
+
&& Array.isArray(record.shots) && record.shots.every(isNamedPathEntry)
|
|
97
|
+
&& validHar
|
|
98
|
+
&& Array.isArray(record.logs) && record.logs.every(item => isNamedPathEntry(item) && typeof (item as unknown as Record<string, unknown>).lines === 'number')
|
|
99
|
+
&& Array.isArray(record.other) && record.other.every(isNamedPathEntry)
|
|
100
|
+
&& 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')
|
|
101
|
+
&& 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')
|
|
102
|
+
&& Array.isArray(record.pendingViewportRestorations);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Reads and VALIDATES one immutable `bundle.json`. The read is contained and
|
|
106
|
+
* no-follow (`readPrivateFile`); ENOENT propagates for callers that treat a
|
|
107
|
+
* missing bundle as "not stopped yet". Unparsable bytes or a schema failure
|
|
108
|
+
* throw a typed `invalid_bundle_manifest` artifact error instead of letting a
|
|
109
|
+
* structurally-trusted cast reach the renderer. */
|
|
110
|
+
function readBundleManifest(bundlePath: string): BundleManifest {
|
|
111
|
+
const raw = readPrivateFile(bundlePath).toString('utf-8');
|
|
112
|
+
let parsed: unknown;
|
|
113
|
+
try {
|
|
114
|
+
parsed = JSON.parse(raw);
|
|
115
|
+
} catch (cause) {
|
|
116
|
+
throw captureError('artifact', 'invalid_bundle_manifest', `bundle manifest at ${bundlePath} is not valid JSON; the record is corrupt and cannot be read as a capture bundle.`, cause);
|
|
117
|
+
}
|
|
118
|
+
if (!isBundleManifest(parsed)) {
|
|
119
|
+
throw captureError('artifact', 'invalid_bundle_manifest', `bundle manifest at ${bundlePath} does not match the capture bundle schema; it was written by an incompatible version or corrupted.`);
|
|
120
|
+
}
|
|
121
|
+
return parsed;
|
|
60
122
|
}
|
|
61
123
|
|
|
124
|
+
/** The bundle-manifest section keys `session view --filter` can address. */
|
|
125
|
+
type SectionKey = 'shots' | 'har' | 'logs' | 'snaps' | 'recs' | 'other';
|
|
126
|
+
|
|
127
|
+
// The `--filter <name>` query names map onto manifest section keys. Most are
|
|
128
|
+
// identical; `measure`/`motion` are the query-facing names for the `snaps`/
|
|
129
|
+
// `recs` keys (matching the `measure`/`motion` command branches rather than
|
|
130
|
+
// the artifact-file naming), so they get an explicit mapping.
|
|
131
|
+
const VIEW_FILTERS: Record<string, SectionKey> = {
|
|
132
|
+
shots: 'shots',
|
|
133
|
+
har: 'har',
|
|
134
|
+
logs: 'logs',
|
|
135
|
+
measure: 'snaps',
|
|
136
|
+
motion: 'recs',
|
|
137
|
+
other: 'other',
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
/** User-facing label for each manifest section in the unfiltered view. */
|
|
141
|
+
const SECTION_LABELS: Record<SectionKey, string> = {
|
|
142
|
+
shots: 'shots',
|
|
143
|
+
har: 'har',
|
|
144
|
+
logs: 'logs',
|
|
145
|
+
snaps: 'measure',
|
|
146
|
+
recs: 'motion',
|
|
147
|
+
other: 'other',
|
|
148
|
+
};
|
|
149
|
+
|
|
62
150
|
function sessionDir(id: string): string {
|
|
63
151
|
return path.join(CAPTURE_ROOT, id);
|
|
64
152
|
}
|
|
65
153
|
|
|
154
|
+
function lifecycleScopeLockPath(): string {
|
|
155
|
+
return path.join(CAPTURE_ROOT, `.session-lifecycle-${process.env.CRTR_NODE_ID ?? 'default'}`);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function withLifecycleCoordinator<T>(action: () => Promise<T>): Promise<T> {
|
|
159
|
+
const handle = await acquirePrivateLock(lifecycleScopeLockPath(), {
|
|
160
|
+
acquireTimeoutMs: 120_000,
|
|
161
|
+
leaseMs: 1_000,
|
|
162
|
+
});
|
|
163
|
+
try {
|
|
164
|
+
return await action();
|
|
165
|
+
} finally {
|
|
166
|
+
handle.release();
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function isSessionStopped(session: Session): boolean {
|
|
171
|
+
return Boolean(session.stoppedAt) || fs.existsSync(path.join(session.dir, 'bundle.json'));
|
|
172
|
+
}
|
|
173
|
+
|
|
66
174
|
/** One-shot artifact session outside an active session — see `createOneshotSession`. */
|
|
67
175
|
export interface OneshotSession {
|
|
68
176
|
/** `oneshot-{id}`; also the dir name under `CAPTURE_ROOT`. */
|
|
69
177
|
id: string;
|
|
70
178
|
/** `{CAPTURE_ROOT}/oneshot-{id}` */
|
|
71
179
|
dir: string;
|
|
72
|
-
kind: 'measure' | 'motion';
|
|
73
|
-
/** `{dir}/measure/snaps
|
|
180
|
+
kind: 'measure' | 'motion' | 'page';
|
|
181
|
+
/** `{dir}/measure/snaps`, `{dir}/motion/recs`, or `{dir}/page`, already created private. */
|
|
74
182
|
artifactsDir: string;
|
|
75
183
|
}
|
|
76
184
|
|
|
77
185
|
/**
|
|
78
|
-
* Creates the ephemeral artifact dir a URL-target `measure`/`motion`
|
|
79
|
-
* writes into when there is no active session: `oneshot-{id}/measure/snaps
|
|
80
|
-
*
|
|
81
|
-
* subtree the caller needs — no HAR, no held bridge, no
|
|
82
|
-
* and is never registered as the active session. It is not
|
|
83
|
-
* down by `session stop`; it accumulates under `/tmp` the same as
|
|
84
|
-
* session dir until the OS reaps `/tmp`.
|
|
186
|
+
* Creates the ephemeral artifact dir a URL-target `measure`/`motion`/`page`
|
|
187
|
+
* leaf writes into when there is no active session: `oneshot-{id}/measure/snaps`,
|
|
188
|
+
* `oneshot-{id}/motion/recs`, or `oneshot-{id}/page` under `CAPTURE_ROOT`.
|
|
189
|
+
* Holds only the one subtree the caller needs — no HAR, no held bridge, no
|
|
190
|
+
* `.session.json` — and is never registered as the active session. It is not
|
|
191
|
+
* bundled/torn down by `session stop`; it accumulates under `/tmp` the same as
|
|
192
|
+
* any other session dir until the OS reaps `/tmp`.
|
|
85
193
|
*/
|
|
86
|
-
export function createOneshotSession(kind: 'measure' | 'motion'): OneshotSession {
|
|
194
|
+
export function createOneshotSession(kind: 'measure' | 'motion' | 'page'): OneshotSession {
|
|
87
195
|
const id = `oneshot-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
88
196
|
const dir = sessionDir(id);
|
|
89
197
|
const artifactsDir = kind === 'measure'
|
|
90
198
|
? path.join(dir, 'measure', 'snaps')
|
|
91
|
-
:
|
|
199
|
+
: kind === 'motion'
|
|
200
|
+
? path.join(dir, 'motion', 'recs')
|
|
201
|
+
: path.join(dir, 'page');
|
|
92
202
|
ensurePrivateDir(artifactsDir);
|
|
93
203
|
return { id, dir, kind, artifactsDir };
|
|
94
204
|
}
|
|
@@ -116,6 +226,7 @@ function readRecMeta(recDir: string, fallbackId: string): BundleManifest['recs']
|
|
|
116
226
|
frames: meta.frames ?? 0,
|
|
117
227
|
durationMs: meta.durationMs ?? 0,
|
|
118
228
|
state: meta.state ?? 'unknown',
|
|
229
|
+
viewportRestored: typeof meta.viewportRestored === 'boolean' ? meta.viewportRestored : null,
|
|
119
230
|
};
|
|
120
231
|
}
|
|
121
232
|
|
|
@@ -141,22 +252,40 @@ function sessionMetaPath(id: string): string {
|
|
|
141
252
|
return path.join(sessionDir(id), '.session.json');
|
|
142
253
|
}
|
|
143
254
|
|
|
255
|
+
/**
|
|
256
|
+
* Reads and VALIDATES one persisted `.session.json`. A missing file throws a
|
|
257
|
+
* typed `unknown_session` precondition error (leaf catch-alls emit it as
|
|
258
|
+
* `unknown_session`); unparsable bytes or a record failing the shared
|
|
259
|
+
* `isActiveStateCandidate` schema throw a typed `invalid_session_record`
|
|
260
|
+
* artifact error instead of letting a structurally-trusted cast reach the
|
|
261
|
+
* renderer (where a legacy-schema record used to crash `session list`
|
|
262
|
+
* unbranded via `fact` interpolation of undefined fields).
|
|
263
|
+
*/
|
|
144
264
|
function readSession(id: string): Session {
|
|
145
265
|
const metaPath = sessionMetaPath(id);
|
|
146
|
-
|
|
147
|
-
|
|
266
|
+
let raw: string;
|
|
267
|
+
try {
|
|
268
|
+
raw = readPrivateFile(metaPath).toString('utf-8');
|
|
269
|
+
} catch (error) {
|
|
270
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') throw captureError('precondition', 'unknown_session', `No capture session found: ${id}`);
|
|
271
|
+
throw error;
|
|
272
|
+
}
|
|
273
|
+
let parsed: unknown;
|
|
274
|
+
try {
|
|
275
|
+
parsed = JSON.parse(raw);
|
|
276
|
+
} catch (cause) {
|
|
277
|
+
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);
|
|
278
|
+
}
|
|
279
|
+
if (!isActiveStateCandidate(parsed)) {
|
|
280
|
+
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.`);
|
|
148
281
|
}
|
|
149
|
-
return
|
|
282
|
+
return parsed;
|
|
150
283
|
}
|
|
151
284
|
|
|
152
285
|
function generateId(): string {
|
|
153
286
|
return `cap-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
|
|
154
287
|
}
|
|
155
288
|
|
|
156
|
-
function hasHelpFlag(args: string[]): boolean {
|
|
157
|
-
return args.some((arg) => arg === '-h' || arg === '--help');
|
|
158
|
-
}
|
|
159
|
-
|
|
160
289
|
export async function waitForPageLoad(
|
|
161
290
|
client: { waitReady(): Promise<void>; send(method: string, params?: Record<string, unknown>): Promise<unknown>; on(event: string, handler: (params: unknown) => void): void; },
|
|
162
291
|
timeoutMs: number,
|
|
@@ -166,458 +295,1094 @@ export async function waitForPageLoad(
|
|
|
166
295
|
|
|
167
296
|
return await new Promise<boolean>((resolve) => {
|
|
168
297
|
const timer = setTimeout(() => resolve(true), timeoutMs);
|
|
169
|
-
|
|
298
|
+
const done = (timedOut: boolean) => {
|
|
170
299
|
clearTimeout(timer);
|
|
171
|
-
resolve(
|
|
172
|
-
}
|
|
300
|
+
resolve(timedOut);
|
|
301
|
+
};
|
|
302
|
+
client.on('Page.loadEventFired', () => done(false));
|
|
303
|
+
// A page that finished loading before we attached (about:blank, cached
|
|
304
|
+
// instant loads) never fires loadEventFired — check readyState directly.
|
|
305
|
+
client
|
|
306
|
+
.send('Runtime.evaluate', {
|
|
307
|
+
expression: 'document.readyState',
|
|
308
|
+
returnByValue: true,
|
|
309
|
+
})
|
|
310
|
+
.then((r) => {
|
|
311
|
+
const value = (r as { result?: { value?: unknown } } | undefined)?.result?.value;
|
|
312
|
+
if (value === 'complete') done(false);
|
|
313
|
+
})
|
|
314
|
+
.catch(() => {});
|
|
173
315
|
});
|
|
174
316
|
}
|
|
175
317
|
|
|
318
|
+
/** Root-help representation of this branch, assembled by `src/capture.ts`. */
|
|
319
|
+
export const COMMAND_BLOCK = `<command name="session">
|
|
320
|
+
the artifact container — a session opens a tab, records HAR, and bundles every artifact; while active, every command auto-targets its tab
|
|
321
|
+
use when starting scoped work against a page: start first, then every other capture command needs no --target/--port threading
|
|
322
|
+
start · stop · list · view · har · log — \`capture session -h\`
|
|
323
|
+
</command>`;
|
|
324
|
+
|
|
325
|
+
const START_USAGE = `capture session start [--url <url>] [--hold] — open a tab, record HAR, and set the active capture context.
|
|
326
|
+
|
|
327
|
+
input:
|
|
328
|
+
--url <url> Absolute URL to open in a fresh tab. Omit to start a session
|
|
329
|
+
with no tab (HAR-only until a later command targets one).
|
|
330
|
+
--hold Hold one CDP browser connection open for the session's
|
|
331
|
+
lifetime, so browser-level state (permission grants,
|
|
332
|
+
ServiceWorker enablement) survives across separate commands.
|
|
333
|
+
--port <n> CDP port to attach to; default is auto-detected across localhost.
|
|
334
|
+
|
|
335
|
+
Output:
|
|
336
|
+
One <session id=… path=… [url=…]> block: the session id, bundle dir, opened
|
|
337
|
+
tab, HAR recording id, and held-bridge pid when applicable. --json mirrors
|
|
338
|
+
the same fields.
|
|
339
|
+
|
|
340
|
+
effects:
|
|
341
|
+
Creates a private session dir with shots/, starts a HAR recording, opens the
|
|
342
|
+
tab (with --url), optionally holds a CDP bridge, and registers the session as
|
|
343
|
+
the active capture context so subsequent commands auto-target it.`;
|
|
344
|
+
|
|
345
|
+
const STOP_USAGE = `capture session stop <session-id> — finalize a session and write its bundle manifest.
|
|
346
|
+
|
|
347
|
+
input:
|
|
348
|
+
<session-id> The session to stop (from \`capture session list\`).
|
|
349
|
+
|
|
350
|
+
Output:
|
|
351
|
+
One <session-stopped id=… path=…> block: the bundle manifest path plus
|
|
352
|
+
counts (shots, HAR entries, logs, measure snaps, motion recs, other). --json
|
|
353
|
+
mirrors the same fields.
|
|
354
|
+
|
|
355
|
+
effects:
|
|
356
|
+
Kills log tailers, tears down the held CDP bridge, finalizes any live
|
|
357
|
+
recorder, collects every artifact into bundle.json, and clears the active
|
|
358
|
+
capture context.`;
|
|
359
|
+
|
|
360
|
+
const LIST_USAGE = `capture session list — list active and stopped capture sessions.
|
|
361
|
+
|
|
362
|
+
input:
|
|
363
|
+
(none)
|
|
364
|
+
|
|
365
|
+
output:
|
|
366
|
+
One <sessions count=…> block, one row per session: id, status
|
|
367
|
+
(active|stopped), start time, and URL when set. --json mirrors the same rows.
|
|
368
|
+
|
|
369
|
+
effects:
|
|
370
|
+
None — reads session metadata only.`;
|
|
371
|
+
|
|
372
|
+
const VIEW_USAGE = `capture session view <session-id> [--filter <section>] — read back a stopped session's bundle manifest.
|
|
373
|
+
|
|
374
|
+
input:
|
|
375
|
+
<session-id> A stopped session (from \`capture session list\`).
|
|
376
|
+
--filter <section> Show only one section: shots, har, logs, measure,
|
|
377
|
+
motion, or other. measure -> manifest snaps, motion ->
|
|
378
|
+
manifest recs.
|
|
379
|
+
|
|
380
|
+
Output:
|
|
381
|
+
One <session id=… path=… [filter=…]> block: the manifest sections (shots,
|
|
382
|
+
har, logs, measure snaps, motion recs, other), or a single section under
|
|
383
|
+
--filter. --json mirrors the same fields.
|
|
384
|
+
|
|
385
|
+
effects:
|
|
386
|
+
None — reads bundle.json only.`;
|
|
387
|
+
|
|
388
|
+
const HAR_USAGE = `capture session har [<session-id>] — read a session's recorded HTTP traffic as a selection list.
|
|
389
|
+
|
|
390
|
+
input:
|
|
391
|
+
<session-id> session to read; defaults to the active session. A
|
|
392
|
+
running session reads its live accumulating HAR; a
|
|
393
|
+
stopped one reads the bundled har.json.
|
|
394
|
+
--filter-url <pattern> substring or regex match on the request URL
|
|
395
|
+
--filter-status <spec> exact status (100-599), one-digit class prefix
|
|
396
|
+
(1-5, e.g. 4 = every 4xx), or ordered range
|
|
397
|
+
(e.g. 400-499); any other token is invalid_filter
|
|
398
|
+
--filter-method <method> HTTP method (GET, POST, …)
|
|
399
|
+
--limit <n> first n matching entries
|
|
400
|
+
--full inline per-entry detail (headers, post data,
|
|
401
|
+
response body — escaped and capped); bodies are
|
|
402
|
+
never inlined without it
|
|
403
|
+
|
|
404
|
+
output:
|
|
405
|
+
One <session-har id=… path=… source=live|bundle entries=… total=…> block,
|
|
406
|
+
one row per entry: method, status, URL, body size, start time. The path
|
|
407
|
+
attribute is the HAR file's absolute path — the full-fidelity pointer.
|
|
408
|
+
--json mirrors the same fields. WebSockets opened while a command was
|
|
409
|
+
recording appear as entries with _resourceType "websocket" and their frames
|
|
410
|
+
in _webSocketMessages (capped at 200 frames/socket, 4KB/frame); sockets
|
|
411
|
+
opened before recording started are not visible.
|
|
412
|
+
|
|
413
|
+
effects:
|
|
414
|
+
None — reads recorded HAR data only.`;
|
|
415
|
+
|
|
416
|
+
const LOG_USAGE = `capture session log <path> [--name <label>] [--session <id>] — tail an external log file into a session's logs/ dir.
|
|
417
|
+
|
|
418
|
+
input:
|
|
419
|
+
<path> log file to follow (must exist). Any filename is accepted — it
|
|
420
|
+
is only ever passed to \`tail\` as an argv element, never a shell.
|
|
421
|
+
--name <label> destination label; default is the source file's basename. One
|
|
422
|
+
bounded path component: no \`/\`, no \`.\`/\`..\`, no NUL, ≤ ${MAX_LOG_LABEL_BYTES} bytes.
|
|
423
|
+
--session <id> target session; defaults to the active session
|
|
424
|
+
|
|
425
|
+
output:
|
|
426
|
+
One <log-tail session=… path=…> block: tailer name, source path, destination
|
|
427
|
+
path, and tailer pid. --json mirrors the same fields.
|
|
428
|
+
|
|
429
|
+
effects:
|
|
430
|
+
Self-spawns one detached, identity-owned worker (its own process group) that
|
|
431
|
+
runs \`tail -f\` and appends ISO-8601-timestamped lines to the session's
|
|
432
|
+
contained logs/<name>.log. \`session stop\` drains it over its nonce-authenticated
|
|
433
|
+
control socket so its buffered output lands before the bundle is committed;
|
|
434
|
+
the identity-bearing tailer record is registered in the session metadata.`;
|
|
435
|
+
|
|
176
436
|
function printSessionHelp(): void {
|
|
177
|
-
console.log(`capture session —
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
1. capture session start --url http://localhost:3000
|
|
192
|
-
2. Interact — no --target / --har needed:
|
|
193
|
-
capture a11y --interactive
|
|
194
|
-
capture click "Sign in"
|
|
195
|
-
capture type "hi@me.com" --into "Email"
|
|
196
|
-
capture screenshot
|
|
197
|
-
capture navigate https://app.example.com/dashboard
|
|
198
|
-
capture har read --filter-url /api
|
|
199
|
-
3. capture session stop <session-id>
|
|
200
|
-
4. capture session view <session-id>
|
|
201
|
-
|
|
202
|
-
--hold: keeps one CDP browser connection open for the session's lifetime, so
|
|
203
|
-
browser-level state (Browser.grantPermissions, ServiceWorker.enable, ...)
|
|
204
|
-
survives across separate commands instead of reverting the instant each
|
|
205
|
-
command's own connection closes. Use it together with \`capture cdp --browser\`:
|
|
206
|
-
|
|
207
|
-
capture session start --url http://localhost:3000 --hold
|
|
208
|
-
capture cdp Browser.grantPermissions --browser --params '{"origin":"http://localhost:3000","permissions":["notifications"]}'
|
|
209
|
-
capture cdp ServiceWorker.enable --browser --target <pageTabId>
|
|
210
|
-
capture cdp ServiceWorker.deliverPushMessage --browser --target <pageTabId> --params '{...}'
|
|
211
|
-
capture session stop <session-id> # also tears down the held connection
|
|
212
|
-
|
|
213
|
-
Related: capture log <path> [--name label] Tail a log into the active session
|
|
214
|
-
See also: capture --help Full command list`);
|
|
437
|
+
console.log(`capture session — the artifact container: opens a tab, records HAR, bundles every artifact.
|
|
438
|
+
|
|
439
|
+
An active session auto-targets its tab and auto-appends recorded traffic — no
|
|
440
|
+
--target/--har threading. \`stop\` finalizes the session and writes its bundle
|
|
441
|
+
manifest; \`view\` reads that manifest back.
|
|
442
|
+
|
|
443
|
+
<subcommand name="start" args="[--url <url>] [--hold]" whenToUse="open a tab, start HAR, and set the active capture context"/>
|
|
444
|
+
<subcommand name="stop" args="<session-id>" whenToUse="finalize the session and write its bundle manifest"/>
|
|
445
|
+
<subcommand name="list" args="" whenToUse="show active and stopped sessions"/>
|
|
446
|
+
<subcommand name="view" args="<session-id> [--filter shots|har|logs|measure|motion|other]" whenToUse="read back a stopped session's bundle manifest"/>
|
|
447
|
+
<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"/>
|
|
448
|
+
<subcommand name="log" args="<path> [--name <label>] [--session <id>]" whenToUse="tail an external log file into the session's logs/ dir"/>
|
|
449
|
+
|
|
450
|
+
capture session <leaf> -h Per-leaf usage`);
|
|
215
451
|
}
|
|
216
452
|
|
|
217
453
|
// ============================================================================
|
|
218
454
|
// Session Commands
|
|
219
455
|
// ============================================================================
|
|
220
456
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
457
|
+
/**
|
|
458
|
+
* The external effects `session start` acquires, behind one seam so the
|
|
459
|
+
* transaction's acquisition/rollback ordering is testable with deterministic
|
|
460
|
+
* fakes instead of a real browser. The default (`productionStartWorld`) wires
|
|
461
|
+
* these to the real CDP layer.
|
|
462
|
+
*/
|
|
463
|
+
export interface SessionStartWorld {
|
|
464
|
+
createHar(dir: string): Promise<string>;
|
|
465
|
+
deleteHar(harId: string): Promise<void>;
|
|
466
|
+
detectCdpPort(): Promise<number>;
|
|
467
|
+
openTab(port: number, url: string): Promise<CDPTarget>;
|
|
468
|
+
closeTarget(port: number, targetId: string): Promise<void>;
|
|
469
|
+
/** Attach to the freshly-opened tab and wait for load; returns page-load-timed-out. */
|
|
470
|
+
awaitTabReady(target: CDPTarget, url: string): Promise<boolean>;
|
|
471
|
+
startBridge(dir: string, port: number): Promise<{ socketPath: string; pid: number }>;
|
|
472
|
+
stopBridge(pid: number | null, socketPath: string | null): void;
|
|
473
|
+
publishActiveSession(session: Session): Promise<void>;
|
|
474
|
+
}
|
|
225
475
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
476
|
+
const productionStartWorld: SessionStartWorld = {
|
|
477
|
+
async createHar(dir) {
|
|
478
|
+
return (await createHarRecording(dir)).id;
|
|
479
|
+
},
|
|
480
|
+
deleteHar: deleteHarRecording,
|
|
481
|
+
async detectCdpPort() {
|
|
482
|
+
const { detectCdpPort } = await import('../cdp.js');
|
|
483
|
+
return detectCdpPort();
|
|
484
|
+
},
|
|
485
|
+
async openTab(port, url) {
|
|
486
|
+
const { openTab } = await import('../cdp.js');
|
|
487
|
+
return openTab(port, url);
|
|
488
|
+
},
|
|
489
|
+
async closeTarget(port, targetId) {
|
|
490
|
+
const { closeTarget } = await import('../cdp/targets.js');
|
|
491
|
+
return closeTarget(port, targetId);
|
|
492
|
+
},
|
|
493
|
+
async awaitTabReady(target, url) {
|
|
494
|
+
if (!target.webSocketDebuggerUrl) {
|
|
495
|
+
throw worldFailure(`capture session start could not attach to ${url}: missing WebSocket debugger URL. Reuse an existing tab with --target.`);
|
|
231
496
|
}
|
|
232
|
-
|
|
497
|
+
const { CDPClient } = await import('../cdp.js');
|
|
498
|
+
const client = new CDPClient(target.webSocketDebuggerUrl);
|
|
499
|
+
try {
|
|
500
|
+
try {
|
|
501
|
+
await client.send('Runtime.enable', {}, 5_000);
|
|
502
|
+
} catch (err) {
|
|
503
|
+
throw worldFailure(`capture session start could not attach to ${url}: ${err instanceof Error ? err.message : err}. Reuse an existing tab with --target.`, err);
|
|
504
|
+
}
|
|
505
|
+
return await waitForPageLoad(client, 10_000);
|
|
506
|
+
} finally {
|
|
507
|
+
client.close();
|
|
508
|
+
}
|
|
509
|
+
},
|
|
510
|
+
startBridge,
|
|
511
|
+
stopBridge,
|
|
512
|
+
publishActiveSession: setActiveSession,
|
|
513
|
+
};
|
|
233
514
|
|
|
234
|
-
|
|
235
|
-
const dir = sessionDir(id);
|
|
236
|
-
ensurePrivateDir(path.join(dir, 'shots'));
|
|
237
|
-
ensurePrivateDir(path.join(dir, 'a11y'));
|
|
515
|
+
let startWorld: SessionStartWorld = productionStartWorld;
|
|
238
516
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
harId = harResult.id;
|
|
244
|
-
} catch (err) {
|
|
245
|
-
console.error(`Warning: could not start HAR recording: ${err instanceof Error ? err.message : err}`);
|
|
246
|
-
}
|
|
517
|
+
/** Test seam: swap the effect world session start drives; pass nothing to restore production. */
|
|
518
|
+
export function __setSessionStartWorld(next?: Partial<SessionStartWorld>): void {
|
|
519
|
+
startWorld = next ? { ...productionStartWorld, ...next } : productionStartWorld;
|
|
520
|
+
}
|
|
247
521
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
522
|
+
/**
|
|
523
|
+
* Exact leaf positional cardinality, enforced at the leaf boundary BEFORE any
|
|
524
|
+
* filesystem/process/session effect (m7). The CLI validator in
|
|
525
|
+
* `src/cdp/args.ts` rejects the same shapes at the entrypoint; this wall
|
|
526
|
+
* covers the direct-call seam so surplus positionals are never silently
|
|
527
|
+
* ignored. Missing-argument rejection stays with each leaf's existing
|
|
528
|
+
* `missing_argument` check. Returns true when the invocation was rejected.
|
|
529
|
+
*/
|
|
530
|
+
function rejectSurplusPositionals(parsed: ParsedArgs, leaf: string, max: number, usage: string): boolean {
|
|
531
|
+
const count = parsed.positional.length;
|
|
532
|
+
if (count <= max) return false;
|
|
533
|
+
const expected = max === 0 ? 'exactly 0' : max === 1 ? 'at most 1' : `at most ${max}`;
|
|
534
|
+
emitResult({
|
|
535
|
+
tag: 'error',
|
|
536
|
+
attrs: { command: `session ${leaf}`, code: 'invalid_input' },
|
|
537
|
+
summary: fact`received: ${count} positional argument(s); expected ${expected} — \`${usage}\`.`,
|
|
538
|
+
}, { json: parsed.json });
|
|
539
|
+
process.exitCode = 1;
|
|
540
|
+
return true;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/** One acquired resource: a label plus its reverse-order release. */
|
|
544
|
+
interface Acquisition {
|
|
545
|
+
label: string;
|
|
546
|
+
release(): Promise<void> | void;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
async function start(parsed: ParsedArgs): Promise<void> {
|
|
550
|
+
if (parsed.help) {
|
|
551
|
+
console.log(START_USAGE);
|
|
552
|
+
return;
|
|
255
553
|
}
|
|
256
|
-
|
|
554
|
+
|
|
555
|
+
if (rejectSurplusPositionals(parsed, 'start', 0, 'capture session start [--url <url>] [--hold]')) return;
|
|
556
|
+
|
|
557
|
+
return withLifecycleCoordinator(async () => {
|
|
558
|
+
const active = getActiveSession();
|
|
559
|
+
if (active && !isSessionStopped(active)) {
|
|
560
|
+
emitResult({
|
|
561
|
+
tag: 'error',
|
|
562
|
+
attrs: { command: 'session start', code: 'start_failed' },
|
|
563
|
+
summary: fact`A session is already active for this scope (${active.sessionId}); stop it first with \`session stop ${active.sessionId}\`.`,
|
|
564
|
+
followUp: text`Only one live session is allowed per capture scope at a time.`,
|
|
565
|
+
}, { json: parsed.json });
|
|
566
|
+
process.exitCode = 1;
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
const url = parsed.url ?? null;
|
|
571
|
+
const hold = parsed.hold === true;
|
|
572
|
+
const id = generateId();
|
|
573
|
+
const dir = sessionDir(id);
|
|
574
|
+
|
|
575
|
+
// Every effect below is owned the instant it succeeds and released in exact
|
|
576
|
+
// reverse order if any later step throws. Nothing is committed outside this
|
|
577
|
+
// one transaction, so a failure leaves the scope byte-identical to before.
|
|
578
|
+
const acquired: Acquisition[] = [];
|
|
579
|
+
let harId: string | null = null;
|
|
580
|
+
let target: CDPTarget | null = null;
|
|
581
|
+
let pageLoadTimedOut = false;
|
|
582
|
+
let bridgeSocket: string | null = null;
|
|
583
|
+
let bridgePid: number | null = null;
|
|
584
|
+
let cdpPort: number | null = null;
|
|
585
|
+
|
|
257
586
|
try {
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
targetId = tab.id;
|
|
261
|
-
|
|
262
|
-
if (!targetId) {
|
|
263
|
-
throw new Error(
|
|
264
|
-
`capture session start could not attach to ${url}. Reuse an existing tab with --target.`,
|
|
265
|
-
);
|
|
266
|
-
}
|
|
267
|
-
if (!tab.webSocketDebuggerUrl) {
|
|
268
|
-
throw new Error(
|
|
269
|
-
`capture session start could not attach to ${url}: missing WebSocket debugger URL. Reuse an existing tab with --target.`,
|
|
270
|
-
);
|
|
271
|
-
}
|
|
587
|
+
ensurePrivateDir(path.join(dir, 'shots'));
|
|
588
|
+
acquired.push({ label: 'artifact tree', release: () => removeArtifactTree(dir) });
|
|
272
589
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
`capture session start could not attach to ${url}: ${err instanceof Error ? err.message : err}. Reuse an existing tab with --target.`,
|
|
280
|
-
);
|
|
281
|
-
}
|
|
282
|
-
pageLoadTimedOut = await waitForPageLoad(client, 10_000);
|
|
283
|
-
} finally {
|
|
284
|
-
client.close();
|
|
590
|
+
harId = await startWorld.createHar(dir);
|
|
591
|
+
const acquiredHarId = harId;
|
|
592
|
+
acquired.push({ label: 'HAR recording', release: () => startWorld.deleteHar(acquiredHarId) });
|
|
593
|
+
|
|
594
|
+
if (url || hold) {
|
|
595
|
+
cdpPort = parsed.port ?? await startWorld.detectCdpPort();
|
|
285
596
|
}
|
|
286
|
-
|
|
287
|
-
if (
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
597
|
+
|
|
598
|
+
if (url) {
|
|
599
|
+
target = await startWorld.openTab(cdpPort!, url);
|
|
600
|
+
const openedPort = cdpPort!;
|
|
601
|
+
const openedTargetId = target.id;
|
|
602
|
+
acquired.push({ label: 'opened target', release: () => startWorld.closeTarget(openedPort, openedTargetId) });
|
|
603
|
+
pageLoadTimedOut = await startWorld.awaitTabReady(target, url);
|
|
293
604
|
}
|
|
294
|
-
throw err;
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
605
|
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
bridgeSocket = bridge.socketPath;
|
|
308
|
-
bridgePid = bridge.pid;
|
|
309
|
-
} catch (err) {
|
|
310
|
-
if (harId) {
|
|
311
|
-
try {
|
|
312
|
-
deleteHarRecording(harId);
|
|
313
|
-
} catch {
|
|
314
|
-
/* best effort */
|
|
315
|
-
}
|
|
606
|
+
// Hold a CDP browser connection open for the session's lifetime so
|
|
607
|
+
// browser-level state (permission grants, ServiceWorker enablement)
|
|
608
|
+
// survives across separate `capture` commands instead of reverting the
|
|
609
|
+
// instant each command's own connection closes.
|
|
610
|
+
if (hold) {
|
|
611
|
+
const bridge = await startWorld.startBridge(dir, cdpPort!);
|
|
612
|
+
bridgeSocket = bridge.socketPath;
|
|
613
|
+
bridgePid = bridge.pid;
|
|
614
|
+
acquired.push({ label: 'held bridge', release: () => startWorld.stopBridge(bridgePid, bridgeSocket) });
|
|
316
615
|
}
|
|
317
|
-
|
|
616
|
+
|
|
617
|
+
const session: Session = {
|
|
618
|
+
sessionId: id,
|
|
619
|
+
dir,
|
|
620
|
+
harId,
|
|
621
|
+
startedAt: new Date().toISOString(),
|
|
622
|
+
stoppedAt: null,
|
|
623
|
+
stopping: false,
|
|
624
|
+
url,
|
|
625
|
+
targetId: target?.id ?? null,
|
|
626
|
+
port: cdpPort,
|
|
627
|
+
stepCount: 0,
|
|
628
|
+
logPids: [],
|
|
629
|
+
bridgeSocket,
|
|
630
|
+
bridgePid,
|
|
631
|
+
};
|
|
632
|
+
// The active index + `.session.json` are published together (last).
|
|
633
|
+
// Its rollback is a scope-owned compare-clear: a no-op unless THIS
|
|
634
|
+
// attempt's publication actually landed.
|
|
635
|
+
acquired.push({ label: 'active publication', release: () => clearActiveSessionIf(id) });
|
|
636
|
+
await startWorld.publishActiveSession(session);
|
|
637
|
+
} catch (primary) {
|
|
638
|
+
await rollbackStart(acquired, parsed, primary);
|
|
639
|
+
return;
|
|
318
640
|
}
|
|
319
|
-
}
|
|
320
641
|
|
|
321
|
-
|
|
322
|
-
id
|
|
323
|
-
|
|
324
|
-
harId
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
// Set as active session for auto-defaults
|
|
336
|
-
setActiveSession({
|
|
337
|
-
sessionId: id,
|
|
338
|
-
dir,
|
|
339
|
-
harId,
|
|
340
|
-
targetId,
|
|
341
|
-
stepCount: 0,
|
|
342
|
-
bridgeSocket,
|
|
642
|
+
const rows: FactLine[] = [fact`bundle dir: ${dir}`];
|
|
643
|
+
if (target) rows.push(fact`tab ${target.id} opened at ${url ?? ''}`);
|
|
644
|
+
if (pageLoadTimedOut) rows.push(fact`page load timed out after 10000ms; the session stays attached to target ${target?.id ?? ''}`);
|
|
645
|
+
if (harId) rows.push(fact`HAR recording ${harId} — traffic auto-appends while the session is active`);
|
|
646
|
+
if (hold) rows.push(fact`held CDP bridge: pid ${bridgePid ?? 0}`);
|
|
647
|
+
|
|
648
|
+
emitResult({
|
|
649
|
+
tag: 'session',
|
|
650
|
+
attrs: { id, path: dir, ...(url ? { url } : {}) },
|
|
651
|
+
summary: fact`Session ${id} started; it is now the active capture context.`,
|
|
652
|
+
sections: [lineList(rows)],
|
|
653
|
+
followUp: fact`capture session stop ${id}`,
|
|
654
|
+
}, { json: parsed.json });
|
|
343
655
|
});
|
|
656
|
+
}
|
|
344
657
|
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
// Agent-friendly next steps on stderr
|
|
359
|
-
console.error(`\nCapture session started: ${id}`);
|
|
360
|
-
if (targetId) {
|
|
361
|
-
console.error(`Tab opened — session context active. No need to pass --target or --har.`);
|
|
362
|
-
if (pageLoadTimedOut) {
|
|
363
|
-
console.error(`Page load timed out, but the session is attached to target ${targetId.slice(0, 8)}. Use \`capture exec --target ${targetId.slice(0, 8)}\` or \`capture list\` if you need to recover it.`);
|
|
658
|
+
/**
|
|
659
|
+
* Releases every acquired resource in exact reverse order, then renders the
|
|
660
|
+
* primary start failure. Cleanup failures never replace the primary error —
|
|
661
|
+
* they are aggregated onto it so both survive.
|
|
662
|
+
*/
|
|
663
|
+
async function rollbackStart(acquired: Acquisition[], parsed: ParsedArgs, primary: unknown): Promise<void> {
|
|
664
|
+
const cleanupFailures: string[] = [];
|
|
665
|
+
for (let i = acquired.length - 1; i >= 0; i -= 1) {
|
|
666
|
+
try {
|
|
667
|
+
await acquired[i].release();
|
|
668
|
+
} catch (error) {
|
|
669
|
+
cleanupFailures.push(`${acquired[i].label}: ${normalizeFailure(error).descriptor.message}`);
|
|
364
670
|
}
|
|
365
671
|
}
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
672
|
+
|
|
673
|
+
const primaryMessage = normalizeFailure(primary).descriptor.message;
|
|
674
|
+
const summary = cleanupFailures.length === 0
|
|
675
|
+
? fact`Session could not start: ${primaryMessage}`
|
|
676
|
+
: fact`Session could not start: ${primaryMessage} (rollback also failed: ${cleanupFailures.join('; ')})`;
|
|
677
|
+
|
|
678
|
+
emitResult({
|
|
679
|
+
tag: 'error',
|
|
680
|
+
attrs: { command: 'session start', code: 'start_failed' },
|
|
681
|
+
summary,
|
|
682
|
+
followUp: text`Ensure a CDP-enabled browser is running — \`capture tab list\` is the probe.`,
|
|
683
|
+
}, { json: parsed.json });
|
|
684
|
+
process.exitCode = 1;
|
|
370
685
|
}
|
|
371
686
|
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
687
|
+
// ============================================================================
|
|
688
|
+
// session log — shell-free, contained, identity-owned log tailing (C2)
|
|
689
|
+
// ============================================================================
|
|
690
|
+
|
|
691
|
+
async function logTail(parsed: ParsedArgs): Promise<void> {
|
|
692
|
+
if (parsed.help) {
|
|
693
|
+
console.log(LOG_USAGE);
|
|
376
694
|
return;
|
|
377
695
|
}
|
|
378
696
|
|
|
379
|
-
|
|
697
|
+
if (rejectSurplusPositionals(parsed, 'log', 1, 'capture session log <path> [--name <label>] [--session <id>]')) return;
|
|
698
|
+
|
|
699
|
+
const sourcePath = parsed.positional[0];
|
|
380
700
|
if (!sourcePath) {
|
|
381
|
-
|
|
382
|
-
|
|
701
|
+
emitResult({
|
|
702
|
+
tag: 'error',
|
|
703
|
+
attrs: { command: 'session log', code: 'missing_argument' },
|
|
704
|
+
summary: fact`received: \`session log\`; expected: \`session log <path> [--name <label>] [--session <id>]\`.`,
|
|
705
|
+
}, { json: parsed.json });
|
|
706
|
+
process.exitCode = 1;
|
|
707
|
+
return;
|
|
383
708
|
}
|
|
384
709
|
|
|
385
|
-
const
|
|
386
|
-
|
|
387
|
-
|
|
710
|
+
const source = path.resolve(sourcePath);
|
|
711
|
+
const name = parsed.name ?? path.basename(source, path.extname(source));
|
|
712
|
+
const labelReason = rejectLogLabel(name);
|
|
713
|
+
if (labelReason) {
|
|
714
|
+
const labelHint = parsed.name === undefined && labelReason === `exceeds ${MAX_LOG_LABEL_BYTES} bytes`
|
|
715
|
+
? ' Pass `--name <short>` to choose a shorter destination label.'
|
|
716
|
+
: '';
|
|
717
|
+
emitResult({
|
|
718
|
+
tag: 'error',
|
|
719
|
+
attrs: { command: 'session log', code: 'invalid_label' },
|
|
720
|
+
summary: fact`invalid destination label (${labelReason}); expected one bounded filename component.${labelHint}`,
|
|
721
|
+
}, { json: parsed.json });
|
|
722
|
+
process.exitCode = 1;
|
|
723
|
+
return;
|
|
388
724
|
}
|
|
389
725
|
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
726
|
+
// Source symlinks are valid caller selections; the path is passed only as one
|
|
727
|
+
// argv element to `tail` after the worker's `--` option terminator.
|
|
728
|
+
if (!fs.existsSync(source)) {
|
|
729
|
+
emitResult({
|
|
730
|
+
tag: 'error',
|
|
731
|
+
attrs: { command: 'session log', code: 'log_file_not_found' },
|
|
732
|
+
summary: fact`received: ${source}; expected an existing log file to follow.`,
|
|
733
|
+
}, { json: parsed.json });
|
|
734
|
+
process.exitCode = 1;
|
|
735
|
+
return;
|
|
395
736
|
}
|
|
396
737
|
|
|
738
|
+
let sessionId = parsed.session ?? null;
|
|
397
739
|
if (!sessionId) {
|
|
398
740
|
const active = getActiveSession();
|
|
399
741
|
if (!active) {
|
|
400
|
-
|
|
742
|
+
emitResult({
|
|
743
|
+
tag: 'error',
|
|
744
|
+
attrs: { command: 'session log', code: 'no_active_session' },
|
|
745
|
+
summary: fact`received: \`session log\` with no --session and no active session; expected an active session or an explicit --session <id>.`,
|
|
746
|
+
followUp: text`Run \`capture session start\` or pass --session <id> from \`capture session list\`.`,
|
|
747
|
+
}, { json: parsed.json });
|
|
748
|
+
process.exitCode = 1;
|
|
749
|
+
return;
|
|
401
750
|
}
|
|
402
751
|
sessionId = active.sessionId;
|
|
403
752
|
}
|
|
404
753
|
|
|
405
|
-
|
|
406
|
-
|
|
754
|
+
let session: Session;
|
|
755
|
+
try {
|
|
756
|
+
session = readSession(sessionId);
|
|
757
|
+
} catch {
|
|
758
|
+
emitResult({
|
|
759
|
+
tag: 'error',
|
|
760
|
+
attrs: { command: 'session log', code: 'unknown_session' },
|
|
761
|
+
summary: fact`No capture session found: ${sessionId}.`,
|
|
762
|
+
followUp: text`Run \`capture session list\` to see known sessions.`,
|
|
763
|
+
}, { json: parsed.json });
|
|
764
|
+
process.exitCode = 1;
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
407
767
|
|
|
408
|
-
|
|
409
|
-
|
|
768
|
+
// Finalized truth is checked before operation admission, directory creation,
|
|
769
|
+
// descriptor open, process spawn, socket creation, or metadata replacement.
|
|
770
|
+
if (isSessionStopped(session)) {
|
|
771
|
+
emitResult({
|
|
772
|
+
tag: 'error',
|
|
773
|
+
attrs: { command: 'session log', code: 'session_stopped' },
|
|
774
|
+
summary: fact`session ${session.sessionId} is finalized; its bundle is immutable and cannot accept a new log tailer.`,
|
|
775
|
+
}, { json: parsed.json });
|
|
776
|
+
process.exitCode = 1;
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
410
779
|
|
|
411
|
-
const
|
|
412
|
-
|
|
413
|
-
|
|
780
|
+
const operation = await admitSessionOperation(session.dir);
|
|
781
|
+
try {
|
|
782
|
+
const result = await withSessionLifecycle(session.dir, async () => startSessionLogTailer({
|
|
783
|
+
sessionDir: session.dir,
|
|
784
|
+
sourcePath: source,
|
|
785
|
+
name,
|
|
786
|
+
register: async record => {
|
|
787
|
+
const current = readSession(session.sessionId);
|
|
788
|
+
await updateSessionState(session.dir, {
|
|
789
|
+
logPids: [...(current.logPids ?? []), record],
|
|
790
|
+
});
|
|
791
|
+
},
|
|
792
|
+
unregister: async record => {
|
|
793
|
+
const current = readSession(session.sessionId);
|
|
794
|
+
await updateSessionState(session.dir, {
|
|
795
|
+
logPids: (current.logPids ?? []).filter(entry => entry.nonce !== record.nonce || entry.pid !== record.pid),
|
|
796
|
+
});
|
|
797
|
+
},
|
|
798
|
+
}));
|
|
799
|
+
|
|
800
|
+
emitResult({
|
|
801
|
+
tag: 'log-tail',
|
|
802
|
+
attrs: { session: session.sessionId, path: result.destPath },
|
|
803
|
+
summary: fact`Tailing ${source} into the session logs/ dir.`,
|
|
804
|
+
sections: [lineList([
|
|
805
|
+
fact`name: ${name}`,
|
|
806
|
+
fact`source: ${source}`,
|
|
807
|
+
fact`dest: ${result.destPath}`,
|
|
808
|
+
fact`tailer pid: ${result.pid} — drained at \`session stop\``,
|
|
809
|
+
])],
|
|
810
|
+
}, { json: parsed.json });
|
|
811
|
+
} catch (error) {
|
|
812
|
+
emitResult(failureResult(error), { json: parsed.json });
|
|
813
|
+
process.exitCode = 1;
|
|
814
|
+
} finally {
|
|
815
|
+
await operation.release();
|
|
816
|
+
}
|
|
817
|
+
}
|
|
414
818
|
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
{ detached: true, stdio: ['ignore', outFd, 'ignore'] },
|
|
419
|
-
);
|
|
420
|
-
child.unref();
|
|
421
|
-
fs.closeSync(outFd);
|
|
819
|
+
// ============================================================================
|
|
820
|
+
// session har — the session-owned HAR read surface (D4)
|
|
821
|
+
// ============================================================================
|
|
422
822
|
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
823
|
+
interface HarSource {
|
|
824
|
+
har: HarFile;
|
|
825
|
+
/** Absolute HAR file path — the block's full-fidelity pointer. */
|
|
826
|
+
path: string;
|
|
827
|
+
source: 'live' | 'bundle';
|
|
828
|
+
}
|
|
426
829
|
|
|
427
|
-
|
|
830
|
+
/**
|
|
831
|
+
* Locates a session's HAR data: the live accumulating recording while the
|
|
832
|
+
* session runs, the bundled `har.json` once it is stopped. Returns an
|
|
833
|
+
* `unavailable` reason instead of a source when the session has no readable
|
|
834
|
+
* HAR (recording never started, or the file is gone).
|
|
835
|
+
*/
|
|
836
|
+
async function locateSessionHar(session: Session): Promise<HarSource | { unavailable: string }> {
|
|
837
|
+
const stopped = fs.existsSync(path.join(session.dir, 'bundle.json'));
|
|
838
|
+
if (stopped) {
|
|
839
|
+
const harPath = path.join(session.dir, 'har.json');
|
|
840
|
+
let raw: string;
|
|
841
|
+
try {
|
|
842
|
+
raw = readPrivateFile(harPath).toString('utf-8');
|
|
843
|
+
} catch (error) {
|
|
844
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
845
|
+
return { unavailable: 'the stopped session bundled no HAR (recording never started or captured nothing)' };
|
|
846
|
+
}
|
|
847
|
+
throw error;
|
|
848
|
+
}
|
|
849
|
+
let parsed: unknown;
|
|
850
|
+
try {
|
|
851
|
+
parsed = JSON.parse(raw);
|
|
852
|
+
} catch (cause) {
|
|
853
|
+
throw captureError('artifact', 'invalid_har_file', `bundled HAR at ${harPath} is not valid JSON; the record is corrupt and cannot be read as a HAR file.`, cause);
|
|
854
|
+
}
|
|
855
|
+
return { har: validateHarFile(parsed, `bundled HAR ${harPath}`), path: harPath, source: 'bundle' };
|
|
856
|
+
}
|
|
857
|
+
if (!session.harId) {
|
|
858
|
+
return { unavailable: 'the running session has no HAR recording (it could not be started with the session)' };
|
|
859
|
+
}
|
|
860
|
+
try {
|
|
861
|
+
const live = await readHarRecording(session.harId);
|
|
862
|
+
return { har: live, path: harFilePath(session.harId), source: 'live' };
|
|
863
|
+
} catch {
|
|
864
|
+
return { unavailable: `the live HAR recording file is missing: ${harFilePath(session.harId)}` };
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
/** One selection-list row: method, status, URL, body size, start time. Body
|
|
869
|
+
* content is NEVER inlined here (I-7) — `--full` is the only opt-in. */
|
|
870
|
+
function harEntryRow(e: HAREntry): FactLine {
|
|
871
|
+
const bodyText = e.response.content?.text;
|
|
872
|
+
const sizePart = typeof bodyText === 'string'
|
|
873
|
+
? fact`${Buffer.byteLength(bodyText, 'utf-8')} bytes`
|
|
874
|
+
: text`body not captured`;
|
|
875
|
+
return line(
|
|
876
|
+
fact`${e.request.method} ${e.response.status} `,
|
|
877
|
+
data(e.request.url, 300),
|
|
878
|
+
text` — `,
|
|
879
|
+
sizePart,
|
|
880
|
+
fact` — started ${e.startedDateTime}`,
|
|
881
|
+
);
|
|
428
882
|
}
|
|
429
883
|
|
|
430
|
-
|
|
431
|
-
|
|
884
|
+
/** `--full` inline detail for one entry: headers, post data, and response
|
|
885
|
+
* body — every value escaped and capped through data()/fact. */
|
|
886
|
+
function harEntryDetail(e: HAREntry, index: number): FactLine {
|
|
887
|
+
const rows: FactLine[] = [line(fact`${index + 1}. `, harEntryRow(e))];
|
|
888
|
+
for (const h of e.request.headers ?? []) {
|
|
889
|
+
rows.push(fact` req ${h.name}: ${h.value}`);
|
|
890
|
+
}
|
|
891
|
+
if (e.request.postData?.text !== undefined) {
|
|
892
|
+
rows.push(fact` post data: ${capped(e.request.postData.text, 2000)}`);
|
|
893
|
+
}
|
|
894
|
+
for (const h of e.response.headers ?? []) {
|
|
895
|
+
rows.push(fact` res ${h.name}: ${h.value}`);
|
|
896
|
+
}
|
|
897
|
+
const bodyText = e.response.content?.text;
|
|
898
|
+
rows.push(
|
|
899
|
+
typeof bodyText === 'string'
|
|
900
|
+
? fact` body: ${capped(bodyText, 2000)}`
|
|
901
|
+
: text` body: not captured`,
|
|
902
|
+
);
|
|
903
|
+
return lineList(rows);
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
async function har(parsed: ParsedArgs): Promise<void> {
|
|
907
|
+
if (parsed.help) {
|
|
908
|
+
console.log(HAR_USAGE);
|
|
909
|
+
return;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
if (rejectSurplusPositionals(parsed, 'har', 1, 'capture session har [<session-id>] [--filter-status <spec>] …')) return;
|
|
913
|
+
|
|
914
|
+
// Leaf-local filter grammar parses BEFORE any session/HAR lookup (M9/A4):
|
|
915
|
+
// an invalid --filter-status wins over an unknown session or a corrupt/
|
|
916
|
+
// missing artifact, and the active-session index is never consulted for it.
|
|
917
|
+
let statusMatch: StatusPredicate | null = null;
|
|
918
|
+
if (parsed.filterStatus !== undefined) {
|
|
919
|
+
try {
|
|
920
|
+
statusMatch = parseStatusFilter(parsed.filterStatus);
|
|
921
|
+
} catch (error) {
|
|
922
|
+
const { descriptor } = normalizeFailure(error);
|
|
923
|
+
emitResult({
|
|
924
|
+
tag: 'error',
|
|
925
|
+
attrs: { command: 'session har', code: descriptor.code },
|
|
926
|
+
summary: fact`${descriptor.message}`,
|
|
927
|
+
}, { json: parsed.json });
|
|
928
|
+
process.exitCode = 1;
|
|
929
|
+
return;
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
let id = parsed.positional[0] ?? null;
|
|
432
934
|
if (!id) {
|
|
433
|
-
|
|
434
|
-
|
|
935
|
+
const active = getActiveSession();
|
|
936
|
+
if (!active) {
|
|
937
|
+
emitResult({
|
|
938
|
+
tag: 'error',
|
|
939
|
+
attrs: { command: 'session har', code: 'no_active_session' },
|
|
940
|
+
summary: fact`received: \`session har\` with no <session-id> and no active session; expected an active session or an explicit session id.`,
|
|
941
|
+
followUp: text`Run \`capture session list\` to find a session id.`,
|
|
942
|
+
}, { json: parsed.json });
|
|
943
|
+
process.exitCode = 1;
|
|
944
|
+
return;
|
|
945
|
+
}
|
|
946
|
+
id = active.sessionId;
|
|
435
947
|
}
|
|
436
948
|
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
949
|
+
let session: Session;
|
|
950
|
+
try {
|
|
951
|
+
session = readSession(id);
|
|
952
|
+
} catch {
|
|
953
|
+
emitResult({
|
|
954
|
+
tag: 'error',
|
|
955
|
+
attrs: { command: 'session har', code: 'unknown_session' },
|
|
956
|
+
summary: fact`No capture session found: ${id}.`,
|
|
957
|
+
followUp: text`Run \`capture session list\` to see known sessions.`,
|
|
958
|
+
}, { json: parsed.json });
|
|
959
|
+
process.exitCode = 1;
|
|
960
|
+
return;
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
const located = await locateSessionHar(session);
|
|
964
|
+
if ('unavailable' in located) {
|
|
965
|
+
emitResult({
|
|
966
|
+
tag: 'error',
|
|
967
|
+
attrs: { command: 'session har', code: 'har_unavailable' },
|
|
968
|
+
summary: fact`Session ${id} has no readable HAR: ${located.unavailable}.`,
|
|
969
|
+
}, { json: parsed.json });
|
|
970
|
+
process.exitCode = 1;
|
|
971
|
+
return;
|
|
972
|
+
}
|
|
441
973
|
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
974
|
+
let entries = located.har.log.entries;
|
|
975
|
+
const total = entries.length;
|
|
976
|
+
const filters: string[] = [];
|
|
977
|
+
|
|
978
|
+
if (parsed.filterUrl) {
|
|
979
|
+
const pattern = parsed.filterUrl;
|
|
980
|
+
let re: RegExp | null = null;
|
|
981
|
+
try { re = new RegExp(pattern, 'i'); } catch { re = null; }
|
|
982
|
+
entries = entries.filter((e) =>
|
|
983
|
+
re ? re.test(e.request.url) : e.request.url.toLowerCase().includes(pattern.toLowerCase()),
|
|
984
|
+
);
|
|
985
|
+
filters.push(`url~${pattern}`);
|
|
445
986
|
}
|
|
446
|
-
if (
|
|
447
|
-
|
|
987
|
+
if (statusMatch) {
|
|
988
|
+
const matches = statusMatch;
|
|
989
|
+
entries = entries.filter((e) => matches(e.response.status));
|
|
990
|
+
filters.push(`status=${parsed.filterStatus}`);
|
|
448
991
|
}
|
|
992
|
+
if (parsed.filterMethod) {
|
|
993
|
+
const m = parsed.filterMethod.toUpperCase();
|
|
994
|
+
entries = entries.filter((e) => e.request.method.toUpperCase() === m);
|
|
995
|
+
filters.push(`method=${m}`);
|
|
996
|
+
}
|
|
997
|
+
if (typeof parsed.limit === 'number' && parsed.limit > 0) {
|
|
998
|
+
entries = entries.slice(0, parsed.limit);
|
|
999
|
+
filters.push(`limit=${parsed.limit}`);
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
const summary = filters.length > 0
|
|
1003
|
+
? fact`${entries.length} of ${total} entries match (${filters.join(', ')}).`
|
|
1004
|
+
: fact`${total} entries.`;
|
|
1005
|
+
|
|
1006
|
+
const sections = parsed.full
|
|
1007
|
+
? entries.map((e, i) => harEntryDetail(e, i))
|
|
1008
|
+
: [lineList(entries.map((e, i) => line(fact`${i + 1}. `, harEntryRow(e))))];
|
|
1009
|
+
|
|
1010
|
+
emitResult({
|
|
1011
|
+
tag: 'session-har',
|
|
1012
|
+
attrs: {
|
|
1013
|
+
id,
|
|
1014
|
+
path: located.path,
|
|
1015
|
+
source: located.source,
|
|
1016
|
+
entries: entries.length,
|
|
1017
|
+
total,
|
|
1018
|
+
},
|
|
1019
|
+
summary,
|
|
1020
|
+
sections,
|
|
1021
|
+
}, { json: parsed.json });
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
function bundleRows(manifest: BundleManifest): FactLine[] {
|
|
1025
|
+
return [
|
|
1026
|
+
fact`shots: ${manifest.shots.length}`,
|
|
1027
|
+
manifest.har ? fact`har: ${manifest.har.entryCount} entries — ${manifest.har.path}` : fact`har: 0 entries`,
|
|
1028
|
+
fact`logs: ${manifest.logs.length}`,
|
|
1029
|
+
fact`measure snaps: ${manifest.snaps.length}`,
|
|
1030
|
+
fact`motion recs: ${manifest.recs.length}`,
|
|
1031
|
+
fact`other: ${manifest.other.length}`,
|
|
1032
|
+
];
|
|
1033
|
+
}
|
|
449
1034
|
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
1035
|
+
function emitStoppedFromManifest(id: string, sessionDir: string, manifest: BundleManifest, parsed: ParsedArgs): void {
|
|
1036
|
+
emitResult({
|
|
1037
|
+
tag: 'session-stopped',
|
|
1038
|
+
attrs: { id, path: path.join(sessionDir, 'bundle.json') },
|
|
1039
|
+
summary: fact`Session ${id} stopped after ${manifest.duration}ms; bundle manifest written.`,
|
|
1040
|
+
sections: [lineList(bundleRows(manifest))],
|
|
1041
|
+
followUp: fact`capture session view ${id}`,
|
|
1042
|
+
}, { json: parsed.json });
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
async function stop(parsed: ParsedArgs): Promise<void> {
|
|
1046
|
+
if (parsed.help) {
|
|
1047
|
+
console.log(STOP_USAGE);
|
|
1048
|
+
return;
|
|
454
1049
|
}
|
|
455
1050
|
|
|
456
|
-
|
|
457
|
-
const shotsDir = path.join(session.dir, 'shots');
|
|
458
|
-
const screenshots = fs.existsSync(shotsDir)
|
|
459
|
-
? fs.readdirSync(shotsDir)
|
|
460
|
-
.filter((f) => f.endsWith('.png') || f.endsWith('.jpg'))
|
|
461
|
-
.map((f) => ({ name: f, path: path.join(shotsDir, f) }))
|
|
462
|
-
: [];
|
|
1051
|
+
if (rejectSurplusPositionals(parsed, 'stop', 1, 'capture session stop <session-id>')) return;
|
|
463
1052
|
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
1053
|
+
const id = parsed.positional[0];
|
|
1054
|
+
if (!id) {
|
|
1055
|
+
emitResult({
|
|
1056
|
+
tag: 'error',
|
|
1057
|
+
attrs: { command: 'session stop', code: 'missing_argument' },
|
|
1058
|
+
summary: fact`received: \`session stop\`; expected: \`session stop <session-id>\`.`,
|
|
1059
|
+
followUp: text`Run \`capture session list\` to find a session id.`,
|
|
1060
|
+
}, { json: parsed.json });
|
|
1061
|
+
process.exitCode = 1;
|
|
1062
|
+
return;
|
|
1063
|
+
}
|
|
471
1064
|
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
if (session.harId) {
|
|
1065
|
+
return withLifecycleCoordinator(async () => {
|
|
1066
|
+
let session: Session;
|
|
475
1067
|
try {
|
|
476
|
-
|
|
477
|
-
|
|
1068
|
+
session = readSession(id);
|
|
1069
|
+
} catch {
|
|
1070
|
+
emitResult({
|
|
1071
|
+
tag: 'error',
|
|
1072
|
+
attrs: { command: 'session stop', code: 'unknown_session' },
|
|
1073
|
+
summary: fact`No capture session found: ${id}.`,
|
|
1074
|
+
followUp: text`Run \`capture session list\` to see known sessions.`,
|
|
1075
|
+
}, { json: parsed.json });
|
|
1076
|
+
process.exitCode = 1;
|
|
1077
|
+
return;
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
const stopAdmission = await beginSessionStop(session.dir);
|
|
1081
|
+
let committed = false;
|
|
1082
|
+
try {
|
|
1083
|
+
// A process waiting behind another stop must re-read finalized truth after
|
|
1084
|
+
// winning the per-session stop lock. Existing bundles are immutable.
|
|
1085
|
+
session = readSession(id);
|
|
1086
|
+
const bundlePath = path.join(session.dir, 'bundle.json');
|
|
1087
|
+
try {
|
|
1088
|
+
const manifest = readBundleManifest(bundlePath);
|
|
1089
|
+
// The bundle commit is authoritative even if the committing process
|
|
1090
|
+
// died before post-commit cleanup. Complete those idempotent steps
|
|
1091
|
+
// without ever replacing the immutable manifest.
|
|
1092
|
+
if (session.harId) {
|
|
1093
|
+
try { await deleteHarRecording(session.harId); }
|
|
1094
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; }
|
|
1095
|
+
}
|
|
1096
|
+
await updateSessionState(session.dir, { stoppedAt: manifest.stoppedAt, stopping: false });
|
|
1097
|
+
clearActiveSessionIf(id);
|
|
1098
|
+
emitStoppedFromManifest(id, session.dir, manifest, parsed);
|
|
1099
|
+
committed = true;
|
|
1100
|
+
return;
|
|
1101
|
+
} catch (error) {
|
|
1102
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
|
1103
|
+
if (session.stoppedAt) throw new Error(`finalized session ${id} is missing its immutable bundle`);
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
session = await updateSessionState(session.dir, { stopping: true });
|
|
1107
|
+
const stoppedAt = new Date().toISOString();
|
|
1108
|
+
const startMs = new Date(session.startedAt ?? stoppedAt).getTime();
|
|
1109
|
+
const duration = Date.now() - startMs;
|
|
1110
|
+
|
|
1111
|
+
// Drain/terminate every registered log tailer identity-first BEFORE the
|
|
1112
|
+
// bundle is committed: a live writer into logs/ would falsify the immutable
|
|
1113
|
+
// bundle. A verified-alive tailer that cannot be drained or terminated
|
|
1114
|
+
// throws here and fails the stop, preserving the failure.
|
|
1115
|
+
await stopSessionLogTailers(session.logPids ?? []);
|
|
1116
|
+
if (session.bridgePid || session.bridgeSocket) stopBridge(session.bridgePid, session.bridgeSocket);
|
|
1117
|
+
|
|
1118
|
+
let recorderTeardown: Awaited<ReturnType<typeof teardownAnyLiveRecorderAtSessionStop>> | null = null;
|
|
1119
|
+
try {
|
|
1120
|
+
recorderTeardown = await teardownAnyLiveRecorderAtSessionStop(session.dir);
|
|
1121
|
+
} catch (error) {
|
|
1122
|
+
// A terminal `rec-stop` failure (the bridge authenticated the request
|
|
1123
|
+
// and explicitly refused — e.g. a fatal HAR drain) must NOT be
|
|
1124
|
+
// swallowed into a warning: it escapes to the outer `stop_failed`
|
|
1125
|
+
// lane below, so no bundle is committed and no live HAR/recorder
|
|
1126
|
+
// handle is deleted while admitted append work may still be lost.
|
|
1127
|
+
// Non-fatal teardown noise (dead handle, liveness-unknown, malformed
|
|
1128
|
+
// handle) keeps the existing warn-and-continue policy.
|
|
1129
|
+
if (isTerminalRecStopFailure(error)) throw error;
|
|
1130
|
+
console.error(`Warning: could not finalize active recording: ${error instanceof Error ? error.message : error}`);
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
const shotsDir = path.join(session.dir, 'shots');
|
|
1134
|
+
const shots = fs.existsSync(shotsDir)
|
|
1135
|
+
? fs.readdirSync(shotsDir).filter(name => name.endsWith('.png') || name.endsWith('.jpg')).map(name => ({ name, path: path.join(shotsDir, name) }))
|
|
1136
|
+
: [];
|
|
1137
|
+
|
|
1138
|
+
let har: BundleManifest['har'] = null;
|
|
1139
|
+
if (session.harId) {
|
|
1140
|
+
const harData = await readHarRecording(session.harId);
|
|
478
1141
|
const harPath = path.join(session.dir, 'har.json');
|
|
479
1142
|
writeJsonPrivate(harPath, harData);
|
|
480
1143
|
har = { id: session.harId, path: harPath, entryCount: harData.log.entries.length };
|
|
481
|
-
// Clean up the HAR recording
|
|
482
|
-
try { deleteHarRecording(session.harId); } catch { /* best effort */ }
|
|
483
1144
|
}
|
|
484
|
-
|
|
485
|
-
|
|
1145
|
+
|
|
1146
|
+
const logsDir = path.join(session.dir, 'logs');
|
|
1147
|
+
const logs = fs.existsSync(logsDir)
|
|
1148
|
+
? fs.readdirSync(logsDir).filter(name => name.endsWith('.log')).map(name => {
|
|
1149
|
+
const filePath = path.join(logsDir, name);
|
|
1150
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
1151
|
+
return { name, path: filePath, lines: content ? content.split('\n').filter(Boolean).length : 0 };
|
|
1152
|
+
})
|
|
1153
|
+
: [];
|
|
1154
|
+
const snaps = collectSnaps(session.dir);
|
|
1155
|
+
const recs = collectRecs(session.dir);
|
|
1156
|
+
const knownDirs = new Set(['shots', 'logs', 'measure', 'motion', '.har', '.stop.lock', '.operations.lock']);
|
|
1157
|
+
const knownFiles = new Set(['.session.json', '.operations.json', 'har.json', 'bundle.json', 'bridge.sock']);
|
|
1158
|
+
const other = fs.readdirSync(session.dir)
|
|
1159
|
+
.filter(name => !knownDirs.has(name) && !knownFiles.has(name))
|
|
1160
|
+
.map(name => ({ name, path: path.join(session.dir, name) }));
|
|
1161
|
+
|
|
1162
|
+
const manifest: BundleManifest = {
|
|
1163
|
+
id: session.sessionId,
|
|
1164
|
+
startedAt: session.startedAt ?? '',
|
|
1165
|
+
stoppedAt,
|
|
1166
|
+
duration,
|
|
1167
|
+
url: session.url ?? null,
|
|
1168
|
+
shots,
|
|
1169
|
+
har,
|
|
1170
|
+
logs,
|
|
1171
|
+
other,
|
|
1172
|
+
snaps,
|
|
1173
|
+
recs,
|
|
1174
|
+
pendingViewportRestorations: recorderTeardown?.pendingViewportRestorations ?? [],
|
|
1175
|
+
};
|
|
1176
|
+
|
|
1177
|
+
// The immutable bundle is the commit point. Live HAR deletion, stopped
|
|
1178
|
+
// metadata, and pointer compare-clear are strictly post-commit.
|
|
1179
|
+
writeJsonPrivate(bundlePath, manifest);
|
|
1180
|
+
if (session.harId) await deleteHarRecording(session.harId);
|
|
1181
|
+
await updateSessionState(session.dir, { stoppedAt, stopping: false });
|
|
1182
|
+
clearActiveSessionIf(id);
|
|
1183
|
+
committed = true;
|
|
1184
|
+
|
|
1185
|
+
const rows = bundleRows(manifest);
|
|
1186
|
+
if (recorderTeardown && 'recId' in recorderTeardown) rows.push(fact`recorder ${recorderTeardown.recId} finalized — state ${recorderTeardown.state}, viewport restored ${String(recorderTeardown.viewportRestored)}`);
|
|
1187
|
+
if (recorderTeardown?.pendingViewportRestorations.length) rows.push(fact`pending viewport restorations: ${recorderTeardown.pendingViewportRestorations.length}`);
|
|
1188
|
+
emitResult({
|
|
1189
|
+
tag: 'session-stopped',
|
|
1190
|
+
attrs: { id, path: bundlePath },
|
|
1191
|
+
summary: fact`Session ${id} stopped after ${duration}ms; bundle manifest written.`,
|
|
1192
|
+
sections: [lineList(rows)],
|
|
1193
|
+
followUp: fact`capture session view ${id}`,
|
|
1194
|
+
}, { json: parsed.json });
|
|
1195
|
+
} catch (error) {
|
|
1196
|
+
try { await updateSessionState(session.dir, { stopping: false }); } catch { /* preserve stop failure */ }
|
|
1197
|
+
emitResult({
|
|
1198
|
+
tag: 'error',
|
|
1199
|
+
attrs: { command: 'session stop', code: 'stop_failed' },
|
|
1200
|
+
summary: fact`Session ${id} could not be stopped: ${error instanceof Error ? error.message : String(error)}`,
|
|
1201
|
+
}, { json: parsed.json });
|
|
1202
|
+
process.exitCode = 1;
|
|
1203
|
+
} finally {
|
|
1204
|
+
await stopAdmission.finish(committed);
|
|
486
1205
|
}
|
|
1206
|
+
});
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
function list(parsed: ParsedArgs): void {
|
|
1210
|
+
if (parsed.help) {
|
|
1211
|
+
console.log(LIST_USAGE);
|
|
1212
|
+
return;
|
|
487
1213
|
}
|
|
488
1214
|
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
const
|
|
492
|
-
? fs.readdirSync(
|
|
493
|
-
.filter((
|
|
494
|
-
.map((
|
|
495
|
-
const
|
|
496
|
-
const
|
|
497
|
-
|
|
498
|
-
return { name: f, path: filePath, lines };
|
|
1215
|
+
if (rejectSurplusPositionals(parsed, 'list', 0, 'capture session list')) return;
|
|
1216
|
+
|
|
1217
|
+
const sessions = fs.existsSync(CAPTURE_ROOT)
|
|
1218
|
+
? fs.readdirSync(CAPTURE_ROOT)
|
|
1219
|
+
.filter((d) => fs.existsSync(sessionMetaPath(d)))
|
|
1220
|
+
.map((d) => {
|
|
1221
|
+
const session = readSession(d);
|
|
1222
|
+
const hasBundled = fs.existsSync(path.join(session.dir, 'bundle.json'));
|
|
1223
|
+
return { id: session.sessionId, startedAt: session.startedAt ?? '', url: session.url ?? null, status: hasBundled ? 'stopped' : 'active' };
|
|
499
1224
|
})
|
|
500
1225
|
: [];
|
|
501
1226
|
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
const knownFiles = new Set(['.session.json', 'har.json', 'bundle.json', 'bridge.sock']);
|
|
509
|
-
const other = fs.readdirSync(session.dir)
|
|
510
|
-
.filter((f) => !knownDirs.has(f) && !knownFiles.has(f))
|
|
511
|
-
.map((f) => ({ name: f, path: path.join(session.dir, f) }));
|
|
512
|
-
|
|
513
|
-
// Clear active session context
|
|
514
|
-
clearActiveSession();
|
|
515
|
-
|
|
516
|
-
const manifest: BundleManifest = {
|
|
517
|
-
id: session.id,
|
|
518
|
-
startedAt: session.startedAt,
|
|
519
|
-
stoppedAt,
|
|
520
|
-
duration,
|
|
521
|
-
url: session.url,
|
|
522
|
-
screenshots,
|
|
523
|
-
har,
|
|
524
|
-
a11y,
|
|
525
|
-
logs,
|
|
526
|
-
other,
|
|
527
|
-
snaps,
|
|
528
|
-
recs,
|
|
529
|
-
};
|
|
530
|
-
|
|
531
|
-
const bundlePath = path.join(session.dir, 'bundle.json');
|
|
532
|
-
writeJsonPrivate(bundlePath, manifest);
|
|
533
|
-
|
|
534
|
-
console.log(JSON.stringify({
|
|
535
|
-
bundlePath,
|
|
536
|
-
summary: {
|
|
537
|
-
duration,
|
|
538
|
-
screenshots: screenshots.length,
|
|
539
|
-
harEntries: har?.entryCount ?? 0,
|
|
540
|
-
a11ySnapshots: a11y.length,
|
|
541
|
-
logFiles: logs.length,
|
|
542
|
-
otherFiles: other.length,
|
|
543
|
-
snaps: snaps.length,
|
|
544
|
-
recs: recs.length,
|
|
545
|
-
},
|
|
546
|
-
}, null, 2));
|
|
547
|
-
|
|
548
|
-
console.error(`\nBundle written: ${bundlePath}`);
|
|
549
|
-
console.error(`Read it: capture session view ${id}`);
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
-
function list(): void {
|
|
553
|
-
if (!fs.existsSync(CAPTURE_ROOT)) {
|
|
554
|
-
console.log('[]');
|
|
1227
|
+
if (sessions.length === 0) {
|
|
1228
|
+
emitResult({
|
|
1229
|
+
tag: 'sessions',
|
|
1230
|
+
attrs: { count: 0 },
|
|
1231
|
+
summary: text`No capture sessions.`,
|
|
1232
|
+
}, { json: parsed.json });
|
|
555
1233
|
return;
|
|
556
1234
|
}
|
|
557
1235
|
|
|
558
|
-
const
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
});
|
|
1236
|
+
const rows = sessions.map((s) =>
|
|
1237
|
+
line(
|
|
1238
|
+
fact`${s.id} — ${s.status} — started ${s.startedAt}`,
|
|
1239
|
+
s.url ? fact` — ${s.url}` : text``,
|
|
1240
|
+
),
|
|
1241
|
+
);
|
|
565
1242
|
|
|
566
|
-
|
|
1243
|
+
emitResult({
|
|
1244
|
+
tag: 'sessions',
|
|
1245
|
+
attrs: { count: sessions.length },
|
|
1246
|
+
sections: [lineList(rows)],
|
|
1247
|
+
}, { json: parsed.json });
|
|
567
1248
|
}
|
|
568
1249
|
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
1250
|
+
/** Renders one manifest section into the per-entry rows the view command prints. */
|
|
1251
|
+
function sectionRows(manifest: BundleManifest, key: SectionKey): FactLine[] {
|
|
1252
|
+
switch (key) {
|
|
1253
|
+
case 'shots':
|
|
1254
|
+
return manifest.shots.map((s) => fact`${s.name} — ${s.path}`);
|
|
1255
|
+
case 'har':
|
|
1256
|
+
return manifest.har
|
|
1257
|
+
? [fact`har.json — ${manifest.har.entryCount} entries — ${manifest.har.path}`]
|
|
1258
|
+
: [];
|
|
1259
|
+
case 'logs':
|
|
1260
|
+
return manifest.logs.map((l) => fact`${l.name} — ${l.lines} lines — ${l.path}`);
|
|
1261
|
+
case 'snaps':
|
|
1262
|
+
return manifest.snaps.map((s) =>
|
|
1263
|
+
fact`${s.id} — url ${s.url ?? '(none)'} — viewport ${s.viewport ?? '(none)'} — settled ${String(s.settled)} — ${s.path}`);
|
|
1264
|
+
case 'recs':
|
|
1265
|
+
return manifest.recs.map((r) =>
|
|
1266
|
+
fact`${r.id} — ${r.action ?? '(none)'} — ${r.frames} frames, ${r.durationMs}ms, ${r.state} — ${r.path}`);
|
|
1267
|
+
case 'other':
|
|
1268
|
+
return manifest.other.map((o) => fact`${o.name} — ${o.path}`);
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
578
1271
|
|
|
579
|
-
function view(
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
console.error('Usage: capture session view <session-id> [--filter screenshots|har|a11y|logs|other|measure|motion]');
|
|
584
|
-
process.exit(1);
|
|
1272
|
+
function view(parsed: ParsedArgs): void {
|
|
1273
|
+
if (parsed.help) {
|
|
1274
|
+
console.log(VIEW_USAGE);
|
|
1275
|
+
return;
|
|
585
1276
|
}
|
|
586
1277
|
|
|
587
|
-
|
|
588
|
-
const bundlePath = path.join(session.dir, 'bundle.json');
|
|
1278
|
+
if (rejectSurplusPositionals(parsed, 'view', 1, 'capture session view <session-id> [--filter <section>]')) return;
|
|
589
1279
|
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
1280
|
+
const id = parsed.positional[0];
|
|
1281
|
+
if (!id) {
|
|
1282
|
+
emitResult({
|
|
1283
|
+
tag: 'error',
|
|
1284
|
+
attrs: { command: 'session view', code: 'missing_argument' },
|
|
1285
|
+
summary: fact`received: \`session view\`; expected: \`session view <session-id>\`.`,
|
|
1286
|
+
followUp: text`Run \`capture session list\` to find a session id.`,
|
|
1287
|
+
}, { json: parsed.json });
|
|
1288
|
+
process.exitCode = 1;
|
|
1289
|
+
return;
|
|
593
1290
|
}
|
|
594
1291
|
|
|
595
|
-
|
|
1292
|
+
let session: Session;
|
|
1293
|
+
try {
|
|
1294
|
+
session = readSession(id);
|
|
1295
|
+
} catch {
|
|
1296
|
+
emitResult({
|
|
1297
|
+
tag: 'error',
|
|
1298
|
+
attrs: { command: 'session view', code: 'unknown_session' },
|
|
1299
|
+
summary: fact`No capture session found: ${id}.`,
|
|
1300
|
+
followUp: text`Run \`capture session list\` to see known sessions.`,
|
|
1301
|
+
}, { json: parsed.json });
|
|
1302
|
+
process.exitCode = 1;
|
|
1303
|
+
return;
|
|
1304
|
+
}
|
|
596
1305
|
|
|
597
|
-
const
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
1306
|
+
const bundlePath = path.join(session.dir, 'bundle.json');
|
|
1307
|
+
let manifest: BundleManifest;
|
|
1308
|
+
try {
|
|
1309
|
+
manifest = readBundleManifest(bundlePath);
|
|
1310
|
+
} catch (error) {
|
|
1311
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
1312
|
+
emitResult({
|
|
1313
|
+
tag: 'error',
|
|
1314
|
+
attrs: { command: 'session view', code: 'session_not_stopped' },
|
|
1315
|
+
summary: fact`Session ${id} has not been stopped; there is no bundle manifest yet.`,
|
|
1316
|
+
followUp: fact`capture session stop ${id}`,
|
|
1317
|
+
}, { json: parsed.json });
|
|
1318
|
+
process.exitCode = 1;
|
|
1319
|
+
return;
|
|
1320
|
+
}
|
|
1321
|
+
throw error;
|
|
604
1322
|
}
|
|
605
|
-
}
|
|
606
1323
|
|
|
607
|
-
|
|
608
|
-
|
|
1324
|
+
if (parsed.filter !== undefined) {
|
|
1325
|
+
const key = VIEW_FILTERS[parsed.filter];
|
|
1326
|
+
if (!key) {
|
|
1327
|
+
emitResult({
|
|
1328
|
+
tag: 'error',
|
|
1329
|
+
attrs: { command: 'session view', code: 'invalid_filter' },
|
|
1330
|
+
summary: fact`received: --filter ${parsed.filter}; expected one of: shots, har, logs, measure, motion, other.`,
|
|
1331
|
+
followUp: text`Re-run \`capture session view <id> --filter <section>\` with a valid section.`,
|
|
1332
|
+
}, { json: parsed.json });
|
|
1333
|
+
process.exitCode = 1;
|
|
1334
|
+
return;
|
|
1335
|
+
}
|
|
609
1336
|
|
|
610
|
-
|
|
611
|
-
|
|
1337
|
+
const rows = sectionRows(manifest, key);
|
|
1338
|
+
emitResult({
|
|
1339
|
+
tag: 'session',
|
|
1340
|
+
attrs: { id, path: bundlePath, filter: parsed.filter },
|
|
1341
|
+
summary: fact`${parsed.filter}: ${rows.length} entries`,
|
|
1342
|
+
sections: [lineList(rows)],
|
|
1343
|
+
}, { json: parsed.json });
|
|
612
1344
|
return;
|
|
613
1345
|
}
|
|
614
1346
|
|
|
615
|
-
|
|
1347
|
+
const keys: SectionKey[] = ['shots', 'har', 'logs', 'snaps', 'recs', 'other'];
|
|
1348
|
+
const sections = keys.map((key) => {
|
|
1349
|
+
const rows = sectionRows(manifest, key);
|
|
1350
|
+
return lineList([
|
|
1351
|
+
fact`${SECTION_LABELS[key]}: ${rows.length}`,
|
|
1352
|
+
...rows.map((r) => line(text` `, r)),
|
|
1353
|
+
]);
|
|
1354
|
+
});
|
|
1355
|
+
|
|
1356
|
+
emitResult({
|
|
1357
|
+
tag: 'session',
|
|
1358
|
+
attrs: { id, path: bundlePath },
|
|
1359
|
+
summary: fact`Session ${id}: started ${manifest.startedAt}, stopped ${manifest.stoppedAt}, ${manifest.duration}ms.`,
|
|
1360
|
+
sections,
|
|
1361
|
+
}, { json: parsed.json });
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
export async function sessionMain(parsed: ParsedArgs, _args: string[]): Promise<void> {
|
|
1365
|
+
const leaf = parsed.positional[0];
|
|
1366
|
+
const rest: ParsedArgs = { ...parsed, positional: parsed.positional.slice(1) };
|
|
1367
|
+
|
|
1368
|
+
switch (leaf) {
|
|
616
1369
|
case 'start': return start(rest);
|
|
617
1370
|
case 'stop': return stop(rest);
|
|
618
|
-
case 'list': return list();
|
|
1371
|
+
case 'list': return list(rest);
|
|
619
1372
|
case 'view': return view(rest);
|
|
620
|
-
|
|
1373
|
+
case 'har': return har(rest);
|
|
1374
|
+
case 'log': return logTail(rest);
|
|
1375
|
+
case undefined:
|
|
621
1376
|
printSessionHelp();
|
|
1377
|
+
return;
|
|
1378
|
+
default:
|
|
1379
|
+
emitResult({
|
|
1380
|
+
tag: 'error',
|
|
1381
|
+
attrs: { command: 'session', code: 'unknown_subcommand' },
|
|
1382
|
+
summary: fact`received: \`session ${leaf}\`; expected one of: start, stop, list, view, har, log.`,
|
|
1383
|
+
followUp: text`Run \`capture session -h\` for usage.`,
|
|
1384
|
+
}, { json: parsed.json });
|
|
1385
|
+
process.exitCode = 1;
|
|
1386
|
+
return;
|
|
622
1387
|
}
|
|
623
1388
|
}
|