@crouton-kit/capture 1.4.2 → 1.4.11
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/README.md +187 -0
- package/bin/capture +31278 -7312
- package/package.json +3 -1
- package/src/capture.ts +21 -18
- package/src/cdp/args.ts +269 -49
- package/src/cdp/bridge/server.ts +6 -5
- package/src/cdp/bridge/spawn.ts +41 -97
- package/src/cdp/commands/bridge-serve.ts +11 -21
- package/src/cdp/commands/cdp.ts +44 -22
- package/src/cdp/commands/heap/census.ts +61 -0
- package/src/cdp/commands/heap/common.ts +77 -0
- package/src/cdp/commands/heap/diff.ts +92 -0
- package/src/cdp/commands/heap/index.ts +38 -0
- package/src/cdp/commands/heap/objects.ts +42 -0
- package/src/cdp/commands/heap/retainers.ts +52 -0
- package/src/cdp/commands/heap/snapshot.ts +94 -0
- package/src/cdp/commands/lib.ts +9 -17
- package/src/cdp/commands/lighthouse.ts +414 -0
- package/src/cdp/commands/measure/census.ts +2 -1
- package/src/cdp/commands/measure/check.ts +70 -16
- package/src/cdp/commands/measure/diff.ts +1 -2
- package/src/cdp/commands/measure/explain.ts +44 -7
- package/src/cdp/commands/measure/index.ts +24 -32
- package/src/cdp/commands/measure/map-ax.ts +2 -1
- package/src/cdp/commands/measure/map-focus.ts +2 -1
- package/src/cdp/commands/measure/map-layers.ts +5 -3
- package/src/cdp/commands/measure/map-scroll.ts +2 -1
- package/src/cdp/commands/measure/snap.ts +5 -4
- package/src/cdp/commands/measure/sweep.ts +16 -11
- package/src/cdp/commands/measure/text.ts +147 -0
- package/src/cdp/commands/motion/index.ts +10 -18
- package/src/cdp/commands/motion/jank.ts +13 -22
- package/src/cdp/commands/motion/mask.ts +10 -9
- package/src/cdp/commands/motion/rec.ts +228 -26
- package/src/cdp/commands/motion/timeline.ts +1 -1
- package/src/cdp/commands/page/click.ts +15 -5
- package/src/cdp/commands/page/elements.ts +43 -11
- package/src/cdp/commands/page/exec.ts +53 -18
- package/src/cdp/commands/page/index.ts +20 -20
- package/src/cdp/commands/page/inspect.ts +338 -0
- package/src/cdp/commands/page/navigate.ts +83 -38
- package/src/cdp/commands/page/repeat.ts +176 -0
- package/src/cdp/commands/page/scroll.ts +10 -5
- package/src/cdp/commands/page/shot.ts +66 -31
- package/src/cdp/commands/page/type.ts +6 -2
- package/src/cdp/commands/perf/index.ts +32 -0
- package/src/cdp/commands/perf/insights.ts +214 -0
- package/src/cdp/commands/perf/trace.ts +235 -0
- package/src/cdp/commands/perf/vitals.ts +76 -0
- package/src/cdp/commands/session/collectors.ts +54 -0
- package/src/cdp/commands/stub.ts +10 -0
- package/src/cdp/commands/tab/close.ts +2 -2
- package/src/cdp/commands/tab/index.ts +16 -23
- package/src/cdp/commands/tab/launch.ts +1 -1
- package/src/cdp/commands/tab/mock/index.ts +24 -0
- package/src/cdp/commands/tab/mock/lifecycle.ts +88 -0
- package/src/cdp/commands/tab/mock/start.ts +65 -0
- package/src/cdp/commands/tab/mock/stop.ts +39 -0
- package/src/cdp/commands/tab/network.ts +22 -13
- package/src/cdp/commands/tab/reset.ts +11 -12
- package/src/cdp/connection.ts +19 -39
- package/src/cdp/detect.ts +27 -7
- package/src/cdp/dispatch.ts +25 -4
- package/src/cdp/domains.ts +2 -2
- package/src/cdp/har-recorder.ts +0 -1
- package/src/cdp/heap-snapshot.ts +647 -0
- package/src/cdp/host/client.ts +22 -0
- package/src/cdp/host/collector.ts +74 -0
- package/src/cdp/host/collectors/heap.ts +79 -0
- package/src/cdp/host/collectors/intercept.ts +247 -0
- package/src/cdp/{recorder-bridge.ts → host/collectors/motion.ts} +388 -473
- package/src/cdp/host/collectors/trace.ts +108 -0
- package/src/cdp/host/core.ts +217 -0
- package/src/cdp/host/handle.ts +47 -0
- package/src/cdp/host/held-client.ts +91 -0
- package/src/cdp/host/kinds.ts +97 -0
- package/src/cdp/host/lifecycle.ts +123 -0
- package/src/cdp/host/server.ts +88 -0
- package/src/cdp/measure/check.ts +181 -16
- package/src/cdp/measure/collectors/hittest.ts +16 -10
- package/src/cdp/measure/collectors/scroll.ts +15 -7
- package/src/cdp/measure/collectors/style-provenance.ts +11 -0
- package/src/cdp/measure/collectors/styles.ts +4 -4
- package/src/cdp/measure/explain.ts +14 -2
- package/src/cdp/measure/map-layers.ts +52 -8
- package/src/cdp/measure/snapshot.ts +5 -3
- package/src/cdp/measure/sweep.ts +29 -6
- package/src/cdp/measure/text.ts +227 -0
- package/src/cdp/motion/jank.ts +29 -83
- package/src/cdp/motion/mask.ts +18 -9
- package/src/cdp/motion/recorder.ts +86 -731
- package/src/cdp/scope-lock.ts +9 -9
- package/src/cdp/screenshot.ts +51 -21
- package/src/cdp/timing.ts +5 -5
- package/src/cdp/trace-analysis.ts +320 -0
- package/src/cdp/types.ts +31 -1
- package/src/cdp.ts +4 -6
- package/src/har-manager.ts +2 -4
- package/src/interact.ts +50 -23
- package/src/output/artifact.ts +49 -5
- package/src/output/render.ts +60 -9
- package/src/output/selection.ts +6 -0
- package/src/session/artifacts.ts +122 -12
- package/src/session/commands.ts +348 -113
- package/src/session/coordinator.ts +0 -93
- package/src/session/har-redact.ts +29 -23
- package/src/session-context.ts +13 -9
- package/src/cdp/bridge/protocol.ts +0 -224
- package/src/cdp/recorder-client.ts +0 -326
- package/src/cdp/tab-lock.ts +0 -132
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { CDPClient } from '../client.js';
|
|
2
|
+
import type { PidBirth } from '../../session/artifacts.js';
|
|
3
|
+
|
|
4
|
+
export type CdpClaim = 'tracing' | 'fetch-interception' | 'heap-sampling' | 'heap-snapshot' | 'screencast';
|
|
5
|
+
export type CollectorKind = 'motion' | 'trace' | 'heap' | 'intercept';
|
|
6
|
+
export type Completion = 'complete' | 'partial' | 'orphaned';
|
|
7
|
+
export type ResolvedCompletion = Completion | 'truncated';
|
|
8
|
+
|
|
9
|
+
export interface ChunkWriter {
|
|
10
|
+
write(chunk: Buffer | string): void;
|
|
11
|
+
commit(): number;
|
|
12
|
+
discard(): void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface CollectorContext {
|
|
16
|
+
readonly client: CDPClient;
|
|
17
|
+
readonly dir: string;
|
|
18
|
+
readonly id: string;
|
|
19
|
+
readonly targetId: string;
|
|
20
|
+
readonly config: unknown;
|
|
21
|
+
appendRecord(file: string, record: unknown): void;
|
|
22
|
+
openChunkFile(file: string): ChunkWriter;
|
|
23
|
+
noteLoss(reason: string, detail?: Record<string, unknown>): void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface DispatchNotice { method: string; params: Record<string, unknown>; annotation?: string; atPerformanceNowMs: number; }
|
|
27
|
+
export interface DispatchOutcome { ok: boolean; error?: string; atPerformanceNowMs: number; }
|
|
28
|
+
export interface DrainCause { trigger: 'explicit' | 'session-stop' | 'transport-lost'; clientUsable: boolean; }
|
|
29
|
+
export interface DrainOutcome<Summary = unknown> { summary: Summary; files: readonly { name: string; bytes: number }[]; }
|
|
30
|
+
|
|
31
|
+
/** A start failure that leaves a collector artifact tree as the recovery record. */
|
|
32
|
+
export class RetainedCollectorStartFailure extends Error {
|
|
33
|
+
constructor(message: string, readonly startError: unknown, readonly cleanupError: unknown) {
|
|
34
|
+
super(message);
|
|
35
|
+
this.name = 'RetainedCollectorStartFailure';
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface Collector<Summary = unknown> {
|
|
40
|
+
readonly kind: CollectorKind;
|
|
41
|
+
readonly claims: readonly CdpClaim[];
|
|
42
|
+
start(ctx: CollectorContext): Promise<void>;
|
|
43
|
+
closeAdmission(): void;
|
|
44
|
+
drain(cause: DrainCause): Promise<DrainOutcome<Summary>>;
|
|
45
|
+
onDispatch?(notice: DispatchNotice): ((outcome: DispatchOutcome) => void) | void;
|
|
46
|
+
control?(message: unknown): Promise<unknown>;
|
|
47
|
+
abandon(): void;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface ClaimReservation {
|
|
51
|
+
token: string;
|
|
52
|
+
claims: readonly CdpClaim[];
|
|
53
|
+
/** Blocks every collector admission on this host while a destructive tab operation runs. */
|
|
54
|
+
exclusive?: boolean;
|
|
55
|
+
holderLabel: string;
|
|
56
|
+
pid: number;
|
|
57
|
+
birth: PidBirth;
|
|
58
|
+
reservedAt: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export type TeardownOutcome =
|
|
62
|
+
| { status: 'drained'; id: string; kind: CollectorKind; completion: Completion; dir: string }
|
|
63
|
+
| { status: 'reaped'; id: string; kind: CollectorKind; dir: string }
|
|
64
|
+
| { status: 'terminal'; id: string; kind: CollectorKind; dir: string; error: string };
|
|
65
|
+
|
|
66
|
+
export interface CollectorKindEntry {
|
|
67
|
+
readonly kind: CollectorKind;
|
|
68
|
+
readonly idSegments: readonly string[];
|
|
69
|
+
readonly idPrefix: string;
|
|
70
|
+
readonly label: string;
|
|
71
|
+
parseConfig(raw: unknown): unknown;
|
|
72
|
+
create(config: unknown): Collector;
|
|
73
|
+
reconstruct(dir: string): DrainOutcome;
|
|
74
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import { type Collector, type CollectorContext, type DrainCause, type DrainOutcome, type ChunkWriter } from '../collector.js';
|
|
4
|
+
|
|
5
|
+
interface HeapSnapshotChunk { chunk?: unknown; }
|
|
6
|
+
|
|
7
|
+
/** Streams Chrome's V8 snapshot artifact; analysis belongs to the heap command readers. */
|
|
8
|
+
export class HeapCollector implements Collector<{ chunks: number; bytes: number; garbageCollected: boolean }> {
|
|
9
|
+
readonly kind = 'heap' as const;
|
|
10
|
+
readonly claims = ['heap-snapshot'] as const;
|
|
11
|
+
private ctx?: CollectorContext;
|
|
12
|
+
private writer?: ChunkWriter;
|
|
13
|
+
private accepting = false;
|
|
14
|
+
private chunks = 0;
|
|
15
|
+
private garbageCollected = false;
|
|
16
|
+
private enabled = false;
|
|
17
|
+
private readonly onChunk = (params: unknown): void => {
|
|
18
|
+
if (!this.accepting) return;
|
|
19
|
+
const chunk = (params as HeapSnapshotChunk | undefined)?.chunk;
|
|
20
|
+
if (typeof chunk !== 'string') {
|
|
21
|
+
this.ctx?.noteLoss('invalid_heap_snapshot_chunk');
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
this.writer!.write(chunk);
|
|
25
|
+
this.chunks += 1;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
async start(ctx: CollectorContext): Promise<void> {
|
|
29
|
+
this.ctx = ctx;
|
|
30
|
+
this.writer = ctx.openChunkFile('snapshot.heapsnapshot');
|
|
31
|
+
this.accepting = true;
|
|
32
|
+
ctx.client.on('HeapProfiler.addHeapSnapshotChunk', this.onChunk);
|
|
33
|
+
try {
|
|
34
|
+
await ctx.client.send('HeapProfiler.enable');
|
|
35
|
+
this.enabled = true;
|
|
36
|
+
await ctx.client.send('HeapProfiler.collectGarbage');
|
|
37
|
+
this.garbageCollected = true;
|
|
38
|
+
await ctx.client.send('HeapProfiler.takeHeapSnapshot', { reportProgress: false });
|
|
39
|
+
} catch (error) {
|
|
40
|
+
this.accepting = false;
|
|
41
|
+
ctx.client.off('HeapProfiler.addHeapSnapshotChunk', this.onChunk);
|
|
42
|
+
this.writer.discard();
|
|
43
|
+
if (this.enabled) {
|
|
44
|
+
try { await ctx.client.send('HeapProfiler.disable'); } catch {}
|
|
45
|
+
}
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
closeAdmission(): void {
|
|
51
|
+
this.accepting = false;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async drain(cause: DrainCause): Promise<DrainOutcome<{ chunks: number; bytes: number; garbageCollected: boolean }>> {
|
|
55
|
+
this.closeAdmission();
|
|
56
|
+
this.ctx!.client.off('HeapProfiler.addHeapSnapshotChunk', this.onChunk);
|
|
57
|
+
if (!cause.clientUsable) {
|
|
58
|
+
this.writer!.discard();
|
|
59
|
+
this.ctx!.noteLoss('transport_lost');
|
|
60
|
+
return { summary: { chunks: this.chunks, bytes: 0, garbageCollected: this.garbageCollected }, files: [] };
|
|
61
|
+
}
|
|
62
|
+
if (this.enabled) await this.ctx!.client.send('HeapProfiler.disable');
|
|
63
|
+
const bytes = this.writer!.commit();
|
|
64
|
+
return { summary: { chunks: this.chunks, bytes, garbageCollected: this.garbageCollected }, files: [{ name: 'snapshot.heapsnapshot', bytes }] };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
abandon(): void {
|
|
68
|
+
this.accepting = false;
|
|
69
|
+
this.ctx?.client.off('HeapProfiler.addHeapSnapshotChunk', this.onChunk);
|
|
70
|
+
try { this.writer?.discard(); } catch {}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function reconstructHeapSnapshot(dir: string): DrainOutcome<{ bytes: number }> {
|
|
75
|
+
const snapshot = path.join(dir, 'snapshot.heapsnapshot');
|
|
76
|
+
if (!fs.existsSync(snapshot)) return { summary: { bytes: 0 }, files: [] };
|
|
77
|
+
const bytes = fs.statSync(snapshot).size;
|
|
78
|
+
return { summary: { bytes }, files: [{ name: 'snapshot.heapsnapshot', bytes }] };
|
|
79
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import * as path from 'node:path';
|
|
2
|
+
import { assertUnderCaptureRoot, readPrivateFile, writePrivateFile } from '../../../session/artifacts.js';
|
|
3
|
+
import type { Collector, CollectorContext, DrainCause, DrainOutcome } from '../collector.js';
|
|
4
|
+
|
|
5
|
+
const MAX_BODY_BYTES = 10 * 1024 * 1024;
|
|
6
|
+
const RESOURCE_TYPES = new Set(['Document', 'Stylesheet', 'Image', 'Media', 'Font', 'Script', 'TextTrack', 'XHR', 'Fetch', 'Prefetch', 'EventSource', 'WebSocket', 'Manifest', 'SignedExchange', 'Ping', 'CSPViolationReport', 'Preflight', 'Other']);
|
|
7
|
+
const FAIL_REASONS = new Set(['Failed', 'Aborted', 'TimedOut', 'AccessDenied', 'ConnectionClosed', 'ConnectionReset', 'ConnectionRefused', 'ConnectionAborted', 'ConnectionFailed', 'NameNotResolved', 'InternetDisconnected', 'AddressUnreachable', 'BlockedByClient', 'BlockedByResponse']);
|
|
8
|
+
|
|
9
|
+
type Headers = Record<string, string>;
|
|
10
|
+
type Action =
|
|
11
|
+
| { kind: 'fulfill'; status: number; headers: Headers; body: string }
|
|
12
|
+
| { kind: 'fail'; reason: string }
|
|
13
|
+
| { kind: 'modify'; url?: string; method?: string; headers?: Headers; postData?: string }
|
|
14
|
+
| { kind: 'passthrough' };
|
|
15
|
+
|
|
16
|
+
interface Rule { url: string; methods?: readonly string[]; resourceTypes?: readonly string[]; action: Action; }
|
|
17
|
+
export interface MockCollectorConfig { rulesPath: string; rules: readonly Rule[]; source: Buffer; }
|
|
18
|
+
|
|
19
|
+
function record(value: unknown, label: string): Record<string, unknown> {
|
|
20
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${label} must be an object`);
|
|
21
|
+
return value as Record<string, unknown>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function stringRecord(value: unknown, label: string): Headers {
|
|
25
|
+
const source = record(value, label);
|
|
26
|
+
const out: Headers = {};
|
|
27
|
+
for (const [key, item] of Object.entries(source)) {
|
|
28
|
+
if (!key || typeof item !== 'string') throw new Error(`${label}.${key || '(empty)'} must be a string`);
|
|
29
|
+
out[key] = item;
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function body(value: Record<string, unknown>, label: string): string {
|
|
35
|
+
const plain = value.body;
|
|
36
|
+
const encoded = value.bodyBase64;
|
|
37
|
+
if (plain !== undefined && encoded !== undefined) throw new Error(`${label} accepts body or bodyBase64, not both`);
|
|
38
|
+
if (plain !== undefined) {
|
|
39
|
+
if (typeof plain !== 'string') throw new Error(`${label}.body must be a string`);
|
|
40
|
+
if (Buffer.byteLength(plain) > MAX_BODY_BYTES) throw new Error(`${label}.body exceeds ${MAX_BODY_BYTES} bytes`);
|
|
41
|
+
return Buffer.from(plain).toString('base64');
|
|
42
|
+
}
|
|
43
|
+
if (encoded !== undefined) {
|
|
44
|
+
if (typeof encoded !== 'string' || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(encoded)) throw new Error(`${label}.bodyBase64 must be valid base64`);
|
|
45
|
+
const decoded = Buffer.from(encoded, 'base64');
|
|
46
|
+
if (decoded.length > MAX_BODY_BYTES) throw new Error(`${label}.bodyBase64 exceeds ${MAX_BODY_BYTES} bytes`);
|
|
47
|
+
return encoded;
|
|
48
|
+
}
|
|
49
|
+
return '';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function parseAction(value: Record<string, unknown>, index: number): Action {
|
|
53
|
+
const names = ['fulfill', 'fail', 'modify', 'passthrough'].filter(name => value[name] !== undefined);
|
|
54
|
+
if (names.length !== 1) throw new Error(`rules[${index}] must contain exactly one action: fulfill, fail, modify, or passthrough`);
|
|
55
|
+
const action = names[0];
|
|
56
|
+
const config = record(value[action], `rules[${index}].${action}`);
|
|
57
|
+
if (action === 'fulfill') {
|
|
58
|
+
const allowed = new Set(['status', 'headers', 'body', 'bodyBase64']);
|
|
59
|
+
if (Object.keys(config).some(key => !allowed.has(key))) throw new Error(`rules[${index}].fulfill has an unsupported field`);
|
|
60
|
+
const status = config.status ?? 200;
|
|
61
|
+
if (!Number.isInteger(status) || (status as number) < 100 || (status as number) > 599) throw new Error(`rules[${index}].fulfill.status must be an HTTP status from 100 through 599`);
|
|
62
|
+
return { kind: 'fulfill', status: status as number, headers: config.headers === undefined ? {} : stringRecord(config.headers, `rules[${index}].fulfill.headers`), body: body(config, `rules[${index}].fulfill`) };
|
|
63
|
+
}
|
|
64
|
+
if (action === 'fail') {
|
|
65
|
+
if (Object.keys(config).some(key => key !== 'reason')) throw new Error(`rules[${index}].fail has an unsupported field`);
|
|
66
|
+
if (typeof config.reason !== 'string' || !FAIL_REASONS.has(config.reason)) throw new Error(`rules[${index}].fail.reason must be a documented CDP network error reason`);
|
|
67
|
+
return { kind: 'fail', reason: config.reason };
|
|
68
|
+
}
|
|
69
|
+
if (action === 'modify') {
|
|
70
|
+
const allowed = new Set(['url', 'method', 'headers', 'postData']);
|
|
71
|
+
if (Object.keys(config).some(key => !allowed.has(key))) throw new Error(`rules[${index}].modify has an unsupported field`);
|
|
72
|
+
if (config.url !== undefined) validateUrlGlob(config.url, `rules[${index}].modify.url`);
|
|
73
|
+
if (config.method !== undefined && (typeof config.method !== 'string' || !/^[A-Z]+$/.test(config.method))) throw new Error(`rules[${index}].modify.method must be an uppercase HTTP method`);
|
|
74
|
+
if (config.postData !== undefined && typeof config.postData !== 'string') throw new Error(`rules[${index}].modify.postData must be a string`);
|
|
75
|
+
if (config.postData !== undefined && Buffer.byteLength(config.postData as string) > MAX_BODY_BYTES) throw new Error(`rules[${index}].modify.postData exceeds ${MAX_BODY_BYTES} bytes`);
|
|
76
|
+
if (config.url === undefined && config.method === undefined && config.headers === undefined && config.postData === undefined) throw new Error(`rules[${index}].modify needs url, method, headers, or postData`);
|
|
77
|
+
return { kind: 'modify', ...(config.url === undefined ? {} : { url: config.url as string }), ...(config.method === undefined ? {} : { method: config.method as string }), ...(config.headers === undefined ? {} : { headers: stringRecord(config.headers, `rules[${index}].modify.headers`) }), ...(config.postData === undefined ? {} : { postData: Buffer.from(config.postData as string).toString('base64') }) };
|
|
78
|
+
}
|
|
79
|
+
if (Object.keys(config).length !== 0) throw new Error(`rules[${index}].passthrough must be an empty object`);
|
|
80
|
+
return { kind: 'passthrough' };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function validateUrlGlob(value: unknown, label: string): asserts value is string {
|
|
84
|
+
if (typeof value !== 'string' || !value) throw new Error(`${label} must be a non-empty full URL glob`);
|
|
85
|
+
try { new URL(value.replaceAll('*', 'mock')); } catch { throw new Error(`${label} must be a parseable full URL glob`); }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function parseSet(value: unknown, label: string, allowed?: ReadonlySet<string>): readonly string[] | undefined {
|
|
89
|
+
if (value === undefined) return undefined;
|
|
90
|
+
if (!Array.isArray(value) || value.length === 0 || value.some(item => typeof item !== 'string' || !item)) throw new Error(`${label} must be a non-empty string array`);
|
|
91
|
+
if (new Set(value).size !== value.length) throw new Error(`${label} must not contain duplicates`);
|
|
92
|
+
if (allowed && value.some(item => !allowed.has(item))) throw new Error(`${label} contains an unsupported value`);
|
|
93
|
+
return value as string[];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function parseMockRuleDocument(source: Buffer): readonly Rule[] {
|
|
97
|
+
let parsed: unknown;
|
|
98
|
+
try { parsed = JSON.parse(source.toString('utf8')); } catch { throw new Error('rules must be valid JSON'); }
|
|
99
|
+
const document = record(parsed, 'rule document');
|
|
100
|
+
if (Object.keys(document).some(key => key !== 'rules') || !Array.isArray(document.rules)) throw new Error('rule document must be an object with a rules array');
|
|
101
|
+
return document.rules.map((item, index) => {
|
|
102
|
+
const rule = record(item, `rules[${index}]`);
|
|
103
|
+
const allowed = new Set(['url', 'methods', 'resourceTypes', 'fulfill', 'fail', 'modify', 'passthrough']);
|
|
104
|
+
if (Object.keys(rule).some(key => !allowed.has(key))) throw new Error(`rules[${index}] has an unsupported field`);
|
|
105
|
+
validateUrlGlob(rule.url, `rules[${index}].url`);
|
|
106
|
+
const methods = parseSet(rule.methods, `rules[${index}].methods`);
|
|
107
|
+
if (methods?.some(method => !/^[A-Z]+$/.test(method))) throw new Error(`rules[${index}].methods must contain uppercase HTTP methods`);
|
|
108
|
+
return { url: rule.url, ...(methods ? { methods } : {}), ...(rule.resourceTypes === undefined ? {} : { resourceTypes: parseSet(rule.resourceTypes, `rules[${index}].resourceTypes`, RESOURCE_TYPES)! }), action: parseAction(rule, index) };
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function parseMockCollectorConfig(raw: unknown): MockCollectorConfig {
|
|
113
|
+
const config = record(raw, 'intercept config');
|
|
114
|
+
if (Object.keys(config).some(key => key !== 'rulesPath') || typeof config.rulesPath !== 'string') throw new Error('intercept config requires a rulesPath');
|
|
115
|
+
const rulesPath = assertUnderCaptureRoot(config.rulesPath);
|
|
116
|
+
const source = readPrivateFile(rulesPath);
|
|
117
|
+
return { rulesPath, source, rules: parseMockRuleDocument(source) };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function matches(pattern: string, url: string): boolean {
|
|
121
|
+
const pieces = pattern.split('*').map(piece => piece.replace(/[|\\{}()[\]^$+?.]/g, '\\$&'));
|
|
122
|
+
return new RegExp(`^${pieces.join('.*')}$`).test(url);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function matchRule(rules: readonly Rule[], request: { url: string; method: string; resourceType?: string }): { rule: Rule; index: number } | null {
|
|
126
|
+
for (const [index, rule] of rules.entries()) {
|
|
127
|
+
if (!matches(rule.url, request.url)) continue;
|
|
128
|
+
if (rule.methods && !rule.methods.includes(request.method)) continue;
|
|
129
|
+
if (rule.resourceTypes && (!request.resourceType || !rule.resourceTypes.includes(request.resourceType))) continue;
|
|
130
|
+
return { rule, index };
|
|
131
|
+
}
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function headerEntries(headers: Headers): Array<{ name: string; value: string }> { return Object.entries(headers).map(([name, value]) => ({ name, value })); }
|
|
136
|
+
|
|
137
|
+
export class InterceptCollector implements Collector<{ rules: number; paused: number; matched: number; releasedUnmatched: number; ruleMatches: number[] }> {
|
|
138
|
+
readonly kind = 'intercept' as const;
|
|
139
|
+
readonly claims = ['fetch-interception'] as const;
|
|
140
|
+
private context?: CollectorContext;
|
|
141
|
+
private config?: MockCollectorConfig;
|
|
142
|
+
private accepting = false;
|
|
143
|
+
private drained?: Promise<DrainOutcome<{ rules: number; paused: number; matched: number; releasedUnmatched: number; ruleMatches: number[] }>>;
|
|
144
|
+
private readonly paused = new Map<string, Promise<void>>();
|
|
145
|
+
private pausedCount = 0;
|
|
146
|
+
private matchedCount = 0;
|
|
147
|
+
private releasedUnmatched = 0;
|
|
148
|
+
private ruleMatches: number[] = [];
|
|
149
|
+
private readonly onPaused = (value: unknown): void => {
|
|
150
|
+
const paused = value as { requestId?: unknown };
|
|
151
|
+
if (typeof paused.requestId !== 'string') return;
|
|
152
|
+
const task = this.handlePaused(value).finally(() => { this.paused.delete(paused.requestId as string); });
|
|
153
|
+
this.paused.set(paused.requestId, task);
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
constructor(config: MockCollectorConfig) { this.config = config; }
|
|
157
|
+
|
|
158
|
+
async start(context: CollectorContext): Promise<void> {
|
|
159
|
+
const config = this.config;
|
|
160
|
+
if (!config) throw new Error('intercept collector has no rule configuration');
|
|
161
|
+
this.context = context;
|
|
162
|
+
this.ruleMatches = Array.from({ length: config.rules.length }, () => 0);
|
|
163
|
+
writePrivateFile(path.join(context.dir, 'rules.json'), config.source);
|
|
164
|
+
writePrivateFile(path.join(context.dir, 'interceptions.jsonl'), '');
|
|
165
|
+
context.client.on('Fetch.requestPaused', this.onPaused);
|
|
166
|
+
try {
|
|
167
|
+
await context.client.send('Fetch.enable', { patterns: [{ urlPattern: '*', requestStage: 'Request' }] });
|
|
168
|
+
this.accepting = true;
|
|
169
|
+
} catch (error) {
|
|
170
|
+
context.client.off('Fetch.requestPaused', this.onPaused);
|
|
171
|
+
throw error;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
closeAdmission(): void { this.accepting = false; }
|
|
176
|
+
|
|
177
|
+
private async continueRequest(requestId: string): Promise<void> { await this.context!.client.send('Fetch.continueRequest', { requestId }); }
|
|
178
|
+
|
|
179
|
+
private async handlePaused(value: unknown): Promise<void> {
|
|
180
|
+
const context = this.context;
|
|
181
|
+
const config = this.config;
|
|
182
|
+
const paused = value as { requestId?: unknown; request?: { url?: unknown; method?: unknown }; resourceType?: unknown };
|
|
183
|
+
if (!context || !config || typeof paused.requestId !== 'string' || typeof paused.request?.url !== 'string' || typeof paused.request.method !== 'string') return;
|
|
184
|
+
const request = { url: paused.request.url, method: paused.request.method, resourceType: typeof paused.resourceType === 'string' ? paused.resourceType : undefined };
|
|
185
|
+
this.pausedCount++;
|
|
186
|
+
let index: number | null = null;
|
|
187
|
+
let action = 'continue';
|
|
188
|
+
try {
|
|
189
|
+
if (!this.accepting) {
|
|
190
|
+
this.releasedUnmatched++;
|
|
191
|
+
action = 'teardown-continue';
|
|
192
|
+
await this.continueRequest(paused.requestId);
|
|
193
|
+
} else {
|
|
194
|
+
const matched = matchRule(config.rules, request);
|
|
195
|
+
if (!matched) await this.continueRequest(paused.requestId);
|
|
196
|
+
else if (!this.accepting) {
|
|
197
|
+
this.releasedUnmatched++;
|
|
198
|
+
action = 'teardown-continue';
|
|
199
|
+
await this.continueRequest(paused.requestId);
|
|
200
|
+
} else {
|
|
201
|
+
index = matched.index;
|
|
202
|
+
action = matched.rule.action.kind;
|
|
203
|
+
const ruleAction = matched.rule.action;
|
|
204
|
+
if (ruleAction.kind === 'fulfill') await context.client.send('Fetch.fulfillRequest', { requestId: paused.requestId, responseCode: ruleAction.status, responseHeaders: headerEntries(ruleAction.headers), body: ruleAction.body });
|
|
205
|
+
else if (ruleAction.kind === 'fail') await context.client.send('Fetch.failRequest', { requestId: paused.requestId, errorReason: ruleAction.reason });
|
|
206
|
+
else if (ruleAction.kind === 'modify') await context.client.send('Fetch.continueRequest', { requestId: paused.requestId, ...(ruleAction.url === undefined ? {} : { url: ruleAction.url }), ...(ruleAction.method === undefined ? {} : { method: ruleAction.method }), ...(ruleAction.headers === undefined ? {} : { headers: headerEntries(ruleAction.headers) }), ...(ruleAction.postData === undefined ? {} : { postData: ruleAction.postData }) });
|
|
207
|
+
else await this.continueRequest(paused.requestId);
|
|
208
|
+
this.ruleMatches[index]++;
|
|
209
|
+
this.matchedCount++;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
context.appendRecord('interceptions.jsonl', { at: new Date().toISOString(), url: request.url, method: request.method, resourceType: request.resourceType ?? null, rule: index, action });
|
|
213
|
+
} catch (error) {
|
|
214
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
215
|
+
context.noteLoss('rule_evaluation_error', { error: message });
|
|
216
|
+
try { await this.continueRequest(paused.requestId); } catch (releaseError) { context.noteLoss('rule_release_error', { error: releaseError instanceof Error ? releaseError.message : String(releaseError) }); }
|
|
217
|
+
context.appendRecord('interceptions.jsonl', { at: new Date().toISOString(), url: request.url, method: request.method, resourceType: request.resourceType ?? null, rule: index, action: 'error', error: message });
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async drain(cause: DrainCause): Promise<DrainOutcome<{ rules: number; paused: number; matched: number; releasedUnmatched: number; ruleMatches: number[] }>> {
|
|
222
|
+
return this.drained ??= this.drainOnce(cause);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
private async drainOnce(cause: DrainCause): Promise<DrainOutcome<{ rules: number; paused: number; matched: number; releasedUnmatched: number; ruleMatches: number[] }>> {
|
|
226
|
+
this.closeAdmission();
|
|
227
|
+
const context = this.context;
|
|
228
|
+
if (!context || !this.config) throw new Error('intercept collector was not started');
|
|
229
|
+
if (!cause.clientUsable) context.noteLoss('transport_lost');
|
|
230
|
+
if (cause.clientUsable) {
|
|
231
|
+
// Fetch can still deliver pauses after closeAdmission() and before its
|
|
232
|
+
// disable acknowledgement. Each takes the teardown-continue path; drain
|
|
233
|
+
// both the pre-disable work and anything delivered before Fetch confirms
|
|
234
|
+
// the domain is off, so meta.json cannot outrun its JSONL evidence.
|
|
235
|
+
while (this.paused.size) await Promise.all([...this.paused.values()]);
|
|
236
|
+
await context.client.send('Fetch.disable');
|
|
237
|
+
while (this.paused.size) await Promise.all([...this.paused.values()]);
|
|
238
|
+
}
|
|
239
|
+
context.client.off('Fetch.requestPaused', this.onPaused);
|
|
240
|
+
return { summary: { rules: this.config.rules.length, paused: this.pausedCount, matched: this.matchedCount, releasedUnmatched: this.releasedUnmatched, ruleMatches: this.ruleMatches }, files: [] };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
abandon(): void {
|
|
244
|
+
this.closeAdmission();
|
|
245
|
+
for (const requestId of this.paused.keys()) void this.continueRequest(requestId).catch(() => undefined);
|
|
246
|
+
}
|
|
247
|
+
}
|