@crouton-kit/capture 1.3.8 → 1.3.10

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 (136) hide show
  1. package/bin/capture +29841 -8539
  2. package/package.json +6 -3
  3. package/src/capture.ts +86 -173
  4. package/src/cdp/a11y.ts +113 -14
  5. package/src/cdp/args.ts +300 -205
  6. package/src/cdp/bridge/protocol.ts +93 -6
  7. package/src/cdp/bridge/server.ts +118 -53
  8. package/src/cdp/bridge/spawn.ts +53 -6
  9. package/src/cdp/client.ts +13 -3
  10. package/src/cdp/commands/bridge-serve.ts +20 -14
  11. package/src/cdp/commands/cdp.ts +259 -90
  12. package/src/cdp/commands/gate-guard.ts +43 -0
  13. package/src/cdp/commands/lib.ts +339 -213
  14. package/src/cdp/commands/measure/census.ts +76 -20
  15. package/src/cdp/commands/measure/check.ts +100 -26
  16. package/src/cdp/commands/measure/diff.ts +109 -21
  17. package/src/cdp/commands/measure/explain.ts +121 -19
  18. package/src/cdp/commands/measure/index.ts +31 -39
  19. package/src/cdp/commands/measure/map-ax.ts +52 -0
  20. package/src/cdp/commands/measure/map-focus.ts +41 -13
  21. package/src/cdp/commands/measure/map-layers.ts +49 -15
  22. package/src/cdp/commands/measure/map-scroll.ts +85 -15
  23. package/src/cdp/commands/measure/snap.ts +405 -25
  24. package/src/cdp/commands/measure/sweep.ts +217 -20
  25. package/src/cdp/commands/motion/index.ts +18 -17
  26. package/src/cdp/commands/motion/jank.ts +119 -9
  27. package/src/cdp/commands/motion/mask.ts +81 -12
  28. package/src/cdp/commands/motion/rec.ts +520 -19
  29. package/src/cdp/commands/motion/response.ts +65 -15
  30. package/src/cdp/commands/motion/timeline.ts +139 -14
  31. package/src/cdp/commands/page/click.ts +194 -0
  32. package/src/cdp/commands/page/elements.ts +154 -0
  33. package/src/cdp/commands/page/exec.ts +317 -0
  34. package/src/cdp/commands/page/index.ts +72 -0
  35. package/src/cdp/commands/page/navigate.ts +330 -0
  36. package/src/cdp/commands/page/scroll.ts +107 -0
  37. package/src/cdp/commands/page/shot.ts +208 -0
  38. package/src/cdp/commands/page/type.ts +104 -0
  39. package/src/cdp/commands/tab/index.ts +59 -0
  40. package/src/cdp/commands/tab/list.ts +167 -0
  41. package/src/cdp/commands/tab/network.ts +162 -0
  42. package/src/cdp/commands/tab/open.ts +90 -0
  43. package/src/cdp/commands/tab/reset.ts +160 -0
  44. package/src/cdp/connection.ts +368 -37
  45. package/src/cdp/detect.ts +42 -13
  46. package/src/cdp/dispatch.ts +74 -28
  47. package/src/cdp/har-recorder.ts +648 -115
  48. package/src/cdp/leaf-grammar.ts +99 -0
  49. package/src/cdp/measure/census.ts +205 -0
  50. package/src/cdp/measure/check.ts +318 -0
  51. package/src/cdp/measure/collectors/animation.ts +385 -0
  52. package/src/cdp/measure/collectors/ax.ts +278 -0
  53. package/src/cdp/measure/collectors/focus.ts +1041 -0
  54. package/src/cdp/measure/collectors/forms.ts +905 -0
  55. package/src/cdp/measure/collectors/geometry.ts +1038 -0
  56. package/src/cdp/measure/collectors/hittest.ts +835 -0
  57. package/src/cdp/measure/collectors/layers.ts +697 -0
  58. package/src/cdp/measure/collectors/media.ts +522 -0
  59. package/src/cdp/measure/collectors/pixels.ts +1584 -0
  60. package/src/cdp/measure/collectors/queries.ts +464 -0
  61. package/src/cdp/measure/collectors/scroll.ts +599 -0
  62. package/src/cdp/measure/collectors/states.ts +992 -0
  63. package/src/cdp/measure/collectors/style-provenance.ts +643 -0
  64. package/src/cdp/measure/collectors/styles.ts +355 -0
  65. package/src/cdp/measure/collectors/text.ts +713 -0
  66. package/src/cdp/measure/diff.ts +470 -0
  67. package/src/cdp/measure/explain.ts +498 -0
  68. package/src/cdp/measure/map-ax.ts +633 -0
  69. package/src/cdp/measure/map-focus.ts +188 -0
  70. package/src/cdp/measure/map-layers.ts +177 -0
  71. package/src/cdp/measure/map-scroll.ts +317 -0
  72. package/src/cdp/measure/redaction.ts +51 -0
  73. package/src/cdp/measure/settle.ts +1051 -0
  74. package/src/cdp/measure/snapshot.ts +390 -0
  75. package/src/cdp/measure/sweep.ts +330 -0
  76. package/src/cdp/measure/types.ts +220 -0
  77. package/src/cdp/measure/v2/collectors.ts +19 -0
  78. package/src/cdp/measure/v2/orchestrator.ts +46 -0
  79. package/src/cdp/measure/v2/snap-selection.ts +33 -0
  80. package/src/cdp/measure/v2/target-lock.ts +15 -0
  81. package/src/cdp/measure/v2/target.ts +16 -0
  82. package/src/cdp/motion/jank.ts +433 -0
  83. package/src/cdp/motion/mask.ts +421 -0
  84. package/src/cdp/motion/recorder.ts +764 -0
  85. package/src/cdp/motion/response.ts +356 -0
  86. package/src/cdp/motion/timeline.ts +215 -0
  87. package/src/cdp/record.ts +5 -212
  88. package/src/cdp/recorder-bridge.ts +1478 -105
  89. package/src/cdp/recorder-client.ts +326 -0
  90. package/src/cdp/scope-lock.ts +73 -0
  91. package/src/cdp/screenshot.ts +199 -66
  92. package/src/cdp/targets.ts +21 -1
  93. package/src/cdp/timing.ts +15 -10
  94. package/src/cdp/types.ts +22 -7
  95. package/src/cdp/viewport.ts +21 -0
  96. package/src/cdp.ts +12 -58
  97. package/src/command-help.ts +29 -0
  98. package/src/command-resolution.ts +22 -0
  99. package/src/command-walker.ts +35 -0
  100. package/src/contracts/a11y.ts +33 -0
  101. package/src/contracts/cursor.ts +35 -0
  102. package/src/contracts/index.ts +15 -0
  103. package/src/contracts/motion.ts +19 -0
  104. package/src/contracts/neutral.ts +56 -0
  105. package/src/contracts/primitives.ts +205 -0
  106. package/src/contracts/release.ts +17 -0
  107. package/src/contracts/results.ts +208 -0
  108. package/src/contracts/routes.ts +140 -0
  109. package/src/contracts/snapshot.ts +92 -0
  110. package/src/cursor/index.ts +13 -0
  111. package/src/cursor/service.ts +310 -0
  112. package/src/errors.ts +71 -0
  113. package/src/har-manager.ts +992 -41
  114. package/src/interact.ts +410 -98
  115. package/src/output/artifact-lifecycle.ts +371 -0
  116. package/src/output/artifact.ts +6 -0
  117. package/src/output/exact-raw.ts +114 -0
  118. package/src/output/render.ts +396 -2
  119. package/src/output/structured.ts +211 -0
  120. package/src/registry.ts +208 -0
  121. package/src/session/artifacts.ts +452 -200
  122. package/src/session/commands.ts +1162 -397
  123. package/src/session/coordinator.ts +251 -0
  124. package/src/session/har-filter.ts +57 -0
  125. package/src/session/log-tailer.ts +653 -0
  126. package/src/session-context.ts +279 -51
  127. package/src/vault/bundle.ts +32 -17
  128. package/src/cdp/commands/exec.ts +0 -105
  129. package/src/cdp/commands/har.ts +0 -128
  130. package/src/cdp/commands/tabs.ts +0 -183
  131. package/src/cdp/commands/traffic.ts +0 -121
  132. package/src/cdp/commands/ui.ts +0 -172
  133. package/src/cdp/execute.ts +0 -113
  134. package/src/cdp/har-output.ts +0 -47
  135. package/src/cdp/usage.ts +0 -79
  136. package/src/tab-session.ts +0 -91
