@crouton-kit/capture 1.3.6 → 1.3.8
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 +3269 -1281
- package/package.json +4 -1
- package/src/capture.ts +30 -0
- package/src/cdp/args.ts +113 -10
- package/src/cdp/bridge/client.ts +49 -0
- package/src/cdp/bridge/protocol.ts +137 -0
- package/src/cdp/bridge/server.ts +241 -0
- package/src/cdp/bridge/spawn.ts +127 -0
- package/src/cdp/client.ts +7 -1
- package/src/cdp/commands/bridge-serve.ts +43 -0
- package/src/cdp/commands/cdp.ts +203 -0
- package/src/cdp/commands/measure/census.ts +31 -0
- package/src/cdp/commands/measure/check.ts +35 -0
- package/src/cdp/commands/measure/diff.ts +32 -0
- package/src/cdp/commands/measure/explain.ts +31 -0
- package/src/cdp/commands/measure/index.ts +114 -0
- package/src/cdp/commands/measure/map-focus.ts +24 -0
- package/src/cdp/commands/measure/map-layers.ts +25 -0
- package/src/cdp/commands/measure/map-scroll.ts +24 -0
- package/src/cdp/commands/measure/snap.ts +36 -0
- package/src/cdp/commands/measure/sweep.ts +30 -0
- package/src/cdp/commands/motion/index.ts +60 -0
- package/src/cdp/commands/motion/jank.ts +22 -0
- package/src/cdp/commands/motion/mask.ts +23 -0
- package/src/cdp/commands/motion/rec.ts +33 -0
- package/src/cdp/commands/motion/response.ts +26 -0
- package/src/cdp/commands/motion/timeline.ts +27 -0
- package/src/cdp/coordinates.ts +158 -0
- package/src/cdp/dispatch.ts +8 -0
- package/src/cdp/domains.ts +75 -0
- package/src/cdp/recorder-bridge.ts +521 -0
- package/src/cdp/source-map.ts +309 -0
- package/src/cdp/targets.ts +8 -1
- package/src/cdp/timing.ts +82 -0
- package/src/cdp/types.ts +71 -0
- package/src/cdp/usage.ts +9 -0
- package/src/output/artifact.ts +520 -0
- package/src/output/diff.ts +111 -0
- package/src/output/render.ts +593 -0
- package/src/output/selector.ts +185 -0
- package/src/session/artifacts.ts +229 -0
- package/src/session/commands.ts +183 -20
- package/src/session-context.ts +50 -8
package/src/session/commands.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import * as fs from 'fs';
|
|
2
2
|
import * as path from 'path';
|
|
3
|
-
import * as os from 'os';
|
|
4
3
|
import { spawn } from 'child_process';
|
|
5
4
|
import {
|
|
6
5
|
createHarRecording,
|
|
@@ -13,8 +12,15 @@ import {
|
|
|
13
12
|
clearActiveSession,
|
|
14
13
|
} from '../session-context.js';
|
|
15
14
|
import { expandEqualsFlags } from '../cdp/args.js';
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
import { startBridge, stopBridge } from '../cdp/bridge/spawn.js';
|
|
16
|
+
import {
|
|
17
|
+
CAPTURE_ROOT,
|
|
18
|
+
FILE_MODE,
|
|
19
|
+
ensurePrivateDir,
|
|
20
|
+
writeJsonPrivate,
|
|
21
|
+
type SnapMeta,
|
|
22
|
+
type RecMeta,
|
|
23
|
+
} from './artifacts.js';
|
|
18
24
|
|
|
19
25
|
interface LogPid {
|
|
20
26
|
pid: number;
|
|
@@ -31,6 +37,9 @@ interface Session {
|
|
|
31
37
|
targetId: string | null;
|
|
32
38
|
stepCount: number;
|
|
33
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;
|
|
34
43
|
}
|
|
35
44
|
|
|
36
45
|
interface BundleManifest {
|
|
@@ -44,12 +53,90 @@ interface BundleManifest {
|
|
|
44
53
|
a11y: Array<{ name: string; path: string }>;
|
|
45
54
|
logs: Array<{ name: string; path: string; lines: number }>;
|
|
46
55
|
other: Array<{ name: string; path: string }>;
|
|
56
|
+
/** `measure snap` artifacts collected from `measure/snaps/{id}/meta.json`. */
|
|
57
|
+
snaps: Array<{ id: string; path: string; url: string | null; viewport: string | null; settled: boolean; capturedAt: string }>;
|
|
58
|
+
/** `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 }>;
|
|
47
60
|
}
|
|
48
61
|
|
|
49
62
|
function sessionDir(id: string): string {
|
|
50
63
|
return path.join(CAPTURE_ROOT, id);
|
|
51
64
|
}
|
|
52
65
|
|
|
66
|
+
/** One-shot artifact session outside an active session — see `createOneshotSession`. */
|
|
67
|
+
export interface OneshotSession {
|
|
68
|
+
/** `oneshot-{id}`; also the dir name under `CAPTURE_ROOT`. */
|
|
69
|
+
id: string;
|
|
70
|
+
/** `{CAPTURE_ROOT}/oneshot-{id}` */
|
|
71
|
+
dir: string;
|
|
72
|
+
kind: 'measure' | 'motion';
|
|
73
|
+
/** `{dir}/measure/snaps` or `{dir}/motion/recs`, already created private. */
|
|
74
|
+
artifactsDir: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Creates the ephemeral artifact dir a URL-target `measure`/`motion` leaf
|
|
79
|
+
* writes into when there is no active session: `oneshot-{id}/measure/snaps`
|
|
80
|
+
* or `oneshot-{id}/motion/recs` under `CAPTURE_ROOT`. Holds only the one
|
|
81
|
+
* subtree the caller needs — no HAR, no held bridge, no `.session.json` —
|
|
82
|
+
* and is never registered as the active session. It is not bundled/torn
|
|
83
|
+
* down by `session stop`; it accumulates under `/tmp` the same as any other
|
|
84
|
+
* session dir until the OS reaps `/tmp`.
|
|
85
|
+
*/
|
|
86
|
+
export function createOneshotSession(kind: 'measure' | 'motion'): OneshotSession {
|
|
87
|
+
const id = `oneshot-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
88
|
+
const dir = sessionDir(id);
|
|
89
|
+
const artifactsDir = kind === 'measure'
|
|
90
|
+
? path.join(dir, 'measure', 'snaps')
|
|
91
|
+
: path.join(dir, 'motion', 'recs');
|
|
92
|
+
ensurePrivateDir(artifactsDir);
|
|
93
|
+
return { id, dir, kind, artifactsDir };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Reads one `measure/snaps/{id}/meta.json` and shapes it into a manifest `snaps[]` entry. */
|
|
97
|
+
function readSnapMeta(snapDir: string, fallbackId: string): BundleManifest['snaps'][number] {
|
|
98
|
+
const meta = JSON.parse(fs.readFileSync(path.join(snapDir, 'meta.json'), 'utf-8')) as Partial<SnapMeta>;
|
|
99
|
+
return {
|
|
100
|
+
id: meta.id ?? fallbackId,
|
|
101
|
+
path: snapDir,
|
|
102
|
+
url: meta.url ?? null,
|
|
103
|
+
viewport: meta.viewport ?? null,
|
|
104
|
+
settled: meta.settled ?? false,
|
|
105
|
+
capturedAt: meta.capturedAt ?? '',
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Reads one `motion/recs/{id}/meta.json` and shapes it into a manifest `recs[]` entry. */
|
|
110
|
+
function readRecMeta(recDir: string, fallbackId: string): BundleManifest['recs'][number] {
|
|
111
|
+
const meta = JSON.parse(fs.readFileSync(path.join(recDir, 'meta.json'), 'utf-8')) as Partial<RecMeta>;
|
|
112
|
+
return {
|
|
113
|
+
id: meta.id ?? fallbackId,
|
|
114
|
+
path: recDir,
|
|
115
|
+
action: meta.action ?? null,
|
|
116
|
+
frames: meta.frames ?? 0,
|
|
117
|
+
durationMs: meta.durationMs ?? 0,
|
|
118
|
+
state: meta.state ?? 'unknown',
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Collects finalized snapshots under `{session.dir}/measure/snaps/{id}/meta.json`. */
|
|
123
|
+
function collectSnaps(dir: string): BundleManifest['snaps'] {
|
|
124
|
+
const snapsRoot = path.join(dir, 'measure', 'snaps');
|
|
125
|
+
if (!fs.existsSync(snapsRoot)) return [];
|
|
126
|
+
return fs.readdirSync(snapsRoot)
|
|
127
|
+
.filter((name) => fs.existsSync(path.join(snapsRoot, name, 'meta.json')))
|
|
128
|
+
.map((name) => readSnapMeta(path.join(snapsRoot, name), name));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Collects finalized recordings under `{session.dir}/motion/recs/{id}/meta.json`. */
|
|
132
|
+
function collectRecs(dir: string): BundleManifest['recs'] {
|
|
133
|
+
const recsRoot = path.join(dir, 'motion', 'recs');
|
|
134
|
+
if (!fs.existsSync(recsRoot)) return [];
|
|
135
|
+
return fs.readdirSync(recsRoot)
|
|
136
|
+
.filter((name) => fs.existsSync(path.join(recsRoot, name, 'meta.json')))
|
|
137
|
+
.map((name) => readRecMeta(path.join(recsRoot, name), name));
|
|
138
|
+
}
|
|
139
|
+
|
|
53
140
|
function sessionMetaPath(id: string): string {
|
|
54
141
|
return path.join(sessionDir(id), '.session.json');
|
|
55
142
|
}
|
|
@@ -90,10 +177,12 @@ function printSessionHelp(): void {
|
|
|
90
177
|
console.log(`capture session — manage capture sessions
|
|
91
178
|
|
|
92
179
|
Sub-commands:
|
|
93
|
-
start [--url <url>]
|
|
180
|
+
start [--url <url>] [--hold] Start a session (opens tab, records HAR, sets active context)
|
|
181
|
+
--hold keeps one CDP browser connection open for the session
|
|
94
182
|
stop <session-id> Finalize and bundle artifacts (screenshots, HAR, a11y, logs)
|
|
95
183
|
list List active and stopped sessions
|
|
96
|
-
view <id> [--filter section] View bundle manifest; section = screenshots|har|a11y|logs
|
|
184
|
+
view <id> [--filter section] View bundle manifest; section = screenshots|har|a11y|logs|measure|motion
|
|
185
|
+
measure -> manifest.snaps, motion -> manifest.recs
|
|
97
186
|
|
|
98
187
|
Why sessions: once started, every subsequent capture command auto-fills
|
|
99
188
|
--target (the tab) and --har (the recording). No manual flag threading.
|
|
@@ -110,6 +199,17 @@ Typical flow:
|
|
|
110
199
|
3. capture session stop <session-id>
|
|
111
200
|
4. capture session view <session-id>
|
|
112
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
|
+
|
|
113
213
|
Related: capture log <path> [--name label] Tail a log into the active session
|
|
114
214
|
See also: capture --help Full command list`);
|
|
115
215
|
}
|
|
@@ -121,17 +221,20 @@ See also: capture --help Full command list`);
|
|
|
121
221
|
async function start(rawArgs: string[]): Promise<void> {
|
|
122
222
|
const args = expandEqualsFlags(rawArgs);
|
|
123
223
|
let url: string | null = null;
|
|
224
|
+
let hold = false;
|
|
124
225
|
|
|
125
226
|
for (let i = 0; i < args.length; i++) {
|
|
126
227
|
if (args[i] === '--url' && args[i + 1]) {
|
|
127
228
|
url = args[++i];
|
|
229
|
+
} else if (args[i] === '--hold') {
|
|
230
|
+
hold = true;
|
|
128
231
|
}
|
|
129
232
|
}
|
|
130
233
|
|
|
131
234
|
const id = generateId();
|
|
132
235
|
const dir = sessionDir(id);
|
|
133
|
-
|
|
134
|
-
|
|
236
|
+
ensurePrivateDir(path.join(dir, 'shots'));
|
|
237
|
+
ensurePrivateDir(path.join(dir, 'a11y'));
|
|
135
238
|
|
|
136
239
|
// Start HAR recording directly
|
|
137
240
|
let harId: string | null = null;
|
|
@@ -145,11 +248,15 @@ async function start(rawArgs: string[]): Promise<void> {
|
|
|
145
248
|
// Open tab if URL provided. Fail fast if the new target cannot attach.
|
|
146
249
|
let targetId: string | null = null;
|
|
147
250
|
let pageLoadTimedOut = false;
|
|
251
|
+
let cdpPort: number | null = null;
|
|
252
|
+
if (url || hold) {
|
|
253
|
+
const { detectCdpPort } = await import('../cdp.js');
|
|
254
|
+
cdpPort = await detectCdpPort();
|
|
255
|
+
}
|
|
148
256
|
if (url) {
|
|
149
257
|
try {
|
|
150
|
-
const {
|
|
151
|
-
const
|
|
152
|
-
const tab = await openTab(port, url);
|
|
258
|
+
const { openTab, CDPClient } = await import('../cdp.js');
|
|
259
|
+
const tab = await openTab(cdpPort!, url);
|
|
153
260
|
targetId = tab.id;
|
|
154
261
|
|
|
155
262
|
if (!targetId) {
|
|
@@ -188,6 +295,29 @@ async function start(rawArgs: string[]): Promise<void> {
|
|
|
188
295
|
}
|
|
189
296
|
}
|
|
190
297
|
|
|
298
|
+
// Hold a CDP browser connection open for the session's lifetime so
|
|
299
|
+
// browser-level state (permission grants, ServiceWorker enablement)
|
|
300
|
+
// survives across separate `capture` commands instead of reverting the
|
|
301
|
+
// instant each command's own connection closes.
|
|
302
|
+
let bridgeSocket: string | null = null;
|
|
303
|
+
let bridgePid: number | null = null;
|
|
304
|
+
if (hold) {
|
|
305
|
+
try {
|
|
306
|
+
const bridge = await startBridge(dir, cdpPort!);
|
|
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
|
+
}
|
|
316
|
+
}
|
|
317
|
+
throw err;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
191
321
|
const session: Session = {
|
|
192
322
|
id,
|
|
193
323
|
dir,
|
|
@@ -197,8 +327,10 @@ async function start(rawArgs: string[]): Promise<void> {
|
|
|
197
327
|
targetId,
|
|
198
328
|
stepCount: 0,
|
|
199
329
|
logPids: [],
|
|
330
|
+
bridgeSocket,
|
|
331
|
+
bridgePid,
|
|
200
332
|
};
|
|
201
|
-
|
|
333
|
+
writeJsonPrivate(sessionMetaPath(id), session);
|
|
202
334
|
|
|
203
335
|
// Set as active session for auto-defaults
|
|
204
336
|
setActiveSession({
|
|
@@ -207,6 +339,7 @@ async function start(rawArgs: string[]): Promise<void> {
|
|
|
207
339
|
harId,
|
|
208
340
|
targetId,
|
|
209
341
|
stepCount: 0,
|
|
342
|
+
bridgeSocket,
|
|
210
343
|
});
|
|
211
344
|
|
|
212
345
|
// Output for agent consumption
|
|
@@ -218,6 +351,7 @@ async function start(rawArgs: string[]): Promise<void> {
|
|
|
218
351
|
pageLoadTimedOut,
|
|
219
352
|
shotsDir: path.join(dir, 'shots'),
|
|
220
353
|
a11yDir: path.join(dir, 'a11y'),
|
|
354
|
+
held: hold,
|
|
221
355
|
};
|
|
222
356
|
console.log(JSON.stringify(result, null, 2));
|
|
223
357
|
|
|
@@ -229,6 +363,9 @@ async function start(rawArgs: string[]): Promise<void> {
|
|
|
229
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.`);
|
|
230
364
|
}
|
|
231
365
|
}
|
|
366
|
+
if (hold) {
|
|
367
|
+
console.error(`CDP bridge held (pid ${bridgePid}) — browser-level state now survives across commands via: capture cdp <Method> --browser [--params '<json>']`);
|
|
368
|
+
}
|
|
232
369
|
console.error(`\nWhen done: capture session stop ${id}`);
|
|
233
370
|
}
|
|
234
371
|
|
|
@@ -269,10 +406,11 @@ export function logCommand(rawArgs: string[]): void {
|
|
|
269
406
|
name = name ?? path.basename(resolved, path.extname(resolved));
|
|
270
407
|
|
|
271
408
|
const logsDir = path.join(session.dir, 'logs');
|
|
272
|
-
|
|
409
|
+
ensurePrivateDir(logsDir);
|
|
273
410
|
|
|
274
411
|
const destPath = path.join(logsDir, `${name}.log`);
|
|
275
|
-
const outFd = fs.openSync(destPath, 'a');
|
|
412
|
+
const outFd = fs.openSync(destPath, 'a', FILE_MODE);
|
|
413
|
+
fs.chmodSync(destPath, FILE_MODE);
|
|
276
414
|
|
|
277
415
|
const child = spawn(
|
|
278
416
|
'sh',
|
|
@@ -284,7 +422,7 @@ export function logCommand(rawArgs: string[]): void {
|
|
|
284
422
|
|
|
285
423
|
const pid = child.pid!;
|
|
286
424
|
session.logPids.push({ pid, name, sourcePath: resolved });
|
|
287
|
-
|
|
425
|
+
writeJsonPrivate(sessionMetaPath(session.id), session);
|
|
288
426
|
|
|
289
427
|
console.log(JSON.stringify({ name, sourcePath: resolved, destPath, pid }, null, 2));
|
|
290
428
|
}
|
|
@@ -309,6 +447,12 @@ async function stop(args: string[]): Promise<void> {
|
|
|
309
447
|
await new Promise((r) => setTimeout(r, 200));
|
|
310
448
|
}
|
|
311
449
|
|
|
450
|
+
// Tear down the held CDP bridge, if any — releases the browser-level
|
|
451
|
+
// connection (and any grants/target-enablement it was keeping alive).
|
|
452
|
+
if (session.bridgePid || session.bridgeSocket) {
|
|
453
|
+
stopBridge(session.bridgePid, session.bridgeSocket);
|
|
454
|
+
}
|
|
455
|
+
|
|
312
456
|
// Collect screenshots
|
|
313
457
|
const shotsDir = path.join(session.dir, 'shots');
|
|
314
458
|
const screenshots = fs.existsSync(shotsDir)
|
|
@@ -332,7 +476,7 @@ async function stop(args: string[]): Promise<void> {
|
|
|
332
476
|
const harData = readHarRecording(session.harId);
|
|
333
477
|
if (harData) {
|
|
334
478
|
const harPath = path.join(session.dir, 'har.json');
|
|
335
|
-
|
|
479
|
+
writeJsonPrivate(harPath, harData);
|
|
336
480
|
har = { id: session.harId, path: harPath, entryCount: harData.log.entries.length };
|
|
337
481
|
// Clean up the HAR recording
|
|
338
482
|
try { deleteHarRecording(session.harId); } catch { /* best effort */ }
|
|
@@ -355,9 +499,13 @@ async function stop(args: string[]): Promise<void> {
|
|
|
355
499
|
})
|
|
356
500
|
: [];
|
|
357
501
|
|
|
502
|
+
// Collect measure snapshots and motion recordings
|
|
503
|
+
const snaps = collectSnaps(session.dir);
|
|
504
|
+
const recs = collectRecs(session.dir);
|
|
505
|
+
|
|
358
506
|
// Collect anything else dropped in the session dir
|
|
359
|
-
const knownDirs = new Set(['shots', 'a11y', 'logs']);
|
|
360
|
-
const knownFiles = new Set(['.session.json', 'har.json', 'bundle.json']);
|
|
507
|
+
const knownDirs = new Set(['shots', 'a11y', 'logs', 'measure', 'motion']);
|
|
508
|
+
const knownFiles = new Set(['.session.json', 'har.json', 'bundle.json', 'bridge.sock']);
|
|
361
509
|
const other = fs.readdirSync(session.dir)
|
|
362
510
|
.filter((f) => !knownDirs.has(f) && !knownFiles.has(f))
|
|
363
511
|
.map((f) => ({ name: f, path: path.join(session.dir, f) }));
|
|
@@ -376,10 +524,12 @@ async function stop(args: string[]): Promise<void> {
|
|
|
376
524
|
a11y,
|
|
377
525
|
logs,
|
|
378
526
|
other,
|
|
527
|
+
snaps,
|
|
528
|
+
recs,
|
|
379
529
|
};
|
|
380
530
|
|
|
381
531
|
const bundlePath = path.join(session.dir, 'bundle.json');
|
|
382
|
-
|
|
532
|
+
writeJsonPrivate(bundlePath, manifest);
|
|
383
533
|
|
|
384
534
|
console.log(JSON.stringify({
|
|
385
535
|
bundlePath,
|
|
@@ -390,6 +540,8 @@ async function stop(args: string[]): Promise<void> {
|
|
|
390
540
|
a11ySnapshots: a11y.length,
|
|
391
541
|
logFiles: logs.length,
|
|
392
542
|
otherFiles: other.length,
|
|
543
|
+
snaps: snaps.length,
|
|
544
|
+
recs: recs.length,
|
|
393
545
|
},
|
|
394
546
|
}, null, 2));
|
|
395
547
|
|
|
@@ -414,11 +566,21 @@ function list(): void {
|
|
|
414
566
|
console.log(JSON.stringify(sessions, null, 2));
|
|
415
567
|
}
|
|
416
568
|
|
|
569
|
+
// `session view --filter <name>` reads a manifest section by the same key
|
|
570
|
+
// name for most sections (screenshots|har|a11y|logs|other), but `measure`
|
|
571
|
+
// and `motion` are the query-facing filter names for the `snaps`/`recs`
|
|
572
|
+
// manifest keys (matching the `measure`/`motion` command branches, not the
|
|
573
|
+
// artifact-file naming), so they need an explicit alias.
|
|
574
|
+
const VIEW_FILTER_ALIASES: Record<string, keyof BundleManifest> = {
|
|
575
|
+
measure: 'snaps',
|
|
576
|
+
motion: 'recs',
|
|
577
|
+
};
|
|
578
|
+
|
|
417
579
|
function view(rawArgs: string[]): void {
|
|
418
580
|
const args = expandEqualsFlags(rawArgs);
|
|
419
581
|
const id = args[0];
|
|
420
582
|
if (!id) {
|
|
421
|
-
console.error('Usage: capture session view <session-id> [--filter screenshots|har|a11y]');
|
|
583
|
+
console.error('Usage: capture session view <session-id> [--filter screenshots|har|a11y|logs|other|measure|motion]');
|
|
422
584
|
process.exit(1);
|
|
423
585
|
}
|
|
424
586
|
|
|
@@ -434,7 +596,8 @@ function view(rawArgs: string[]): void {
|
|
|
434
596
|
|
|
435
597
|
const filter = args.find((_, i) => args[i - 1] === '--filter');
|
|
436
598
|
if (filter) {
|
|
437
|
-
const
|
|
599
|
+
const key = VIEW_FILTER_ALIASES[filter] ?? (filter as keyof BundleManifest);
|
|
600
|
+
const section = manifest[key];
|
|
438
601
|
console.log(JSON.stringify(section, null, 2));
|
|
439
602
|
} else {
|
|
440
603
|
console.log(JSON.stringify(manifest, null, 2));
|
package/src/session-context.ts
CHANGED
|
@@ -2,16 +2,33 @@
|
|
|
2
2
|
* Session Context — persists active session state so CDP commands
|
|
3
3
|
* can auto-fill --target, --har, and --out without manual threading.
|
|
4
4
|
*
|
|
5
|
-
* State is written to /tmp/capture-sessions/.active (JSON)
|
|
5
|
+
* State is written to /tmp/capture-sessions/.active-<scope> (JSON), where
|
|
6
|
+
* <scope> isolates concurrent callers (see activeScopeKey() below) so one
|
|
7
|
+
* caller's `session start` never clobbers another's active pointer.
|
|
6
8
|
* Stale sessions (dir deleted, crashed process) are cleaned up on read.
|
|
7
9
|
*/
|
|
8
10
|
|
|
9
11
|
import * as fs from 'fs';
|
|
10
12
|
import * as path from 'path';
|
|
11
|
-
import
|
|
13
|
+
import { CAPTURE_ROOT } from './session/artifacts.js';
|
|
12
14
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
+
/**
|
|
16
|
+
* The active-session pointer is scoped per caller so concurrent, unrelated
|
|
17
|
+
* capture invocations never clobber one another's "active session" — e.g.
|
|
18
|
+
* two crtr agent nodes each running `capture session start` in parallel.
|
|
19
|
+
* When invoked from a crtr node, CRTR_NODE_ID scopes the pointer to that
|
|
20
|
+
* node (stable across separate tool calls within the node's lifetime).
|
|
21
|
+
* Outside crtr (a bare interactive terminal), there's no such identity, so
|
|
22
|
+
* fall back to the single legacy pointer — the original single-user,
|
|
23
|
+
* single-session-at-a-time contract still holds there.
|
|
24
|
+
*/
|
|
25
|
+
function activeScopeKey(): string {
|
|
26
|
+
return process.env.CRTR_NODE_ID ?? 'default';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function getActivePath(): string {
|
|
30
|
+
return path.join(CAPTURE_ROOT, `.active-${activeScopeKey()}`);
|
|
31
|
+
}
|
|
15
32
|
|
|
16
33
|
export interface ActiveSessionState {
|
|
17
34
|
sessionId: string;
|
|
@@ -19,12 +36,22 @@ export interface ActiveSessionState {
|
|
|
19
36
|
harId: string | null;
|
|
20
37
|
targetId: string | null;
|
|
21
38
|
stepCount: number;
|
|
39
|
+
/** Unix socket of the session's held CDP bridge (`session start --hold`), if any. */
|
|
40
|
+
bridgeSocket?: string | null;
|
|
41
|
+
/**
|
|
42
|
+
* The id of the recording (`motion/recs/{recId}`) currently live under
|
|
43
|
+
* this session, if `motion rec --start` has armed one. Cleared by
|
|
44
|
+
* `motion rec --stop` (or a stale-recorder reap). See
|
|
45
|
+
* {@link setActiveRecId}/{@link clearActiveRecId}/{@link getActiveRecId}.
|
|
46
|
+
*/
|
|
47
|
+
activeRecId?: string | null;
|
|
22
48
|
}
|
|
23
49
|
|
|
24
50
|
export function getActiveSession(): ActiveSessionState | null {
|
|
25
51
|
try {
|
|
26
|
-
|
|
27
|
-
|
|
52
|
+
const activePath = getActivePath();
|
|
53
|
+
if (!fs.existsSync(activePath)) return null;
|
|
54
|
+
const state = JSON.parse(fs.readFileSync(activePath, 'utf-8')) as ActiveSessionState;
|
|
28
55
|
// Validate session dir still exists — clean up stale files
|
|
29
56
|
if (!fs.existsSync(state.dir)) {
|
|
30
57
|
clearActiveSession();
|
|
@@ -38,12 +65,12 @@ export function getActiveSession(): ActiveSessionState | null {
|
|
|
38
65
|
|
|
39
66
|
export function setActiveSession(state: ActiveSessionState): void {
|
|
40
67
|
fs.mkdirSync(CAPTURE_ROOT, { recursive: true });
|
|
41
|
-
fs.writeFileSync(
|
|
68
|
+
fs.writeFileSync(getActivePath(), JSON.stringify(state, null, 2));
|
|
42
69
|
}
|
|
43
70
|
|
|
44
71
|
export function clearActiveSession(): void {
|
|
45
72
|
try {
|
|
46
|
-
fs.unlinkSync(
|
|
73
|
+
fs.unlinkSync(getActivePath());
|
|
47
74
|
} catch {
|
|
48
75
|
// Already gone
|
|
49
76
|
}
|
|
@@ -57,6 +84,21 @@ export function updateActiveSession(patch: Partial<ActiveSessionState>): ActiveS
|
|
|
57
84
|
return updated;
|
|
58
85
|
}
|
|
59
86
|
|
|
87
|
+
/** Records `recId` as the active session's live recording. */
|
|
88
|
+
export function setActiveRecId(recId: string): ActiveSessionState | null {
|
|
89
|
+
return updateActiveSession({ activeRecId: recId });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Clears the active session's live-recording pointer (on `rec --stop` / stale reap). */
|
|
93
|
+
export function clearActiveRecId(): ActiveSessionState | null {
|
|
94
|
+
return updateActiveSession({ activeRecId: null });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Reads the active session's live recording id, or `null` if none. */
|
|
98
|
+
export function getActiveRecId(): string | null {
|
|
99
|
+
return getActiveSession()?.activeRecId ?? null;
|
|
100
|
+
}
|
|
101
|
+
|
|
60
102
|
function sanitizeLabel(label: string): string {
|
|
61
103
|
return label
|
|
62
104
|
.toLowerCase()
|