@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
|
@@ -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
|
+
}
|