@@ -0,0 +1,251 @@
1
+ import * as crypto from 'node:crypto';
2
+ import * as fs from 'node:fs';
3
+ import * as path from 'node:path';
4
+ import { captureError } from '../errors.js';
5
+ import {
6
+ acquirePrivateLock,
7
+ assertUnderCaptureRoot,
8
+ readPrivateFile,
9
+ sameBirth,
10
+ writeJsonPrivate,
11
+ processPidBirthProvider,
12
+ type PidBirth,
13
+ type PidBirthProvider,
14
+ } from './artifacts.js';
15
+ import { getActiveSession, updateSessionState } from '../session-context.js';
16
+
17
+ interface OperationOwner {
18
+ token: string;
19
+ pid: number;
20
+ birth: PidBirth;
21
+ }
22
+
23
+ interface OperationState {
24
+ stopping: boolean;
25
+ tokens: OperationOwner[];
26
+ }
27
+
28
+ export interface SessionOperation {
29
+ token: string;
30
+ release(): Promise<void>;
31
+ }
32
+
33
+ export interface SessionStopAdmission {
34
+ finish(success: boolean): Promise<void>;
35
+ }
36
+
37
+ function statePath(sessionDir: string): string {
38
+ return path.join(assertUnderCaptureRoot(sessionDir), '.operations.json');
39
+ }
40
+
41
+ function lockPath(sessionDir: string): string {
42
+ return path.join(assertUnderCaptureRoot(sessionDir), '.operations.lock');
43
+ }
44
+
45
+ function isBirth(value: unknown): value is PidBirth {
46
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
47
+ const birth = value as Record<string, unknown>;
48
+ return (birth.provider === 'linux-proc-v1' && typeof birth.bootId === 'string' && typeof birth.startTicks === 'string')
49
+ || (birth.provider === 'darwin-kern-proc-v1' && typeof birth.startSec === 'string' && Number.isSafeInteger(birth.startUsec));
50
+ }
51
+
52
+ function isOperationOwner(value: unknown): value is OperationOwner {
53
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
54
+ const owner = value as Record<string, unknown>;
55
+ return typeof owner.token === 'string' && Number.isSafeInteger(owner.pid) && (owner.pid as number) > 0 && isBirth(owner.birth);
56
+ }
57
+
58
+ function readState(sessionDir: string): OperationState {
59
+ try {
60
+ const value = JSON.parse(readPrivateFile(statePath(sessionDir)).toString('utf-8')) as unknown;
61
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('malformed session operation state');
62
+ const record = value as Record<string, unknown>;
63
+ if (typeof record.stopping !== 'boolean' || !Array.isArray(record.tokens) || !record.tokens.every(isOperationOwner)) {
64
+ throw new Error('malformed session operation state');
65
+ }
66
+ return { stopping: record.stopping, tokens: [...record.tokens] };
67
+ } catch (error) {
68
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { stopping: false, tokens: [] };
69
+ throw error;
70
+ }
71
+ }
72
+
73
+ async function withStateLock<T>(sessionDir: string, action: (state: OperationState) => T): Promise<T> {
74
+ const lock = await acquirePrivateLock(lockPath(sessionDir), { acquireTimeoutMs: 30_000, leaseMs: 500 });
75
+ try {
76
+ const state = readState(sessionDir);
77
+ const result = action(state);
78
+ writeJsonPrivate(statePath(sessionDir), state);
79
+ return result;
80
+ } finally {
81
+ lock.release();
82
+ }
83
+ }
84
+
85
+ /** Register before the first session-bound effect. Admissions after stop marking fail. */
86
+ export async function admitSessionOperation(sessionDir: string): Promise<SessionOperation> {
87
+ const token = crypto.randomBytes(18).toString('hex');
88
+ const observed = processPidBirthProvider.read(process.pid);
89
+ if (observed.status !== 'found') throw new Error(`cannot identify session operation owner: ${observed.status === 'unknown' ? observed.reason : 'process absent'}`);
90
+ const owner: OperationOwner = { token, pid: process.pid, birth: observed.identity };
91
+ await withStateLock(sessionDir, state => {
92
+ if (state.stopping) throw captureError('precondition', 'session_stopping', 'This session is stopping; start a new operation after `session stop` finalizes.');
93
+ state.tokens.push(owner);
94
+ });
95
+ let released = false;
96
+ return {
97
+ token,
98
+ async release() {
99
+ if (released) return;
100
+ await withStateLock(sessionDir, state => {
101
+ state.tokens = state.tokens.filter(candidate => candidate.token !== token);
102
+ });
103
+ released = true;
104
+ },
105
+ };
106
+ }
107
+
108
+ /** Mark stopping, reject later admissions, and wait for every earlier token to drain. */
109
+ export async function beginSessionStop(sessionDir: string): Promise<SessionStopAdmission> {
110
+ const stopLock = await acquirePrivateLock(path.join(assertUnderCaptureRoot(sessionDir), '.stop.lock'), {
111
+ acquireTimeoutMs: 120_000,
112
+ leaseMs: 1_000,
113
+ });
114
+ try {
115
+ await withStateLock(sessionDir, state => { state.stopping = true; });
116
+ for (;;) {
117
+ const pending = await withStateLock(sessionDir, state => {
118
+ state.tokens = state.tokens.filter(owner => {
119
+ const observed = processPidBirthProvider.read(owner.pid);
120
+ if (observed.status === 'unknown') throw new Error(`cannot establish session operation owner liveness: ${observed.reason}`);
121
+ return observed.status === 'found' && sameBirth(observed.identity, owner.birth);
122
+ });
123
+ return state.tokens.length;
124
+ });
125
+ if (pending === 0) break;
126
+ await new Promise(resolve => setTimeout(resolve, 10));
127
+ }
128
+ } catch (error) {
129
+ stopLock.release();
130
+ throw error;
131
+ }
132
+ let finished = false;
133
+ return {
134
+ async finish(success: boolean) {
135
+ if (finished) return;
136
+ try {
137
+ if (!success) await withStateLock(sessionDir, state => { state.stopping = false; });
138
+ } finally {
139
+ stopLock.release();
140
+ finished = true;
141
+ }
142
+ },
143
+ };
144
+ }
145
+
146
+ // ---------------------------------------------------------------------------
147
+ // Neutral recorder-handle scan + lifecycle transaction. Owned here so tab/reset
148
+ // and recorder start never import a motion feature (A2). Motion CONSEQUENCES
149
+ // (orphan finalize, markers/meta, viewport restore) stay in recorder.ts.
150
+ // ---------------------------------------------------------------------------
151
+
152
+ export interface RecorderHandleRecord {
153
+ recId: string; pid: number; socketPath: string; targetId: string;
154
+ url: string | null; startedAt: string; state: string; birth: PidBirth; markers: unknown;
155
+ /** The recording's control-socket admission token (64 lowercase hex chars,
156
+ * server-generated by the recorder bridge at boot). Required — a handle
157
+ * without a structurally valid nonce is malformed; there is no
158
+ * unauthenticated or legacy handle shape. */
159
+ nonce: string;
160
+ }
161
+ export type RecorderHandleClassification = 'live' | 'dead' | 'malformed' | 'unknown';
162
+ export interface ScannedRecorderHandle {
163
+ recId: string; recDir: string; classification: RecorderHandleClassification;
164
+ record: RecorderHandleRecord | null; // null iff malformed
165
+ }
166
+ export interface LifecycleSeams {
167
+ pidBirthProvider?: PidBirthProvider; nowNs?: () => bigint;
168
+ sleep?: (ms: number) => Promise<void>; token?: () => string;
169
+ acquireTimeoutMs?: number; leaseMs?: number;
170
+ /** How long a graceful recorder stop waits for the bridge process's own
171
+ * verified exit before escalating to an identity-checked SIGTERM
172
+ * (default ~2000ms) — injectable so tests with never-exiting placeholder
173
+ * children stay fast. */
174
+ stopExitTimeoutMs?: number;
175
+ }
176
+
177
+ /** Structural gate for a recorder control-socket nonce — the exact shape the
178
+ * bridge generates (`crypto.randomBytes(32).toString('hex')`). */
179
+ export const RECORDER_NONCE = /^[0-9a-f]{64}$/;
180
+
181
+ function parseRecorderHandle(raw: unknown): RecorderHandleRecord | null {
182
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
183
+ const r = raw as Record<string, unknown>;
184
+ if (typeof r.recId !== 'string' || !r.recId) return null;
185
+ if (!Number.isSafeInteger(r.pid) || (r.pid as number) <= 0) return null;
186
+ if (typeof r.socketPath !== 'string' || !r.socketPath) return null;
187
+ if (typeof r.targetId !== 'string' || !r.targetId) return null;
188
+ if (!(r.url === null || typeof r.url === 'string')) return null;
189
+ if (typeof r.startedAt !== 'string' || !r.startedAt) return null;
190
+ if (typeof r.state !== 'string' || !r.state) return null;
191
+ if (!isBirth(r.birth)) return null;
192
+ if (typeof r.nonce !== 'string' || !RECORDER_NONCE.test(r.nonce)) return null;
193
+ return { recId: r.recId, pid: r.pid as number, socketPath: r.socketPath, targetId: r.targetId,
194
+ url: r.url as string | null, startedAt: r.startedAt, state: r.state, birth: r.birth as PidBirth, markers: r.markers,
195
+ nonce: r.nonce };
196
+ }
197
+
198
+ /** True iff the recorded process is the same birth-identity still alive. */
199
+ export function recorderProcessAlive(record: Pick<RecorderHandleRecord, 'pid' | 'birth'>, provider: PidBirthProvider = processPidBirthProvider): boolean {
200
+ const now = provider.read(record.pid);
201
+ return now.status === 'found' && sameBirth(now.identity, record.birth);
202
+ }
203
+
204
+ export function scanRecorderHandles(sessionDir: string, seams: LifecycleSeams = {}): ScannedRecorderHandle[] {
205
+ const provider = seams.pidBirthProvider ?? processPidBirthProvider;
206
+ const recsRoot = path.join(assertUnderCaptureRoot(sessionDir), 'motion', 'recs');
207
+ let entries: fs.Dirent[];
208
+ try { entries = fs.readdirSync(recsRoot, { withFileTypes: true }); }
209
+ catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []; throw error; }
210
+ const out: ScannedRecorderHandle[] = [];
211
+ for (const entry of entries) {
212
+ if (!entry.isDirectory()) continue;
213
+ const recDir = path.join(recsRoot, entry.name);
214
+ let raw: unknown;
215
+ try { raw = JSON.parse(readPrivateFile(path.join(recDir, 'recorder.json')).toString('utf-8')); }
216
+ catch (error) {
217
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue; // no handle
218
+ out.push({ recId: entry.name, recDir, classification: 'malformed', record: null }); continue;
219
+ }
220
+ const record = parseRecorderHandle(raw);
221
+ if (!record) { out.push({ recId: entry.name, recDir, classification: 'malformed', record: null }); continue; }
222
+ const read = provider.read(record.pid);
223
+ const classification: RecorderHandleClassification =
224
+ read.status === 'unknown' ? 'unknown'
225
+ : (read.status === 'found' && sameBirth(read.identity, record.birth) && record.state === 'recording') ? 'live'
226
+ : 'dead';
227
+ out.push({ recId: entry.name, recDir, classification, record });
228
+ }
229
+ return out;
230
+ }
231
+
232
+ /** Reset-time pointer hygiene: reset does NOT remove dead handles — it only
233
+ * clears a session pointer that no longer names a LIVE recorder. Dead handles
234
+ * are left intact so motion's own reapDeadHandles / session-stop teardown
235
+ * orphan-finalizes them on the next start/stop, preserving their partial frames
236
+ * as meta.json in the bundle. */
237
+ export async function clearDanglingRecorderPointer(sessionDir: string, scan: ScannedRecorderHandle[]): Promise<void> {
238
+ const active = getActiveSession();
239
+ if (active && active.dir === sessionDir && active.activeRecId
240
+ && !scan.some(h => h.recId === active.activeRecId && h.classification === 'live')) {
241
+ await updateSessionState(sessionDir, { activeRecId: null });
242
+ }
243
+ }
244
+
245
+ export async function withSessionLifecycle<T>(sessionDir: string, action: () => Promise<T>, seams: LifecycleSeams = {}): Promise<T> {
246
+ const lock = await acquirePrivateLock(path.join(assertUnderCaptureRoot(sessionDir), '.lifecycle.lock'), {
247
+ acquireTimeoutMs: seams.acquireTimeoutMs ?? 30_000, leaseMs: seams.leaseMs ?? 30_000,
248
+ pidBirthProvider: seams.pidBirthProvider, nowNs: seams.nowNs, sleep: seams.sleep, token: seams.token,
249
+ });
250
+ try { return await action(); } finally { lock.release(); }
251
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * `session har --filter-status` grammar (M9) — one pure, complete-token
3
+ * parser. Exactly three documented forms are valid:
4
+ *
5
+ * exact code 100..599 e.g. `404`
6
+ * class prefix 1..5 (one digit) e.g. `4` — every 4xx status
7
+ * exact range lo-hi, both 100..599, lo<=hi e.g. `400-499`
8
+ *
9
+ * Every other token — empty, whitespace-bearing, two-digit prefixes (`40`),
10
+ * out-of-range codes (`99`, `600`), unordered/partial/extra-hyphen ranges —
11
+ * throws a typed `invalid_filter` CaptureError. There is no match-all
12
+ * fallback, and the parser touches no session or HAR state: the caller must
13
+ * parse BEFORE any session/HAR lookup, so an invalid filter wins over a
14
+ * missing or corrupt artifact.
15
+ */
16
+ import { captureError, type CaptureError } from '../errors.js';
17
+
18
+ const EXACT = /^[1-5]\d{2}$/;
19
+ const CLASS_PREFIX = /^[1-5]$/;
20
+ const RANGE = /^([1-5]\d{2})-([1-5]\d{2})$/;
21
+
22
+ /** Decides whether one recorded response status matches the parsed filter. */
23
+ export type StatusPredicate = (status: number) => boolean;
24
+
25
+ function invalidFilter(spec: string): CaptureError {
26
+ const received = spec === '' ? '(empty)' : spec;
27
+ return captureError(
28
+ 'invocation',
29
+ 'invalid_filter',
30
+ `received: --filter-status ${received}; expected an exact status (100-599), a one-digit class prefix (1-5), or an ordered range like 400-499.`,
31
+ );
32
+ }
33
+
34
+ /**
35
+ * Parses one `--filter-status` token into a status predicate, or throws a
36
+ * typed `invalid_filter` CaptureError for any token outside the documented
37
+ * grammar. Pure: no session, filesystem, or HAR access.
38
+ */
39
+ export function parseStatusFilter(spec: string): StatusPredicate {
40
+ if (EXACT.test(spec)) {
41
+ const code = Number(spec);
42
+ return (status) => status === code;
43
+ }
44
+ if (CLASS_PREFIX.test(spec)) {
45
+ const lo = Number(spec) * 100;
46
+ const hi = lo + 99;
47
+ return (status) => status >= lo && status <= hi;
48
+ }
49
+ const range = RANGE.exec(spec);
50
+ if (range) {
51
+ const lo = Number(range[1]);
52
+ const hi = Number(range[2]);
53
+ if (lo > hi) throw invalidFilter(spec);
54
+ return (status) => status >= lo && status <= hi;
55
+ }
56
+ throw invalidFilter(spec);
57
+ }