@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.
- package/bin/capture +29841 -8539
- package/package.json +6 -3
- package/src/capture.ts +86 -173
- package/src/cdp/a11y.ts +113 -14
- package/src/cdp/args.ts +300 -205
- package/src/cdp/bridge/protocol.ts +93 -6
- package/src/cdp/bridge/server.ts +118 -53
- package/src/cdp/bridge/spawn.ts +53 -6
- package/src/cdp/client.ts +13 -3
- package/src/cdp/commands/bridge-serve.ts +20 -14
- package/src/cdp/commands/cdp.ts +259 -90
- package/src/cdp/commands/gate-guard.ts +43 -0
- package/src/cdp/commands/lib.ts +339 -213
- package/src/cdp/commands/measure/census.ts +76 -20
- package/src/cdp/commands/measure/check.ts +100 -26
- package/src/cdp/commands/measure/diff.ts +109 -21
- package/src/cdp/commands/measure/explain.ts +121 -19
- package/src/cdp/commands/measure/index.ts +31 -39
- package/src/cdp/commands/measure/map-ax.ts +52 -0
- package/src/cdp/commands/measure/map-focus.ts +41 -13
- package/src/cdp/commands/measure/map-layers.ts +49 -15
- package/src/cdp/commands/measure/map-scroll.ts +85 -15
- package/src/cdp/commands/measure/snap.ts +405 -25
- package/src/cdp/commands/measure/sweep.ts +217 -20
- package/src/cdp/commands/motion/index.ts +18 -17
- package/src/cdp/commands/motion/jank.ts +119 -9
- package/src/cdp/commands/motion/mask.ts +81 -12
- package/src/cdp/commands/motion/rec.ts +520 -19
- package/src/cdp/commands/motion/response.ts +65 -15
- package/src/cdp/commands/motion/timeline.ts +139 -14
- package/src/cdp/commands/page/click.ts +194 -0
- package/src/cdp/commands/page/elements.ts +154 -0
- package/src/cdp/commands/page/exec.ts +317 -0
- package/src/cdp/commands/page/index.ts +72 -0
- package/src/cdp/commands/page/navigate.ts +330 -0
- package/src/cdp/commands/page/scroll.ts +107 -0
- package/src/cdp/commands/page/shot.ts +208 -0
- package/src/cdp/commands/page/type.ts +104 -0
- package/src/cdp/commands/tab/index.ts +59 -0
- package/src/cdp/commands/tab/list.ts +167 -0
- package/src/cdp/commands/tab/network.ts +162 -0
- package/src/cdp/commands/tab/open.ts +90 -0
- package/src/cdp/commands/tab/reset.ts +160 -0
- package/src/cdp/connection.ts +368 -37
- package/src/cdp/detect.ts +42 -13
- package/src/cdp/dispatch.ts +74 -28
- package/src/cdp/har-recorder.ts +648 -115
- package/src/cdp/leaf-grammar.ts +99 -0
- package/src/cdp/measure/census.ts +205 -0
- package/src/cdp/measure/check.ts +318 -0
- package/src/cdp/measure/collectors/animation.ts +385 -0
- package/src/cdp/measure/collectors/ax.ts +278 -0
- package/src/cdp/measure/collectors/focus.ts +1041 -0
- package/src/cdp/measure/collectors/forms.ts +905 -0
- package/src/cdp/measure/collectors/geometry.ts +1038 -0
- package/src/cdp/measure/collectors/hittest.ts +835 -0
- package/src/cdp/measure/collectors/layers.ts +697 -0
- package/src/cdp/measure/collectors/media.ts +522 -0
- package/src/cdp/measure/collectors/pixels.ts +1584 -0
- package/src/cdp/measure/collectors/queries.ts +464 -0
- package/src/cdp/measure/collectors/scroll.ts +599 -0
- package/src/cdp/measure/collectors/states.ts +992 -0
- package/src/cdp/measure/collectors/style-provenance.ts +643 -0
- package/src/cdp/measure/collectors/styles.ts +355 -0
- package/src/cdp/measure/collectors/text.ts +713 -0
- package/src/cdp/measure/diff.ts +470 -0
- package/src/cdp/measure/explain.ts +498 -0
- package/src/cdp/measure/map-ax.ts +633 -0
- package/src/cdp/measure/map-focus.ts +188 -0
- package/src/cdp/measure/map-layers.ts +177 -0
- package/src/cdp/measure/map-scroll.ts +317 -0
- package/src/cdp/measure/redaction.ts +51 -0
- package/src/cdp/measure/settle.ts +1051 -0
- package/src/cdp/measure/snapshot.ts +390 -0
- package/src/cdp/measure/sweep.ts +330 -0
- package/src/cdp/measure/types.ts +220 -0
- package/src/cdp/measure/v2/collectors.ts +19 -0
- package/src/cdp/measure/v2/orchestrator.ts +46 -0
- package/src/cdp/measure/v2/snap-selection.ts +33 -0
- package/src/cdp/measure/v2/target-lock.ts +15 -0
- package/src/cdp/measure/v2/target.ts +16 -0
- package/src/cdp/motion/jank.ts +433 -0
- package/src/cdp/motion/mask.ts +421 -0
- package/src/cdp/motion/recorder.ts +764 -0
- package/src/cdp/motion/response.ts +356 -0
- package/src/cdp/motion/timeline.ts +215 -0
- package/src/cdp/record.ts +5 -212
- package/src/cdp/recorder-bridge.ts +1478 -105
- package/src/cdp/recorder-client.ts +326 -0
- package/src/cdp/scope-lock.ts +73 -0
- package/src/cdp/screenshot.ts +199 -66
- package/src/cdp/targets.ts +21 -1
- package/src/cdp/timing.ts +15 -10
- package/src/cdp/types.ts +22 -7
- package/src/cdp/viewport.ts +21 -0
- package/src/cdp.ts +12 -58
- package/src/command-help.ts +29 -0
- package/src/command-resolution.ts +22 -0
- package/src/command-walker.ts +35 -0
- package/src/contracts/a11y.ts +33 -0
- package/src/contracts/cursor.ts +35 -0
- package/src/contracts/index.ts +15 -0
- package/src/contracts/motion.ts +19 -0
- package/src/contracts/neutral.ts +56 -0
- package/src/contracts/primitives.ts +205 -0
- package/src/contracts/release.ts +17 -0
- package/src/contracts/results.ts +208 -0
- package/src/contracts/routes.ts +140 -0
- package/src/contracts/snapshot.ts +92 -0
- package/src/cursor/index.ts +13 -0
- package/src/cursor/service.ts +310 -0
- package/src/errors.ts +71 -0
- package/src/har-manager.ts +992 -41
- package/src/interact.ts +410 -98
- package/src/output/artifact-lifecycle.ts +371 -0
- package/src/output/artifact.ts +6 -0
- package/src/output/exact-raw.ts +114 -0
- package/src/output/render.ts +396 -2
- package/src/output/structured.ts +211 -0
- package/src/registry.ts +208 -0
- package/src/session/artifacts.ts +452 -200
- package/src/session/commands.ts +1162 -397
- package/src/session/coordinator.ts +251 -0
- package/src/session/har-filter.ts +57 -0
- package/src/session/log-tailer.ts +653 -0
- package/src/session-context.ts +279 -51
- package/src/vault/bundle.ts +32 -17
- package/src/cdp/commands/exec.ts +0 -105
- package/src/cdp/commands/har.ts +0 -128
- package/src/cdp/commands/tabs.ts +0 -183
- package/src/cdp/commands/traffic.ts +0 -121
- package/src/cdp/commands/ui.ts +0 -172
- package/src/cdp/execute.ts +0 -113
- package/src/cdp/har-output.ts +0 -47
- package/src/cdp/usage.ts +0 -79
- 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
|
+
}
|