@crouton-kit/capture 1.3.7 → 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.
Files changed (40) hide show
  1. package/bin/capture +2807 -1312
  2. package/package.json +4 -1
  3. package/src/capture.ts +10 -0
  4. package/src/cdp/args.ts +99 -10
  5. package/src/cdp/bridge/protocol.ts +99 -0
  6. package/src/cdp/bridge/server.ts +105 -43
  7. package/src/cdp/bridge/spawn.ts +62 -0
  8. package/src/cdp/commands/bridge-serve.ts +28 -3
  9. package/src/cdp/commands/cdp.ts +30 -2
  10. package/src/cdp/commands/measure/census.ts +31 -0
  11. package/src/cdp/commands/measure/check.ts +35 -0
  12. package/src/cdp/commands/measure/diff.ts +32 -0
  13. package/src/cdp/commands/measure/explain.ts +31 -0
  14. package/src/cdp/commands/measure/index.ts +114 -0
  15. package/src/cdp/commands/measure/map-focus.ts +24 -0
  16. package/src/cdp/commands/measure/map-layers.ts +25 -0
  17. package/src/cdp/commands/measure/map-scroll.ts +24 -0
  18. package/src/cdp/commands/measure/snap.ts +36 -0
  19. package/src/cdp/commands/measure/sweep.ts +30 -0
  20. package/src/cdp/commands/motion/index.ts +60 -0
  21. package/src/cdp/commands/motion/jank.ts +22 -0
  22. package/src/cdp/commands/motion/mask.ts +23 -0
  23. package/src/cdp/commands/motion/rec.ts +33 -0
  24. package/src/cdp/commands/motion/response.ts +26 -0
  25. package/src/cdp/commands/motion/timeline.ts +27 -0
  26. package/src/cdp/coordinates.ts +158 -0
  27. package/src/cdp/dispatch.ts +4 -0
  28. package/src/cdp/domains.ts +75 -0
  29. package/src/cdp/recorder-bridge.ts +521 -0
  30. package/src/cdp/source-map.ts +309 -0
  31. package/src/cdp/timing.ts +82 -0
  32. package/src/cdp/types.ts +66 -0
  33. package/src/cdp/usage.ts +2 -0
  34. package/src/output/artifact.ts +520 -0
  35. package/src/output/diff.ts +111 -0
  36. package/src/output/render.ts +593 -0
  37. package/src/output/selector.ts +185 -0
  38. package/src/session/artifacts.ts +229 -0
  39. package/src/session/commands.ts +119 -15
  40. package/src/session-context.ts +48 -8
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Selector helpers — map a query leaf's `--selector`/`--element` input
3
+ * string to the stored per-element records a snapshot's collector files
4
+ * carry. Pure lookups over an already-read array; this module never reads
5
+ * an artifact file itself (see `src/output/artifact.ts` for that).
6
+ *
7
+ * Contract this module fixes for upstream collector writers: any collector
8
+ * file that indexes elements (`geometry.json` first and foremost — see the
9
+ * contract note at the top of `artifact.ts`) should shape each element's
10
+ * record to satisfy {@link ElementRecord} — a stable `id`, and whichever of
11
+ * `selector`/`backendNodeId`/`axId`/`axName`/`text` it has facts for — so a
12
+ * query leaf's selector lookup works the same way regardless of which
13
+ * collector file it pulled the array from.
14
+ */
15
+
16
+ // ============================================================================
17
+ // Element records
18
+ // ============================================================================
19
+
20
+ export interface ElementRecord {
21
+ /** Stable per-snapshot element id (e.g. `el-14`) collectors key off of. */
22
+ readonly id: string;
23
+ /** The collector-computed CSS-ish selector string for this element
24
+ * (e.g. `.message-card:nth-child(4)`), when known. */
25
+ readonly selector?: string;
26
+ readonly backendNodeId?: number;
27
+ /** Accessibility node id, when the element has a non-ignored AX node. */
28
+ readonly axId?: string;
29
+ /** Accessibility computed name, when known. */
30
+ readonly axName?: string;
31
+ /** Text content associated with the element, when known. */
32
+ readonly text?: string;
33
+ readonly [key: string]: unknown;
34
+ }
35
+
36
+ // ============================================================================
37
+ // Direct lookups
38
+ // ============================================================================
39
+
40
+ /** Elements whose stored `selector` exactly equals `selector`. */
41
+ export function findBySelector(elements: readonly ElementRecord[], selector: string): ElementRecord[] {
42
+ return elements.filter((e) => e.selector === selector);
43
+ }
44
+
45
+ /** The element with this `backendNodeId`, if any. */
46
+ export function findByBackendNodeId(elements: readonly ElementRecord[], backendNodeId: number): ElementRecord | undefined {
47
+ return elements.find((e) => e.backendNodeId === backendNodeId);
48
+ }
49
+
50
+ /** The element with this exact `axId`, if any. */
51
+ export function findByAxId(elements: readonly ElementRecord[], axId: string): ElementRecord | undefined {
52
+ return elements.find((e) => e.axId === axId);
53
+ }
54
+
55
+ /** Elements whose `axName` matches `name` — exact (case-insensitive) by
56
+ * default, or a case-insensitive substring match with `{ exact: false }`. */
57
+ export function findByAxName(
58
+ elements: readonly ElementRecord[],
59
+ name: string,
60
+ opts: { exact?: boolean } = {},
61
+ ): ElementRecord[] {
62
+ const exact = opts.exact ?? true;
63
+ const needle = name.toLowerCase();
64
+ return elements.filter((e) => {
65
+ if (e.axName === undefined) return false;
66
+ const hay = e.axName.toLowerCase();
67
+ return exact ? hay === needle : hay.includes(needle);
68
+ });
69
+ }
70
+
71
+ /** Elements whose `text` matches `needle` — case-insensitive substring by
72
+ * default, or an exact (case-insensitive) match with `{ exact: true }`. */
73
+ export function findByText(
74
+ elements: readonly ElementRecord[],
75
+ needle: string,
76
+ opts: { exact?: boolean } = {},
77
+ ): ElementRecord[] {
78
+ const exact = opts.exact ?? false;
79
+ const target = needle.toLowerCase();
80
+ return elements.filter((e) => {
81
+ if (e.text === undefined) return false;
82
+ const hay = e.text.toLowerCase();
83
+ return exact ? hay === target : hay.includes(target);
84
+ });
85
+ }
86
+
87
+ // ============================================================================
88
+ // Selector-input grammar
89
+ // ============================================================================
90
+
91
+ export type SelectorInputKind = 'backend' | 'axid' | 'ax' | 'text' | 'css';
92
+
93
+ export interface ParsedSelectorInput {
94
+ readonly kind: SelectorInputKind;
95
+ readonly value: string;
96
+ }
97
+
98
+ /**
99
+ * Parses a `--selector`/`--element` input string into its lookup kind:
100
+ * - `backend:1234` — backend node id
101
+ * - `axid:<id>` — accessibility node id
102
+ * - `ax:<name>` — accessibility name (substring)
103
+ * - `text:<needle>`— text content (substring)
104
+ * - anything else — a CSS-ish selector, matched exactly against the
105
+ * collector's stored `selector` string (the form every design sample
106
+ * uses, e.g. `--selector ".toast-container"`)
107
+ */
108
+ export function parseSelectorInput(input: string): ParsedSelectorInput {
109
+ const prefixes: Array<[string, SelectorInputKind]> = [
110
+ ['backend:', 'backend'],
111
+ ['axid:', 'axid'],
112
+ ['ax:', 'ax'],
113
+ ['text:', 'text'],
114
+ ];
115
+ for (const [prefix, kind] of prefixes) {
116
+ if (input.startsWith(prefix)) {
117
+ return { kind, value: input.slice(prefix.length) };
118
+ }
119
+ }
120
+ return { kind: 'css', value: input };
121
+ }
122
+
123
+ /**
124
+ * Resolves a `--selector`/`--element` input string to every matching
125
+ * element record, dispatching on {@link parseSelectorInput}'s grammar. This
126
+ * is the one entry point query leaves should call for user-supplied
127
+ * selector input rather than picking a `findBy*` helper themselves.
128
+ */
129
+ export function resolveSelectorInput(elements: readonly ElementRecord[], input: string): ElementRecord[] {
130
+ const parsed = parseSelectorInput(input);
131
+ switch (parsed.kind) {
132
+ case 'backend': {
133
+ const id = Number(parsed.value);
134
+ if (!Number.isFinite(id)) return [];
135
+ const found = findByBackendNodeId(elements, id);
136
+ return found ? [found] : [];
137
+ }
138
+ case 'axid': {
139
+ const found = findByAxId(elements, parsed.value);
140
+ return found ? [found] : [];
141
+ }
142
+ case 'ax':
143
+ return findByAxName(elements, parsed.value, { exact: false });
144
+ case 'text':
145
+ return findByText(elements, parsed.value, { exact: false });
146
+ case 'css':
147
+ return findBySelector(elements, parsed.value);
148
+ }
149
+ }
150
+
151
+ // ============================================================================
152
+ // Recovery hints
153
+ // ============================================================================
154
+
155
+ export interface SelectorHints {
156
+ readonly selectors: readonly string[];
157
+ readonly axNames: readonly string[];
158
+ readonly texts: readonly string[];
159
+ }
160
+
161
+ function uniqueTruncated(values: readonly (string | undefined)[], limit: number): string[] {
162
+ const seen = new Set<string>();
163
+ for (const v of values) {
164
+ if (v === undefined) continue;
165
+ seen.add(v);
166
+ if (seen.size >= limit) break;
167
+ }
168
+ return [...seen];
169
+ }
170
+
171
+ /**
172
+ * Example selector/AX-name/text values present in `elements`, for a query
173
+ * leaf to embed in a "missing selector" structured recovery error (per the
174
+ * design: "missing selector returns a structured recovery error listing
175
+ * available selector forms"). This module only gathers the raw examples —
176
+ * the calling leaf renders them through `src/output/render.ts`'s `data()`
177
+ * so untrusted DOM/AX/text content is escaped before it reaches output.
178
+ */
179
+ export function selectorHints(elements: readonly ElementRecord[], limit = 10): SelectorHints {
180
+ return {
181
+ selectors: uniqueTruncated(elements.map((e) => e.selector), limit),
182
+ axNames: uniqueTruncated(elements.map((e) => e.axName), limit),
183
+ texts: uniqueTruncated(elements.map((e) => e.text), limit),
184
+ };
185
+ }
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Secure artifact filesystem — the one place every session/oneshot artifact
3
+ * writer (screenshots, HAR, snapshot substrate, recordings) goes through.
4
+ *
5
+ * Contract: directories are created `0700`, files are created `0600`,
6
+ * writes are atomic (temp file + rename) and never follow a symlink planted
7
+ * at the destination, and every helper refuses to operate outside
8
+ * `CAPTURE_ROOT`. Downstream units (snapshot collectors, the recorder
9
+ * bridge, motion query leaves) write ALL artifacts through these helpers
10
+ * instead of ad-hoc `fs.writeFile`/`fs.mkdirSync`.
11
+ */
12
+
13
+ import * as fs from 'fs';
14
+ import * as os from 'os';
15
+ import * as path from 'path';
16
+
17
+ /** The one canonical session/oneshot root. Every artifact lives under here. */
18
+ export const CAPTURE_ROOT = path.join(os.tmpdir(), 'capture-sessions');
19
+
20
+ /** Owner rwx only. */
21
+ export const DIR_MODE = 0o700;
22
+ /** Owner rw only. */
23
+ export const FILE_MODE = 0o600;
24
+
25
+ // ============================================================================
26
+ // Meta.json contracts — the shape session stop() reads back out of
27
+ // measure/snaps/*/meta.json and motion/recs/*/meta.json to build the bundle
28
+ // manifest's `snaps`/`recs` arrays. Later units (U06 snapshot orchestrator,
29
+ // U13/U14 recorder) write meta.json in this shape.
30
+ // ============================================================================
31
+
32
+ export interface SnapMeta {
33
+ id: string;
34
+ url: string | null;
35
+ viewport: string | null;
36
+ settled: boolean;
37
+ capturedAt: string;
38
+ }
39
+
40
+ export interface RecMeta {
41
+ id: string;
42
+ action: string | null;
43
+ frames: number;
44
+ durationMs: number;
45
+ state: string;
46
+ }
47
+
48
+ // ============================================================================
49
+ // Path boundary
50
+ // ============================================================================
51
+
52
+ /**
53
+ * Resolves `targetPath` to an absolute path and throws unless it is
54
+ * strictly under `CAPTURE_ROOT`. Every write/cleanup helper below routes
55
+ * through this so a caller can never be tricked (or mistaken) into writing
56
+ * or deleting outside the capture artifact tree.
57
+ */
58
+ export function assertUnderCaptureRoot(targetPath: string): string {
59
+ const resolved = path.resolve(targetPath);
60
+ const root = path.resolve(CAPTURE_ROOT);
61
+ const rel = path.relative(root, resolved);
62
+ if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) {
63
+ throw new Error(`artifact path escapes capture root ${root}: ${targetPath}`);
64
+ }
65
+ return resolved;
66
+ }
67
+
68
+ // ============================================================================
69
+ // Directories
70
+ // ============================================================================
71
+
72
+ /** Creates (or re-secures) one path segment as a private, non-symlink dir. */
73
+ function ensureSegmentPrivate(dirPath: string): void {
74
+ let st: fs.Stats;
75
+ try {
76
+ st = fs.lstatSync(dirPath);
77
+ } catch (err) {
78
+ if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
79
+ fs.mkdirSync(dirPath, { mode: DIR_MODE });
80
+ // mkdir's mode is masked by umask; enforce explicitly.
81
+ fs.chmodSync(dirPath, DIR_MODE);
82
+ return;
83
+ }
84
+ if (st.isSymbolicLink()) {
85
+ throw new Error(`refusing to use a symlinked artifact directory: ${dirPath}`);
86
+ }
87
+ if (!st.isDirectory()) {
88
+ throw new Error(`artifact path exists and is not a directory: ${dirPath}`);
89
+ }
90
+ fs.chmodSync(dirPath, DIR_MODE);
91
+ }
92
+
93
+ /**
94
+ * Ensures `dirPath` and every missing ancestor up to (and including)
95
+ * `CAPTURE_ROOT` exist as private (`0700`), non-symlink directories.
96
+ * Existing directories in the chain are re-chmod'd to `0700` rather than
97
+ * trusted. Returns the resolved absolute path. Throws if `dirPath` is not
98
+ * under `CAPTURE_ROOT`, or if any path segment is a symlink or a
99
+ * non-directory.
100
+ */
101
+ export function ensurePrivateDir(dirPath: string): string {
102
+ const resolved = assertUnderCaptureRoot(dirPath);
103
+ const root = path.resolve(CAPTURE_ROOT);
104
+
105
+ ensureSegmentPrivate(root);
106
+ const rel = path.relative(root, resolved);
107
+ let current = root;
108
+ for (const part of rel.split(path.sep).filter(Boolean)) {
109
+ current = path.join(current, part);
110
+ ensureSegmentPrivate(current);
111
+ }
112
+ return resolved;
113
+ }
114
+
115
+ // ============================================================================
116
+ // Atomic, no-symlink-follow writes
117
+ // ============================================================================
118
+
119
+ function refuseExistingSymlink(resolved: string): void {
120
+ try {
121
+ const st = fs.lstatSync(resolved);
122
+ if (st.isSymbolicLink()) {
123
+ throw new Error(`refusing to write through a symlink: ${resolved}`);
124
+ }
125
+ } catch (err) {
126
+ if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Writes `data` to `filePath` as a private (`0600`) file, atomically:
132
+ * writes to a uniquely-named temp file in the same directory (opened with
133
+ * `O_NOFOLLOW`, so a pre-planted symlink at the temp name can't be written
134
+ * through) and renames it over the destination. `rename(2)` replaces
135
+ * whatever is at the destination — including a symlink — without ever
136
+ * following it, so a symlink swapped in at `filePath` between calls is
137
+ * unlinked, never written through. Ensures the parent directory exists
138
+ * (and is private) first. This is the one primitive every other write
139
+ * helper in this module is built on.
140
+ */
141
+ export function writePrivateFile(filePath: string, data: string | Buffer): void {
142
+ const resolved = assertUnderCaptureRoot(filePath);
143
+ const dir = path.dirname(resolved);
144
+ ensurePrivateDir(dir);
145
+ refuseExistingSymlink(resolved);
146
+
147
+ const tmpPath = path.join(dir, `.${path.basename(resolved)}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}.tmp`);
148
+ const fd = fs.openSync(
149
+ tmpPath,
150
+ fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_TRUNC | fs.constants.O_NOFOLLOW,
151
+ FILE_MODE,
152
+ );
153
+ try {
154
+ fs.writeSync(fd, data);
155
+ } finally {
156
+ fs.closeSync(fd);
157
+ }
158
+ fs.chmodSync(tmpPath, FILE_MODE);
159
+ fs.renameSync(tmpPath, resolved);
160
+ }
161
+
162
+ /** Writes `value` as pretty-printed JSON through {@link writePrivateFile}. */
163
+ export function writeJsonPrivate(filePath: string, value: unknown): void {
164
+ writePrivateFile(filePath, JSON.stringify(value, null, 2));
165
+ }
166
+
167
+ /**
168
+ * Writes `records` as a complete newline-delimited JSON file (one JSON
169
+ * value per line), replacing any existing content atomically. For
170
+ * incremental line-at-a-time appends (e.g. a live recorder streaming
171
+ * frames/events), use {@link appendNdjsonPrivate} instead.
172
+ */
173
+ export function writeNdjsonPrivate(filePath: string, records: unknown[]): void {
174
+ const body = records.map((record) => JSON.stringify(record)).join('\n') + (records.length ? '\n' : '');
175
+ writePrivateFile(filePath, body);
176
+ }
177
+
178
+ /**
179
+ * Appends one record as a single NDJSON line to `filePath`, creating it
180
+ * (private, `0600`) if absent. Not atomic across the whole file the way
181
+ * {@link writePrivateFile} is — it's the incremental-write counterpart used
182
+ * while a recording is live — but still refuses to append through a
183
+ * symlink and enforces the private mode on every call.
184
+ */
185
+ export function appendNdjsonPrivate(filePath: string, record: unknown): void {
186
+ const resolved = assertUnderCaptureRoot(filePath);
187
+ const dir = path.dirname(resolved);
188
+ ensurePrivateDir(dir);
189
+ refuseExistingSymlink(resolved);
190
+
191
+ const line = JSON.stringify(record) + '\n';
192
+ const fd = fs.openSync(
193
+ resolved,
194
+ fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_APPEND | fs.constants.O_NOFOLLOW,
195
+ FILE_MODE,
196
+ );
197
+ try {
198
+ fs.writeSync(fd, line);
199
+ } finally {
200
+ fs.closeSync(fd);
201
+ }
202
+ fs.chmodSync(resolved, FILE_MODE);
203
+ }
204
+
205
+ /**
206
+ * Writes binary artifact data (PNG crops, `video.webm`, ...) through
207
+ * {@link writePrivateFile}. A distinct named export for call-site clarity;
208
+ * behavior is identical to passing a `Buffer` to `writePrivateFile`.
209
+ */
210
+ export function writeBinaryPrivate(filePath: string, data: Buffer): void {
211
+ writePrivateFile(filePath, data);
212
+ }
213
+
214
+ // ============================================================================
215
+ // Cleanup
216
+ // ============================================================================
217
+
218
+ /**
219
+ * Recursively removes the artifact tree at `targetPath` (a one-shot
220
+ * session dir, an active session's `measure/snaps/{id}`, a
221
+ * `motion/recs/{id}`, ...). Refuses any path that is not strictly under
222
+ * `CAPTURE_ROOT` (including `CAPTURE_ROOT` itself). If `targetPath` is
223
+ * itself a symlink, only the symlink is unlinked — its target is never
224
+ * traversed or deleted.
225
+ */
226
+ export function removeArtifactTree(targetPath: string): void {
227
+ const resolved = assertUnderCaptureRoot(targetPath);
228
+ fs.rmSync(resolved, { recursive: true, force: true });
229
+ }
@@ -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,
@@ -14,8 +13,14 @@ import {
14
13
  } from '../session-context.js';
15
14
  import { expandEqualsFlags } from '../cdp/args.js';
16
15
  import { startBridge, stopBridge } from '../cdp/bridge/spawn.js';
17
-
18
- const CAPTURE_ROOT = path.join(os.tmpdir(), 'capture-sessions');
16
+ import {
17
+ CAPTURE_ROOT,
18
+ FILE_MODE,
19
+ ensurePrivateDir,
20
+ writeJsonPrivate,
21
+ type SnapMeta,
22
+ type RecMeta,
23
+ } from './artifacts.js';
19
24
 
20
25
  interface LogPid {
21
26
  pid: number;
@@ -48,12 +53,90 @@ interface BundleManifest {
48
53
  a11y: Array<{ name: string; path: string }>;
49
54
  logs: Array<{ name: string; path: string; lines: number }>;
50
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 }>;
51
60
  }
52
61
 
53
62
  function sessionDir(id: string): string {
54
63
  return path.join(CAPTURE_ROOT, id);
55
64
  }
56
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
+
57
140
  function sessionMetaPath(id: string): string {
58
141
  return path.join(sessionDir(id), '.session.json');
59
142
  }
@@ -98,7 +181,8 @@ Sub-commands:
98
181
  --hold keeps one CDP browser connection open for the session
99
182
  stop <session-id> Finalize and bundle artifacts (screenshots, HAR, a11y, logs)
100
183
  list List active and stopped sessions
101
- 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
102
186
 
103
187
  Why sessions: once started, every subsequent capture command auto-fills
104
188
  --target (the tab) and --har (the recording). No manual flag threading.
@@ -149,8 +233,8 @@ async function start(rawArgs: string[]): Promise<void> {
149
233
 
150
234
  const id = generateId();
151
235
  const dir = sessionDir(id);
152
- fs.mkdirSync(path.join(dir, 'shots'), { recursive: true });
153
- fs.mkdirSync(path.join(dir, 'a11y'), { recursive: true });
236
+ ensurePrivateDir(path.join(dir, 'shots'));
237
+ ensurePrivateDir(path.join(dir, 'a11y'));
154
238
 
155
239
  // Start HAR recording directly
156
240
  let harId: string | null = null;
@@ -246,7 +330,7 @@ async function start(rawArgs: string[]): Promise<void> {
246
330
  bridgeSocket,
247
331
  bridgePid,
248
332
  };
249
- fs.writeFileSync(sessionMetaPath(id), JSON.stringify(session, null, 2));
333
+ writeJsonPrivate(sessionMetaPath(id), session);
250
334
 
251
335
  // Set as active session for auto-defaults
252
336
  setActiveSession({
@@ -322,10 +406,11 @@ export function logCommand(rawArgs: string[]): void {
322
406
  name = name ?? path.basename(resolved, path.extname(resolved));
323
407
 
324
408
  const logsDir = path.join(session.dir, 'logs');
325
- fs.mkdirSync(logsDir, { recursive: true });
409
+ ensurePrivateDir(logsDir);
326
410
 
327
411
  const destPath = path.join(logsDir, `${name}.log`);
328
- const outFd = fs.openSync(destPath, 'a');
412
+ const outFd = fs.openSync(destPath, 'a', FILE_MODE);
413
+ fs.chmodSync(destPath, FILE_MODE);
329
414
 
330
415
  const child = spawn(
331
416
  'sh',
@@ -337,7 +422,7 @@ export function logCommand(rawArgs: string[]): void {
337
422
 
338
423
  const pid = child.pid!;
339
424
  session.logPids.push({ pid, name, sourcePath: resolved });
340
- fs.writeFileSync(sessionMetaPath(session.id), JSON.stringify(session, null, 2));
425
+ writeJsonPrivate(sessionMetaPath(session.id), session);
341
426
 
342
427
  console.log(JSON.stringify({ name, sourcePath: resolved, destPath, pid }, null, 2));
343
428
  }
@@ -391,7 +476,7 @@ async function stop(args: string[]): Promise<void> {
391
476
  const harData = readHarRecording(session.harId);
392
477
  if (harData) {
393
478
  const harPath = path.join(session.dir, 'har.json');
394
- fs.writeFileSync(harPath, JSON.stringify(harData, null, 2));
479
+ writeJsonPrivate(harPath, harData);
395
480
  har = { id: session.harId, path: harPath, entryCount: harData.log.entries.length };
396
481
  // Clean up the HAR recording
397
482
  try { deleteHarRecording(session.harId); } catch { /* best effort */ }
@@ -414,8 +499,12 @@ async function stop(args: string[]): Promise<void> {
414
499
  })
415
500
  : [];
416
501
 
502
+ // Collect measure snapshots and motion recordings
503
+ const snaps = collectSnaps(session.dir);
504
+ const recs = collectRecs(session.dir);
505
+
417
506
  // Collect anything else dropped in the session dir
418
- const knownDirs = new Set(['shots', 'a11y', 'logs']);
507
+ const knownDirs = new Set(['shots', 'a11y', 'logs', 'measure', 'motion']);
419
508
  const knownFiles = new Set(['.session.json', 'har.json', 'bundle.json', 'bridge.sock']);
420
509
  const other = fs.readdirSync(session.dir)
421
510
  .filter((f) => !knownDirs.has(f) && !knownFiles.has(f))
@@ -435,10 +524,12 @@ async function stop(args: string[]): Promise<void> {
435
524
  a11y,
436
525
  logs,
437
526
  other,
527
+ snaps,
528
+ recs,
438
529
  };
439
530
 
440
531
  const bundlePath = path.join(session.dir, 'bundle.json');
441
- fs.writeFileSync(bundlePath, JSON.stringify(manifest, null, 2));
532
+ writeJsonPrivate(bundlePath, manifest);
442
533
 
443
534
  console.log(JSON.stringify({
444
535
  bundlePath,
@@ -449,6 +540,8 @@ async function stop(args: string[]): Promise<void> {
449
540
  a11ySnapshots: a11y.length,
450
541
  logFiles: logs.length,
451
542
  otherFiles: other.length,
543
+ snaps: snaps.length,
544
+ recs: recs.length,
452
545
  },
453
546
  }, null, 2));
454
547
 
@@ -473,11 +566,21 @@ function list(): void {
473
566
  console.log(JSON.stringify(sessions, null, 2));
474
567
  }
475
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
+
476
579
  function view(rawArgs: string[]): void {
477
580
  const args = expandEqualsFlags(rawArgs);
478
581
  const id = args[0];
479
582
  if (!id) {
480
- 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]');
481
584
  process.exit(1);
482
585
  }
483
586
 
@@ -493,7 +596,8 @@ function view(rawArgs: string[]): void {
493
596
 
494
597
  const filter = args.find((_, i) => args[i - 1] === '--filter');
495
598
  if (filter) {
496
- const section = manifest[filter as keyof BundleManifest];
599
+ const key = VIEW_FILTER_ALIASES[filter] ?? (filter as keyof BundleManifest);
600
+ const section = manifest[key];
497
601
  console.log(JSON.stringify(section, null, 2));
498
602
  } else {
499
603
  console.log(JSON.stringify(manifest, null, 2));