@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.
- package/bin/capture +2807 -1312
- package/package.json +4 -1
- package/src/capture.ts +10 -0
- package/src/cdp/args.ts +99 -10
- package/src/cdp/bridge/protocol.ts +99 -0
- package/src/cdp/bridge/server.ts +105 -43
- package/src/cdp/bridge/spawn.ts +62 -0
- package/src/cdp/commands/bridge-serve.ts +28 -3
- package/src/cdp/commands/cdp.ts +30 -2
- 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 +4 -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/timing.ts +82 -0
- package/src/cdp/types.ts +66 -0
- package/src/cdp/usage.ts +2 -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 +119 -15
- 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
|
+
}
|
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,
|
|
@@ -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
|
-
|
|
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
|
-
|
|
153
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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));
|