@crouton-kit/capture 1.3.7 → 1.3.9
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 +32786 -9989
- package/package.json +9 -3
- package/src/capture.ts +86 -163
- package/src/cdp/a11y.ts +113 -14
- package/src/cdp/args.ts +300 -116
- package/src/cdp/bridge/protocol.ts +187 -1
- package/src/cdp/bridge/server.ts +211 -84
- package/src/cdp/bridge/spawn.ts +110 -1
- package/src/cdp/client.ts +13 -3
- package/src/cdp/commands/bridge-serve.ts +35 -4
- package/src/cdp/commands/cdp.ts +266 -69
- package/src/cdp/commands/gate-guard.ts +43 -0
- package/src/cdp/commands/lib.ts +339 -213
- package/src/cdp/commands/measure/census.ts +87 -0
- package/src/cdp/commands/measure/check.ts +109 -0
- package/src/cdp/commands/measure/diff.ts +120 -0
- package/src/cdp/commands/measure/explain.ts +133 -0
- package/src/cdp/commands/measure/index.ts +106 -0
- package/src/cdp/commands/measure/map-ax.ts +52 -0
- package/src/cdp/commands/measure/map-focus.ts +52 -0
- package/src/cdp/commands/measure/map-layers.ts +59 -0
- package/src/cdp/commands/measure/map-scroll.ts +94 -0
- package/src/cdp/commands/measure/snap.ts +416 -0
- package/src/cdp/commands/measure/sweep.ts +227 -0
- package/src/cdp/commands/motion/index.ts +61 -0
- package/src/cdp/commands/motion/jank.ts +132 -0
- package/src/cdp/commands/motion/mask.ts +92 -0
- package/src/cdp/commands/motion/rec.ts +534 -0
- package/src/cdp/commands/motion/response.ts +76 -0
- package/src/cdp/commands/motion/timeline.ts +152 -0
- 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/coordinates.ts +158 -0
- package/src/cdp/detect.ts +42 -13
- package/src/cdp/dispatch.ts +76 -26
- package/src/cdp/domains.ts +75 -0
- 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 +1894 -0
- 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/source-map.ts +309 -0
- package/src/cdp/targets.ts +21 -1
- package/src/cdp/timing.ts +87 -0
- package/src/cdp/types.ts +87 -6
- 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 +526 -0
- package/src/output/diff.ts +111 -0
- package/src/output/exact-raw.ts +114 -0
- package/src/output/render.ts +987 -0
- package/src/output/selector.ts +185 -0
- package/src/output/structured.ts +211 -0
- package/src/registry.ts +208 -0
- package/src/session/artifacts.ts +481 -0
- package/src/session/commands.ts +1237 -368
- 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 +302 -34
- 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 -77
- package/src/tab-session.ts +0 -91
package/src/session/commands.ts
CHANGED
|
@@ -1,41 +1,56 @@
|
|
|
1
1
|
import * as fs from 'fs';
|
|
2
2
|
import * as path from 'path';
|
|
3
|
-
import * as os from 'os';
|
|
4
|
-
import { spawn } from 'child_process';
|
|
5
3
|
import {
|
|
6
4
|
createHarRecording,
|
|
7
5
|
readHarRecording,
|
|
8
6
|
deleteHarRecording,
|
|
7
|
+
harFilePath,
|
|
8
|
+
validateHarFile,
|
|
9
|
+
type HarFile,
|
|
10
|
+
type HAREntry,
|
|
9
11
|
} from '../har-manager.js';
|
|
10
12
|
import {
|
|
11
13
|
getActiveSession,
|
|
12
14
|
setActiveSession,
|
|
13
|
-
|
|
15
|
+
clearActiveSessionIf,
|
|
16
|
+
updateSessionState,
|
|
17
|
+
isActiveStateCandidate,
|
|
18
|
+
type ActiveSessionState,
|
|
14
19
|
} from '../session-context.js';
|
|
15
|
-
import {
|
|
20
|
+
import { type ParsedArgs, type CDPTarget } from '../cdp/types.js';
|
|
16
21
|
import { startBridge, stopBridge } from '../cdp/bridge/spawn.js';
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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';
|
|
38
|
+
import {
|
|
39
|
+
CAPTURE_ROOT,
|
|
40
|
+
MAX_LOG_LABEL_BYTES,
|
|
41
|
+
acquirePrivateLock,
|
|
42
|
+
ensurePrivateDir,
|
|
43
|
+
rejectLogLabel,
|
|
44
|
+
writeJsonPrivate,
|
|
45
|
+
readPrivateFile,
|
|
46
|
+
removeArtifactTree,
|
|
47
|
+
type SnapMeta,
|
|
48
|
+
type RecMeta,
|
|
49
|
+
} from './artifacts.js';
|
|
50
|
+
import { beginSessionStop, admitSessionOperation, withSessionLifecycle } from './coordinator.js';
|
|
51
|
+
import { parseStatusFilter, type StatusPredicate } from './har-filter.js';
|
|
52
|
+
|
|
53
|
+
type Session = ActiveSessionState;
|
|
39
54
|
|
|
40
55
|
interface BundleManifest {
|
|
41
56
|
id: string;
|
|
@@ -43,37 +58,234 @@ interface BundleManifest {
|
|
|
43
58
|
stoppedAt: string;
|
|
44
59
|
duration: number;
|
|
45
60
|
url: string | null;
|
|
46
|
-
|
|
61
|
+
shots: Array<{ name: string; path: string }>;
|
|
47
62
|
har: { id: string; path: string; entryCount: number } | null;
|
|
48
|
-
a11y: Array<{ name: string; path: string }>;
|
|
49
63
|
logs: Array<{ name: string; path: string; lines: number }>;
|
|
50
64
|
other: Array<{ name: string; path: string }>;
|
|
65
|
+
/** `measure snap` artifacts collected from `measure/snaps/{id}/meta.json`. */
|
|
66
|
+
snaps: Array<{ id: string; path: string; url: string | null; viewport: string | null; settled: boolean; capturedAt: string }>;
|
|
67
|
+
/** `motion rec` artifacts collected from `motion/recs/{id}/meta.json`. */
|
|
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';
|
|
51
77
|
}
|
|
52
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;
|
|
122
|
+
}
|
|
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
|
+
|
|
53
150
|
function sessionDir(id: string): string {
|
|
54
151
|
return path.join(CAPTURE_ROOT, id);
|
|
55
152
|
}
|
|
56
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
|
+
|
|
174
|
+
/** One-shot artifact session outside an active session — see `createOneshotSession`. */
|
|
175
|
+
export interface OneshotSession {
|
|
176
|
+
/** `oneshot-{id}`; also the dir name under `CAPTURE_ROOT`. */
|
|
177
|
+
id: string;
|
|
178
|
+
/** `{CAPTURE_ROOT}/oneshot-{id}` */
|
|
179
|
+
dir: string;
|
|
180
|
+
kind: 'measure' | 'motion' | 'page';
|
|
181
|
+
/** `{dir}/measure/snaps`, `{dir}/motion/recs`, or `{dir}/page`, already created private. */
|
|
182
|
+
artifactsDir: string;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
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`.
|
|
193
|
+
*/
|
|
194
|
+
export function createOneshotSession(kind: 'measure' | 'motion' | 'page'): OneshotSession {
|
|
195
|
+
const id = `oneshot-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
196
|
+
const dir = sessionDir(id);
|
|
197
|
+
const artifactsDir = kind === 'measure'
|
|
198
|
+
? path.join(dir, 'measure', 'snaps')
|
|
199
|
+
: kind === 'motion'
|
|
200
|
+
? path.join(dir, 'motion', 'recs')
|
|
201
|
+
: path.join(dir, 'page');
|
|
202
|
+
ensurePrivateDir(artifactsDir);
|
|
203
|
+
return { id, dir, kind, artifactsDir };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Reads one `measure/snaps/{id}/meta.json` and shapes it into a manifest `snaps[]` entry. */
|
|
207
|
+
function readSnapMeta(snapDir: string, fallbackId: string): BundleManifest['snaps'][number] {
|
|
208
|
+
const meta = JSON.parse(fs.readFileSync(path.join(snapDir, 'meta.json'), 'utf-8')) as Partial<SnapMeta>;
|
|
209
|
+
return {
|
|
210
|
+
id: meta.id ?? fallbackId,
|
|
211
|
+
path: snapDir,
|
|
212
|
+
url: meta.url ?? null,
|
|
213
|
+
viewport: meta.viewport ?? null,
|
|
214
|
+
settled: meta.settled ?? false,
|
|
215
|
+
capturedAt: meta.capturedAt ?? '',
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Reads one `motion/recs/{id}/meta.json` and shapes it into a manifest `recs[]` entry. */
|
|
220
|
+
function readRecMeta(recDir: string, fallbackId: string): BundleManifest['recs'][number] {
|
|
221
|
+
const meta = JSON.parse(fs.readFileSync(path.join(recDir, 'meta.json'), 'utf-8')) as Partial<RecMeta>;
|
|
222
|
+
return {
|
|
223
|
+
id: meta.id ?? fallbackId,
|
|
224
|
+
path: recDir,
|
|
225
|
+
action: meta.action ?? null,
|
|
226
|
+
frames: meta.frames ?? 0,
|
|
227
|
+
durationMs: meta.durationMs ?? 0,
|
|
228
|
+
state: meta.state ?? 'unknown',
|
|
229
|
+
viewportRestored: typeof meta.viewportRestored === 'boolean' ? meta.viewportRestored : null,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Collects finalized snapshots under `{session.dir}/measure/snaps/{id}/meta.json`. */
|
|
234
|
+
function collectSnaps(dir: string): BundleManifest['snaps'] {
|
|
235
|
+
const snapsRoot = path.join(dir, 'measure', 'snaps');
|
|
236
|
+
if (!fs.existsSync(snapsRoot)) return [];
|
|
237
|
+
return fs.readdirSync(snapsRoot)
|
|
238
|
+
.filter((name) => fs.existsSync(path.join(snapsRoot, name, 'meta.json')))
|
|
239
|
+
.map((name) => readSnapMeta(path.join(snapsRoot, name), name));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Collects finalized recordings under `{session.dir}/motion/recs/{id}/meta.json`. */
|
|
243
|
+
function collectRecs(dir: string): BundleManifest['recs'] {
|
|
244
|
+
const recsRoot = path.join(dir, 'motion', 'recs');
|
|
245
|
+
if (!fs.existsSync(recsRoot)) return [];
|
|
246
|
+
return fs.readdirSync(recsRoot)
|
|
247
|
+
.filter((name) => fs.existsSync(path.join(recsRoot, name, 'meta.json')))
|
|
248
|
+
.map((name) => readRecMeta(path.join(recsRoot, name), name));
|
|
249
|
+
}
|
|
250
|
+
|
|
57
251
|
function sessionMetaPath(id: string): string {
|
|
58
252
|
return path.join(sessionDir(id), '.session.json');
|
|
59
253
|
}
|
|
60
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
|
+
*/
|
|
61
264
|
function readSession(id: string): Session {
|
|
62
265
|
const metaPath = sessionMetaPath(id);
|
|
63
|
-
|
|
64
|
-
|
|
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;
|
|
65
272
|
}
|
|
66
|
-
|
|
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.`);
|
|
281
|
+
}
|
|
282
|
+
return parsed;
|
|
67
283
|
}
|
|
68
284
|
|
|
69
285
|
function generateId(): string {
|
|
70
286
|
return `cap-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
|
|
71
287
|
}
|
|
72
288
|
|
|
73
|
-
function hasHelpFlag(args: string[]): boolean {
|
|
74
|
-
return args.some((arg) => arg === '-h' || arg === '--help');
|
|
75
|
-
}
|
|
76
|
-
|
|
77
289
|
export async function waitForPageLoad(
|
|
78
290
|
client: { waitReady(): Promise<void>; send(method: string, params?: Record<string, unknown>): Promise<unknown>; on(event: string, handler: (params: unknown) => void): void; },
|
|
79
291
|
timeoutMs: number,
|
|
@@ -83,437 +295,1094 @@ export async function waitForPageLoad(
|
|
|
83
295
|
|
|
84
296
|
return await new Promise<boolean>((resolve) => {
|
|
85
297
|
const timer = setTimeout(() => resolve(true), timeoutMs);
|
|
86
|
-
|
|
298
|
+
const done = (timedOut: boolean) => {
|
|
87
299
|
clearTimeout(timer);
|
|
88
|
-
resolve(
|
|
89
|
-
}
|
|
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(() => {});
|
|
90
315
|
});
|
|
91
316
|
}
|
|
92
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
|
+
|
|
93
436
|
function printSessionHelp(): void {
|
|
94
|
-
console.log(`capture session —
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
--
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
2. Interact — no --target / --har needed:
|
|
109
|
-
capture a11y --interactive
|
|
110
|
-
capture click "Sign in"
|
|
111
|
-
capture type "hi@me.com" --into "Email"
|
|
112
|
-
capture screenshot
|
|
113
|
-
capture navigate https://app.example.com/dashboard
|
|
114
|
-
capture har read --filter-url /api
|
|
115
|
-
3. capture session stop <session-id>
|
|
116
|
-
4. capture session view <session-id>
|
|
117
|
-
|
|
118
|
-
--hold: keeps one CDP browser connection open for the session's lifetime, so
|
|
119
|
-
browser-level state (Browser.grantPermissions, ServiceWorker.enable, ...)
|
|
120
|
-
survives across separate commands instead of reverting the instant each
|
|
121
|
-
command's own connection closes. Use it together with \`capture cdp --browser\`:
|
|
122
|
-
|
|
123
|
-
capture session start --url http://localhost:3000 --hold
|
|
124
|
-
capture cdp Browser.grantPermissions --browser --params '{"origin":"http://localhost:3000","permissions":["notifications"]}'
|
|
125
|
-
capture cdp ServiceWorker.enable --browser --target <pageTabId>
|
|
126
|
-
capture cdp ServiceWorker.deliverPushMessage --browser --target <pageTabId> --params '{...}'
|
|
127
|
-
capture session stop <session-id> # also tears down the held connection
|
|
128
|
-
|
|
129
|
-
Related: capture log <path> [--name label] Tail a log into the active session
|
|
130
|
-
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`);
|
|
131
451
|
}
|
|
132
452
|
|
|
133
453
|
// ============================================================================
|
|
134
454
|
// Session Commands
|
|
135
455
|
// ============================================================================
|
|
136
456
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
+
}
|
|
141
475
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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.`);
|
|
147
496
|
}
|
|
148
|
-
|
|
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
|
+
};
|
|
149
514
|
|
|
150
|
-
|
|
151
|
-
const dir = sessionDir(id);
|
|
152
|
-
fs.mkdirSync(path.join(dir, 'shots'), { recursive: true });
|
|
153
|
-
fs.mkdirSync(path.join(dir, 'a11y'), { recursive: true });
|
|
515
|
+
let startWorld: SessionStartWorld = productionStartWorld;
|
|
154
516
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
harId = harResult.id;
|
|
160
|
-
} catch (err) {
|
|
161
|
-
console.error(`Warning: could not start HAR recording: ${err instanceof Error ? err.message : err}`);
|
|
162
|
-
}
|
|
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
|
+
}
|
|
163
521
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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;
|
|
171
553
|
}
|
|
172
|
-
|
|
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
|
+
|
|
173
586
|
try {
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
targetId = tab.id;
|
|
177
|
-
|
|
178
|
-
if (!targetId) {
|
|
179
|
-
throw new Error(
|
|
180
|
-
`capture session start could not attach to ${url}. Reuse an existing tab with --target.`,
|
|
181
|
-
);
|
|
182
|
-
}
|
|
183
|
-
if (!tab.webSocketDebuggerUrl) {
|
|
184
|
-
throw new Error(
|
|
185
|
-
`capture session start could not attach to ${url}: missing WebSocket debugger URL. Reuse an existing tab with --target.`,
|
|
186
|
-
);
|
|
187
|
-
}
|
|
587
|
+
ensurePrivateDir(path.join(dir, 'shots'));
|
|
588
|
+
acquired.push({ label: 'artifact tree', release: () => removeArtifactTree(dir) });
|
|
188
589
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
`capture session start could not attach to ${url}: ${err instanceof Error ? err.message : err}. Reuse an existing tab with --target.`,
|
|
196
|
-
);
|
|
197
|
-
}
|
|
198
|
-
pageLoadTimedOut = await waitForPageLoad(client, 10_000);
|
|
199
|
-
} finally {
|
|
200
|
-
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();
|
|
201
596
|
}
|
|
202
|
-
|
|
203
|
-
if (
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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);
|
|
209
604
|
}
|
|
210
|
-
throw err;
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
605
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
bridgeSocket = bridge.socketPath;
|
|
224
|
-
bridgePid = bridge.pid;
|
|
225
|
-
} catch (err) {
|
|
226
|
-
if (harId) {
|
|
227
|
-
try {
|
|
228
|
-
deleteHarRecording(harId);
|
|
229
|
-
} catch {
|
|
230
|
-
/* best effort */
|
|
231
|
-
}
|
|
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) });
|
|
232
615
|
}
|
|
233
|
-
|
|
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;
|
|
234
640
|
}
|
|
235
|
-
}
|
|
236
641
|
|
|
237
|
-
|
|
238
|
-
id
|
|
239
|
-
|
|
240
|
-
harId
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
// Set as active session for auto-defaults
|
|
252
|
-
setActiveSession({
|
|
253
|
-
sessionId: id,
|
|
254
|
-
dir,
|
|
255
|
-
harId,
|
|
256
|
-
targetId,
|
|
257
|
-
stepCount: 0,
|
|
258
|
-
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 });
|
|
259
655
|
});
|
|
656
|
+
}
|
|
260
657
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
// Agent-friendly next steps on stderr
|
|
275
|
-
console.error(`\nCapture session started: ${id}`);
|
|
276
|
-
if (targetId) {
|
|
277
|
-
console.error(`Tab opened — session context active. No need to pass --target or --har.`);
|
|
278
|
-
if (pageLoadTimedOut) {
|
|
279
|
-
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}`);
|
|
280
670
|
}
|
|
281
671
|
}
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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;
|
|
286
685
|
}
|
|
287
686
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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);
|
|
292
694
|
return;
|
|
293
695
|
}
|
|
294
696
|
|
|
295
|
-
|
|
697
|
+
if (rejectSurplusPositionals(parsed, 'log', 1, 'capture session log <path> [--name <label>] [--session <id>]')) return;
|
|
698
|
+
|
|
699
|
+
const sourcePath = parsed.positional[0];
|
|
296
700
|
if (!sourcePath) {
|
|
297
|
-
|
|
298
|
-
|
|
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;
|
|
299
708
|
}
|
|
300
709
|
|
|
301
|
-
const
|
|
302
|
-
|
|
303
|
-
|
|
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;
|
|
304
724
|
}
|
|
305
725
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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;
|
|
311
736
|
}
|
|
312
737
|
|
|
738
|
+
let sessionId = parsed.session ?? null;
|
|
313
739
|
if (!sessionId) {
|
|
314
740
|
const active = getActiveSession();
|
|
315
741
|
if (!active) {
|
|
316
|
-
|
|
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;
|
|
317
750
|
}
|
|
318
751
|
sessionId = active.sessionId;
|
|
319
752
|
}
|
|
320
753
|
|
|
321
|
-
|
|
322
|
-
|
|
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
|
+
}
|
|
323
767
|
|
|
324
|
-
|
|
325
|
-
|
|
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
|
+
}
|
|
326
779
|
|
|
327
|
-
const
|
|
328
|
-
|
|
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
|
+
}
|
|
329
818
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
{ detached: true, stdio: ['ignore', outFd, 'ignore'] },
|
|
334
|
-
);
|
|
335
|
-
child.unref();
|
|
336
|
-
fs.closeSync(outFd);
|
|
819
|
+
// ============================================================================
|
|
820
|
+
// session har — the session-owned HAR read surface (D4)
|
|
821
|
+
// ============================================================================
|
|
337
822
|
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
+
}
|
|
341
829
|
|
|
342
|
-
|
|
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
|
+
);
|
|
882
|
+
}
|
|
883
|
+
|
|
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);
|
|
343
904
|
}
|
|
344
905
|
|
|
345
|
-
async function
|
|
346
|
-
|
|
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;
|
|
347
934
|
if (!id) {
|
|
348
|
-
|
|
349
|
-
|
|
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;
|
|
350
947
|
}
|
|
351
948
|
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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
|
+
}
|
|
356
973
|
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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}`);
|
|
360
986
|
}
|
|
361
|
-
if (
|
|
362
|
-
|
|
987
|
+
if (statusMatch) {
|
|
988
|
+
const matches = statusMatch;
|
|
989
|
+
entries = entries.filter((e) => matches(e.response.status));
|
|
990
|
+
filters.push(`status=${parsed.filterStatus}`);
|
|
363
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
|
+
}
|
|
1034
|
+
|
|
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
|
+
}
|
|
364
1044
|
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
1045
|
+
async function stop(parsed: ParsedArgs): Promise<void> {
|
|
1046
|
+
if (parsed.help) {
|
|
1047
|
+
console.log(STOP_USAGE);
|
|
1048
|
+
return;
|
|
369
1049
|
}
|
|
370
1050
|
|
|
371
|
-
|
|
372
|
-
const shotsDir = path.join(session.dir, 'shots');
|
|
373
|
-
const screenshots = fs.existsSync(shotsDir)
|
|
374
|
-
? fs.readdirSync(shotsDir)
|
|
375
|
-
.filter((f) => f.endsWith('.png') || f.endsWith('.jpg'))
|
|
376
|
-
.map((f) => ({ name: f, path: path.join(shotsDir, f) }))
|
|
377
|
-
: [];
|
|
1051
|
+
if (rejectSurplusPositionals(parsed, 'stop', 1, 'capture session stop <session-id>')) return;
|
|
378
1052
|
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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
|
+
}
|
|
1064
|
+
|
|
1065
|
+
return withLifecycleCoordinator(async () => {
|
|
1066
|
+
let session: Session;
|
|
1067
|
+
try {
|
|
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
|
+
}
|
|
386
1079
|
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
if (session.harId) {
|
|
1080
|
+
const stopAdmission = await beginSessionStop(session.dir);
|
|
1081
|
+
let committed = false;
|
|
390
1082
|
try {
|
|
391
|
-
|
|
392
|
-
|
|
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);
|
|
393
1141
|
const harPath = path.join(session.dir, 'har.json');
|
|
394
|
-
|
|
1142
|
+
writeJsonPrivate(harPath, harData);
|
|
395
1143
|
har = { id: session.harId, path: harPath, entryCount: harData.log.entries.length };
|
|
396
|
-
// Clean up the HAR recording
|
|
397
|
-
try { deleteHarRecording(session.harId); } catch { /* best effort */ }
|
|
398
1144
|
}
|
|
399
|
-
|
|
400
|
-
|
|
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);
|
|
401
1205
|
}
|
|
1206
|
+
});
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
function list(parsed: ParsedArgs): void {
|
|
1210
|
+
if (parsed.help) {
|
|
1211
|
+
console.log(LIST_USAGE);
|
|
1212
|
+
return;
|
|
402
1213
|
}
|
|
403
1214
|
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
const
|
|
407
|
-
? fs.readdirSync(
|
|
408
|
-
.filter((
|
|
409
|
-
.map((
|
|
410
|
-
const
|
|
411
|
-
const
|
|
412
|
-
|
|
413
|
-
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' };
|
|
414
1224
|
})
|
|
415
1225
|
: [];
|
|
416
1226
|
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
clearActiveSession();
|
|
426
|
-
|
|
427
|
-
const manifest: BundleManifest = {
|
|
428
|
-
id: session.id,
|
|
429
|
-
startedAt: session.startedAt,
|
|
430
|
-
stoppedAt,
|
|
431
|
-
duration,
|
|
432
|
-
url: session.url,
|
|
433
|
-
screenshots,
|
|
434
|
-
har,
|
|
435
|
-
a11y,
|
|
436
|
-
logs,
|
|
437
|
-
other,
|
|
438
|
-
};
|
|
1227
|
+
if (sessions.length === 0) {
|
|
1228
|
+
emitResult({
|
|
1229
|
+
tag: 'sessions',
|
|
1230
|
+
attrs: { count: 0 },
|
|
1231
|
+
summary: text`No capture sessions.`,
|
|
1232
|
+
}, { json: parsed.json });
|
|
1233
|
+
return;
|
|
1234
|
+
}
|
|
439
1235
|
|
|
440
|
-
const
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
duration,
|
|
447
|
-
screenshots: screenshots.length,
|
|
448
|
-
harEntries: har?.entryCount ?? 0,
|
|
449
|
-
a11ySnapshots: a11y.length,
|
|
450
|
-
logFiles: logs.length,
|
|
451
|
-
otherFiles: other.length,
|
|
452
|
-
},
|
|
453
|
-
}, null, 2));
|
|
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
|
+
);
|
|
454
1242
|
|
|
455
|
-
|
|
456
|
-
|
|
1243
|
+
emitResult({
|
|
1244
|
+
tag: 'sessions',
|
|
1245
|
+
attrs: { count: sessions.length },
|
|
1246
|
+
sections: [lineList(rows)],
|
|
1247
|
+
}, { json: parsed.json });
|
|
457
1248
|
}
|
|
458
1249
|
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
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}`);
|
|
463
1269
|
}
|
|
464
|
-
|
|
465
|
-
const sessions = fs.readdirSync(CAPTURE_ROOT)
|
|
466
|
-
.filter((d) => fs.existsSync(sessionMetaPath(d)))
|
|
467
|
-
.map((d) => {
|
|
468
|
-
const session = readSession(d);
|
|
469
|
-
const hasBundled = fs.existsSync(path.join(session.dir, 'bundle.json'));
|
|
470
|
-
return { id: session.id, startedAt: session.startedAt, url: session.url, status: hasBundled ? 'stopped' : 'active' };
|
|
471
|
-
});
|
|
472
|
-
|
|
473
|
-
console.log(JSON.stringify(sessions, null, 2));
|
|
474
1270
|
}
|
|
475
1271
|
|
|
476
|
-
function view(
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
console.error('Usage: capture session view <session-id> [--filter screenshots|har|a11y]');
|
|
481
|
-
process.exit(1);
|
|
1272
|
+
function view(parsed: ParsedArgs): void {
|
|
1273
|
+
if (parsed.help) {
|
|
1274
|
+
console.log(VIEW_USAGE);
|
|
1275
|
+
return;
|
|
482
1276
|
}
|
|
483
1277
|
|
|
484
|
-
|
|
485
|
-
const bundlePath = path.join(session.dir, 'bundle.json');
|
|
1278
|
+
if (rejectSurplusPositionals(parsed, 'view', 1, 'capture session view <session-id> [--filter <section>]')) return;
|
|
486
1279
|
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
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;
|
|
490
1290
|
}
|
|
491
1291
|
|
|
492
|
-
|
|
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
|
+
}
|
|
493
1305
|
|
|
494
|
-
const
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
}
|
|
499
|
-
|
|
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;
|
|
500
1322
|
}
|
|
501
|
-
}
|
|
502
1323
|
|
|
503
|
-
|
|
504
|
-
|
|
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
|
+
}
|
|
505
1336
|
|
|
506
|
-
|
|
507
|
-
|
|
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 });
|
|
508
1344
|
return;
|
|
509
1345
|
}
|
|
510
1346
|
|
|
511
|
-
|
|
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) {
|
|
512
1369
|
case 'start': return start(rest);
|
|
513
1370
|
case 'stop': return stop(rest);
|
|
514
|
-
case 'list': return list();
|
|
1371
|
+
case 'list': return list(rest);
|
|
515
1372
|
case 'view': return view(rest);
|
|
516
|
-
|
|
1373
|
+
case 'har': return har(rest);
|
|
1374
|
+
case 'log': return logTail(rest);
|
|
1375
|
+
case undefined:
|
|
517
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;
|
|
518
1387
|
}
|
|
519
1388
|
}
|