@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,108 @@
|
|
|
1
|
+
import { performance } from 'node:perf_hooks';
|
|
2
|
+
import type { CDPClient } from '../../client.js';
|
|
3
|
+
import type { Collector, CollectorContext, DrainCause, DrainOutcome } from '../collector.js';
|
|
4
|
+
|
|
5
|
+
const TRACE_CATEGORIES = 'devtools.timeline,disabled-by-default-devtools.timeline,disabled-by-default-devtools.timeline.frame,disabled-by-default-devtools.timeline.stack,loading,blink.user_timing';
|
|
6
|
+
|
|
7
|
+
interface TraceEventParams { value?: unknown; }
|
|
8
|
+
|
|
9
|
+
function eventArray(params: unknown): readonly object[] {
|
|
10
|
+
const value = (params as TraceEventParams | null)?.value;
|
|
11
|
+
return Array.isArray(value) ? value.filter((event): event is object => event !== null && typeof event === 'object') : [];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function navigationCount(events: readonly object[]): number {
|
|
15
|
+
return events.filter((event) => (event as { name?: unknown }).name === 'navigationStart').length;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Streams Chrome's Trace event batches into one DevTools-openable trace.json. */
|
|
19
|
+
export class TraceCollector implements Collector<{ events: number; windowMs: number; navigations: number; categories: string }> {
|
|
20
|
+
readonly kind = 'trace' as const;
|
|
21
|
+
readonly claims = ['tracing'] as const;
|
|
22
|
+
private context: CollectorContext | undefined;
|
|
23
|
+
private writer: ReturnType<CollectorContext['openChunkFile']> | undefined;
|
|
24
|
+
private eventHandler: ((params: unknown) => void) | undefined;
|
|
25
|
+
private completeHandler: ((params: unknown) => void) | undefined;
|
|
26
|
+
private complete: Promise<void> | undefined;
|
|
27
|
+
private resolveComplete: (() => void) | undefined;
|
|
28
|
+
private startedAt = 0;
|
|
29
|
+
private events = 0;
|
|
30
|
+
private navigations = 0;
|
|
31
|
+
private first = true;
|
|
32
|
+
private closed = false;
|
|
33
|
+
private started = false;
|
|
34
|
+
private drainPromise: Promise<DrainOutcome<{ events: number; windowMs: number; navigations: number; categories: string }>> | undefined;
|
|
35
|
+
|
|
36
|
+
async start(ctx: CollectorContext): Promise<void> {
|
|
37
|
+
this.context = ctx;
|
|
38
|
+
this.writer = ctx.openChunkFile('trace.json');
|
|
39
|
+
this.writer.write('{"traceEvents":[');
|
|
40
|
+
this.eventHandler = (params) => {
|
|
41
|
+
if (this.closed) return;
|
|
42
|
+
const events = eventArray(params);
|
|
43
|
+
for (const event of events) {
|
|
44
|
+
this.writer!.write(`${this.first ? '' : ','}${JSON.stringify(event)}`);
|
|
45
|
+
this.first = false;
|
|
46
|
+
}
|
|
47
|
+
this.events += events.length;
|
|
48
|
+
this.navigations += navigationCount(events);
|
|
49
|
+
};
|
|
50
|
+
this.complete = new Promise(resolve => { this.resolveComplete = resolve; });
|
|
51
|
+
this.completeHandler = () => this.resolveComplete?.();
|
|
52
|
+
ctx.client.on('Tracing.dataCollected', this.eventHandler);
|
|
53
|
+
ctx.client.on('Tracing.tracingComplete', this.completeHandler);
|
|
54
|
+
try {
|
|
55
|
+
await ctx.client.send('Tracing.start', { transferMode: 'ReportEvents', categories: TRACE_CATEGORIES });
|
|
56
|
+
this.startedAt = performance.now();
|
|
57
|
+
this.started = true;
|
|
58
|
+
} catch (error) {
|
|
59
|
+
ctx.client.off('Tracing.dataCollected', this.eventHandler);
|
|
60
|
+
ctx.client.off('Tracing.tracingComplete', this.completeHandler);
|
|
61
|
+
this.writer.discard();
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
closeAdmission(): void {
|
|
67
|
+
// A trace has no routed-work admission surface. Tracing.dataCollected must
|
|
68
|
+
// remain subscribed until tracingComplete drains Chrome's final batches.
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async drain(cause: DrainCause): Promise<DrainOutcome<{ events: number; windowMs: number; navigations: number; categories: string }>> {
|
|
72
|
+
if (this.drainPromise) return this.drainPromise;
|
|
73
|
+
this.drainPromise = this.finish(cause);
|
|
74
|
+
return this.drainPromise;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
private async finish(cause: DrainCause): Promise<DrainOutcome<{ events: number; windowMs: number; navigations: number; categories: string }>> {
|
|
78
|
+
const ctx = this.context!;
|
|
79
|
+
if (this.started && cause.clientUsable) {
|
|
80
|
+
try {
|
|
81
|
+
await ctx.client.send('Tracing.end');
|
|
82
|
+
await Promise.race([
|
|
83
|
+
this.complete!,
|
|
84
|
+
new Promise<void>((_, reject) => setTimeout(() => reject(new Error('Timed out waiting for Tracing.tracingComplete.')), 30_000)),
|
|
85
|
+
]);
|
|
86
|
+
} catch (error) {
|
|
87
|
+
ctx.noteLoss('trace_drain_incomplete', { error: error instanceof Error ? error.message : String(error) });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (!cause.clientUsable) ctx.noteLoss('transport_lost');
|
|
91
|
+
this.closed = true;
|
|
92
|
+
ctx.client.off('Tracing.dataCollected', this.eventHandler!);
|
|
93
|
+
ctx.client.off('Tracing.tracingComplete', this.completeHandler!);
|
|
94
|
+
this.writer!.write(']}');
|
|
95
|
+
const bytes = this.writer!.commit();
|
|
96
|
+
return {
|
|
97
|
+
summary: { events: this.events, windowMs: this.startedAt ? performance.now() - this.startedAt : 0, navigations: this.navigations, categories: 'devtools-default' },
|
|
98
|
+
files: [{ name: 'trace.json', bytes }],
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
abandon(): void {
|
|
103
|
+
this.closed = true;
|
|
104
|
+
try { this.writer?.discard(); } catch {}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export { TRACE_CATEGORIES };
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import * as crypto from 'node:crypto';
|
|
2
|
+
import * as fs from 'node:fs';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import { performance } from 'node:perf_hooks';
|
|
5
|
+
import { CDPClient } from '../client.js';
|
|
6
|
+
import { appendNdjsonPrivate, ensurePrivateDir, openPrivateChunkWriter, readPrivateFile, removeArtifactTree, unlinkPrivateFile, writeJsonPrivate, type PidBirth } from '../../session/artifacts.js';
|
|
7
|
+
import { processPidBirthProvider, sameBirth } from '../../session/artifacts.js';
|
|
8
|
+
import { collectorKind } from './kinds.js';
|
|
9
|
+
import { RetainedCollectorStartFailure, type CdpClaim, type ClaimReservation, type Collector, type CollectorContext, type CollectorKind, type Completion, type DispatchNotice, type DrainCause, type DrainOutcome, type TeardownOutcome } from './collector.js';
|
|
10
|
+
|
|
11
|
+
export interface CollectorRow { id: string; kind: CollectorKind; dir: string; claims: readonly CdpClaim[]; startedAt: string; }
|
|
12
|
+
export interface HostSnapshot { collectors: readonly CollectorRow[]; reservations: readonly ClaimReservation[]; }
|
|
13
|
+
export interface HostIdentity { pid: number; birth: PidBirth; targetId: string; }
|
|
14
|
+
|
|
15
|
+
interface LiveCollector { row: CollectorRow; collector: Collector; startedAt: string; losses: Array<{ reason: string; detail?: Record<string, unknown> }>; stopping: boolean; }
|
|
16
|
+
|
|
17
|
+
export class CollectorHost {
|
|
18
|
+
private readonly live = new Map<string, LiveCollector>();
|
|
19
|
+
private readonly reservations = new Map<string, ClaimReservation>();
|
|
20
|
+
private readonly starting = new Map<string, readonly CdpClaim[]>();
|
|
21
|
+
private harClosing = false;
|
|
22
|
+
private clientUsable = true;
|
|
23
|
+
|
|
24
|
+
constructor(
|
|
25
|
+
readonly client: CDPClient,
|
|
26
|
+
readonly sessionDir: string,
|
|
27
|
+
readonly identity: HostIdentity,
|
|
28
|
+
private readonly publish: (snapshot: HostSnapshot) => void,
|
|
29
|
+
private readonly onEmpty: () => void,
|
|
30
|
+
) {
|
|
31
|
+
client.onDisconnect(() => { void this.transportLost(); });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
snapshot(): HostSnapshot { return { collectors: [...this.live.values()].map(item => item.row), reservations: [...this.reservations.values()] }; }
|
|
35
|
+
|
|
36
|
+
private changed(): void { this.publish(this.snapshot()); if (!this.live.size && !this.reservations.size) this.onEmpty(); }
|
|
37
|
+
|
|
38
|
+
private releaseDeadReservations(): void {
|
|
39
|
+
for (const [token, reservation] of this.reservations) {
|
|
40
|
+
const observed = processPidBirthProvider.read(reservation.pid);
|
|
41
|
+
if (observed.status === 'absent' || (observed.status === 'found' && !sameBirth(observed.identity, reservation.birth))) this.reservations.delete(token);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
private refuseClaims(claims: readonly CdpClaim[], exclusive = false): void {
|
|
46
|
+
this.releaseDeadReservations();
|
|
47
|
+
const exclusiveReservation = [...this.reservations.values()].find(reservation => reservation.exclusive);
|
|
48
|
+
if (exclusiveReservation) throw new Error(`Target is held exclusively by reservation ${exclusiveReservation.holderLabel}.`);
|
|
49
|
+
if (exclusive) {
|
|
50
|
+
const collector = this.live.values().next().value as LiveCollector | undefined;
|
|
51
|
+
if (collector) throw new Error(`Target has live collector ${collector.row.id}.`);
|
|
52
|
+
const reservation = this.reservations.values().next().value as ClaimReservation | undefined;
|
|
53
|
+
if (reservation) throw new Error(`Target has reservation ${reservation.holderLabel}.`);
|
|
54
|
+
const starting = this.starting.keys().next().value as string | undefined;
|
|
55
|
+
if (starting) throw new Error(`Target has starting collector ${starting}.`);
|
|
56
|
+
}
|
|
57
|
+
for (const claim of claims) {
|
|
58
|
+
const collector = [...this.live.values()].find(value => value.row.claims.includes(claim));
|
|
59
|
+
if (collector) throw new Error(`Claim "${claim}" is held by collector ${collector.row.id}.`);
|
|
60
|
+
const reservation = [...this.reservations.values()].find(value => value.claims.includes(claim));
|
|
61
|
+
if (reservation) throw new Error(`Claim "${claim}" is held by reservation ${reservation.holderLabel}.`);
|
|
62
|
+
const starting = [...this.starting.entries()].find(([, held]) => held.includes(claim));
|
|
63
|
+
if (starting) throw new Error(`Claim "${claim}" is held by collector ${starting[0]}.`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
reserve(claims: readonly CdpClaim[], holderLabel: string, pid: number, birth: PidBirth, exclusive = false): ClaimReservation {
|
|
68
|
+
this.refuseClaims(claims, exclusive);
|
|
69
|
+
const reservation: ClaimReservation = { token: crypto.randomBytes(18).toString('hex'), claims: [...new Set(claims)], ...(exclusive ? { exclusive: true } : {}), holderLabel, pid, birth, reservedAt: new Date().toISOString() };
|
|
70
|
+
this.reservations.set(reservation.token, reservation);
|
|
71
|
+
this.changed();
|
|
72
|
+
return reservation;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
releaseReservation(token: string): void { this.reservations.delete(token); this.changed(); }
|
|
76
|
+
|
|
77
|
+
async start(kind: CollectorKind, rawConfig: unknown): Promise<CollectorRow> {
|
|
78
|
+
const entry = collectorKind(kind);
|
|
79
|
+
const config = entry.parseConfig(rawConfig);
|
|
80
|
+
const collector = entry.create(config);
|
|
81
|
+
this.refuseClaims(collector.claims);
|
|
82
|
+
const id = `${entry.idPrefix}-${crypto.randomBytes(5).toString('hex')}`;
|
|
83
|
+
this.starting.set(id, collector.claims);
|
|
84
|
+
const dir = path.join(this.sessionDir, ...entry.idSegments, id);
|
|
85
|
+
try {
|
|
86
|
+
ensurePrivateDir(dir);
|
|
87
|
+
const startedAt = new Date().toISOString();
|
|
88
|
+
const row: CollectorRow = { id, kind, dir, claims: [...collector.claims], startedAt };
|
|
89
|
+
const live: LiveCollector = { row, collector, startedAt, losses: [], stopping: false };
|
|
90
|
+
const context: CollectorContext = {
|
|
91
|
+
client: this.client,
|
|
92
|
+
dir,
|
|
93
|
+
id,
|
|
94
|
+
targetId: this.identity.targetId,
|
|
95
|
+
config,
|
|
96
|
+
appendRecord: (file, record) => appendNdjsonPrivate(path.join(dir, file), record),
|
|
97
|
+
openChunkFile: file => openPrivateChunkWriter(path.join(dir, file)),
|
|
98
|
+
noteLoss: (reason, detail) => { live.losses.push({ reason, detail }); },
|
|
99
|
+
};
|
|
100
|
+
writeJsonPrivate(path.join(dir, 'collecting.json'), { kind, id, startedAt, hostPid: this.identity.pid, hostBirth: this.identity.birth });
|
|
101
|
+
await collector.start(context);
|
|
102
|
+
this.starting.delete(id);
|
|
103
|
+
this.live.set(id, live);
|
|
104
|
+
this.changed();
|
|
105
|
+
return row;
|
|
106
|
+
} catch (error) {
|
|
107
|
+
this.starting.delete(id);
|
|
108
|
+
if (!(error instanceof RetainedCollectorStartFailure)) removeArtifactTree(dir);
|
|
109
|
+
this.changed();
|
|
110
|
+
throw error;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private cutoff(items: Iterable<LiveCollector>): LiveCollector[] {
|
|
115
|
+
const selected = [...items].filter(item => !item.stopping);
|
|
116
|
+
for (const item of selected) item.stopping = true;
|
|
117
|
+
for (const item of selected) item.collector.closeAdmission();
|
|
118
|
+
return selected;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
private finalizedFiles(dir: string): Array<{ name: string; bytes: number }> {
|
|
122
|
+
const files: Array<{ name: string; bytes: number }> = [];
|
|
123
|
+
const walk = (relative: string): void => {
|
|
124
|
+
for (const entry of fs.readdirSync(path.join(dir, relative), { withFileTypes: true })) {
|
|
125
|
+
const name = path.join(relative, entry.name);
|
|
126
|
+
if (entry.isDirectory()) walk(name);
|
|
127
|
+
else if (entry.isFile()) files.push({ name, bytes: readPrivateFile(path.join(dir, name)).length });
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
walk('');
|
|
131
|
+
return files;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private async drainOne(live: LiveCollector, cause: DrainCause): Promise<{ completion: Completion; outcome: DrainOutcome }> {
|
|
135
|
+
const outcome = await live.collector.drain(cause);
|
|
136
|
+
unlinkPrivateFile(path.join(live.row.dir, 'collecting.json'));
|
|
137
|
+
const files = this.finalizedFiles(live.row.dir);
|
|
138
|
+
const loss = live.losses[0];
|
|
139
|
+
const completion: Completion = loss || !cause.clientUsable ? 'partial' : 'complete';
|
|
140
|
+
const summary = outcome.summary && typeof outcome.summary === 'object' && !Array.isArray(outcome.summary) ? outcome.summary as Record<string, unknown> : { value: outcome.summary };
|
|
141
|
+
writeJsonPrivate(path.join(live.row.dir, 'meta.json'), {
|
|
142
|
+
id: live.row.id,
|
|
143
|
+
kind: live.row.kind,
|
|
144
|
+
completion,
|
|
145
|
+
...(completion === 'partial' ? { reason: loss?.reason ?? 'transport_lost' } : {}),
|
|
146
|
+
startedAt: live.startedAt,
|
|
147
|
+
endedAt: new Date().toISOString(),
|
|
148
|
+
files,
|
|
149
|
+
summary,
|
|
150
|
+
...summary,
|
|
151
|
+
});
|
|
152
|
+
this.live.delete(live.row.id);
|
|
153
|
+
this.changed();
|
|
154
|
+
return { completion, outcome: { ...outcome, files } };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async stop(id: string, trigger: DrainCause['trigger'] = 'explicit'): Promise<{ completion: Completion; summary: unknown; files: readonly { name: string; bytes: number }[] }> {
|
|
158
|
+
const live = this.live.get(id);
|
|
159
|
+
if (!live) throw new Error(`No live collector named ${id}.`);
|
|
160
|
+
this.cutoff([live]);
|
|
161
|
+
const drained = await this.drainOne(live, { trigger, clientUsable: this.clientUsable });
|
|
162
|
+
return { completion: drained.completion, summary: drained.outcome.summary, files: drained.outcome.files };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async teardown(trigger: DrainCause['trigger'] = 'session-stop'): Promise<TeardownOutcome[]> {
|
|
166
|
+
const stopping = this.cutoff(this.live.values());
|
|
167
|
+
const outcomes: TeardownOutcome[] = [];
|
|
168
|
+
for (const live of stopping) {
|
|
169
|
+
try {
|
|
170
|
+
const drained = await this.drainOne(live, { trigger, clientUsable: this.clientUsable });
|
|
171
|
+
outcomes.push({ status: 'drained', id: live.row.id, kind: live.row.kind, completion: drained.completion, dir: live.row.dir });
|
|
172
|
+
} catch (error) {
|
|
173
|
+
outcomes.push({ status: 'terminal', id: live.row.id, kind: live.row.kind, dir: live.row.dir, error: error instanceof Error ? error.message : String(error) });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return outcomes;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private motionController(): Collector | undefined {
|
|
180
|
+
return [...this.live.values()].find(live => live.row.kind === 'motion')?.collector;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async control(message: unknown): Promise<unknown> {
|
|
184
|
+
if (!this.clientUsable) throw new Error('collector host CDP transport is unavailable');
|
|
185
|
+
const controller = this.motionController();
|
|
186
|
+
if (!controller?.control) throw new Error('no collector accepts this host control request');
|
|
187
|
+
return controller.control(message);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async dispatch(method: string, params: Record<string, unknown> = {}, annotation?: string, waitEvent?: string, timeoutMs?: number, observeDocumentResponse = false): Promise<unknown> {
|
|
191
|
+
if (!this.clientUsable) throw new Error('collector host CDP transport is unavailable');
|
|
192
|
+
const controller = this.motionController();
|
|
193
|
+
if (controller?.control) return controller.control({ type: 'cdp', method, params, annotation, waitEvent, timeoutMs, observeDocumentResponse });
|
|
194
|
+
const notice: DispatchNotice = { method, params, annotation, atPerformanceNowMs: performance.now() };
|
|
195
|
+
const callbacks = [...this.live.values()].flatMap(live => {
|
|
196
|
+
try { const callback = live.collector.onDispatch?.(notice); return callback ? [callback] : []; } catch { return []; }
|
|
197
|
+
});
|
|
198
|
+
try {
|
|
199
|
+
const result = await this.client.send(method, params);
|
|
200
|
+
const outcome = { ok: true, atPerformanceNowMs: performance.now() };
|
|
201
|
+
for (const callback of callbacks) callback(outcome);
|
|
202
|
+
return result;
|
|
203
|
+
} catch (error) {
|
|
204
|
+
const outcome = { ok: false, error: error instanceof Error ? error.message : String(error), atPerformanceNowMs: performance.now() };
|
|
205
|
+
for (const callback of callbacks) callback(outcome);
|
|
206
|
+
throw error;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
private async transportLost(): Promise<void> {
|
|
211
|
+
if (!this.clientUsable) return;
|
|
212
|
+
this.clientUsable = false;
|
|
213
|
+
await this.teardown('transport-lost');
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
abandon(): void { for (const live of this.live.values()) live.collector.abandon(); }
|
|
217
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import { collectorHostSocketPath } from '../bridge/spawn.js';
|
|
4
|
+
import { assertUnderCaptureRoot, parseBirth, readPrivateFile, sameBirth, type PidBirth } from '../../session/artifacts.js';
|
|
5
|
+
import { processPidBirthProvider } from '../../session/artifacts.js';
|
|
6
|
+
import type { CollectorRow } from './core.js';
|
|
7
|
+
import type { ClaimReservation } from './collector.js';
|
|
8
|
+
|
|
9
|
+
export interface CollectorHostHandle {
|
|
10
|
+
pid: number;
|
|
11
|
+
birth: PidBirth;
|
|
12
|
+
socketPath: string;
|
|
13
|
+
targetId: string;
|
|
14
|
+
nonce: string;
|
|
15
|
+
startedAt: string;
|
|
16
|
+
collectors: readonly CollectorRow[];
|
|
17
|
+
reservations: readonly ClaimReservation[];
|
|
18
|
+
}
|
|
19
|
+
export type CollectorHostClassification = 'absent' | 'live' | 'dead' | 'malformed' | 'unknown';
|
|
20
|
+
export interface ScannedCollectorHost { classification: CollectorHostClassification; handle: CollectorHostHandle | null; path: string; }
|
|
21
|
+
|
|
22
|
+
export function collectorHostPath(sessionDir: string): string { return path.join(assertUnderCaptureRoot(sessionDir), '.collector-host.json'); }
|
|
23
|
+
function parseHandle(raw: unknown, sessionDir: string): CollectorHostHandle | null {
|
|
24
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
25
|
+
const value = raw as Record<string, unknown>;
|
|
26
|
+
const birth = parseBirth(value.birth);
|
|
27
|
+
if (!birth || !Number.isSafeInteger(value.pid) || (value.pid as number) <= 0 || typeof value.socketPath !== 'string' || value.socketPath !== collectorHostSocketPath(sessionDir) || typeof value.targetId !== 'string' || !value.targetId || typeof value.nonce !== 'string' || !/^[0-9a-f]{64}$/.test(value.nonce) || typeof value.startedAt !== 'string' || !Array.isArray(value.collectors) || !Array.isArray(value.reservations)) return null;
|
|
28
|
+
return value as unknown as CollectorHostHandle;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function scanCollectorHost(sessionDir: string): ScannedCollectorHost {
|
|
32
|
+
const file = collectorHostPath(sessionDir);
|
|
33
|
+
let raw: unknown;
|
|
34
|
+
try { raw = JSON.parse(readPrivateFile(file).toString('utf8')); }
|
|
35
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { classification: 'absent', handle: null, path: file }; return { classification: 'malformed', handle: null, path: file }; }
|
|
36
|
+
const handle = parseHandle(raw, sessionDir);
|
|
37
|
+
if (!handle) return { classification: 'malformed', handle: null, path: file };
|
|
38
|
+
const read = processPidBirthProvider.read(handle.pid);
|
|
39
|
+
if (read.status === 'unknown') return { classification: 'unknown', handle, path: file };
|
|
40
|
+
if (read.status === 'found' && sameBirth(read.identity, handle.birth)) {
|
|
41
|
+
try {
|
|
42
|
+
if (!fs.lstatSync(handle.socketPath).isSocket()) return { classification: 'malformed', handle: null, path: file };
|
|
43
|
+
} catch { return { classification: 'malformed', handle: null, path: file }; }
|
|
44
|
+
return { classification: 'live', handle, path: file };
|
|
45
|
+
}
|
|
46
|
+
return { classification: 'dead', handle, path: file };
|
|
47
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { sendHostRequest } from './client.js';
|
|
2
|
+
|
|
3
|
+
const MARKABLE_EXACT_METHODS = new Set(['Input.insertText', 'Page.navigate']);
|
|
4
|
+
|
|
5
|
+
function isMarkableActionMethod(method: string, params: Record<string, unknown>): boolean {
|
|
6
|
+
if (MARKABLE_EXACT_METHODS.has(method)) return true;
|
|
7
|
+
if (method === 'Input.dispatchMouseEvent') return params.type === 'mousePressed';
|
|
8
|
+
if (method === 'Input.dispatchKeyEvent') return params.type === 'keyDown' || params.type === 'rawKeyDown';
|
|
9
|
+
if (method === 'Input.dispatchTouchEvent') return params.type === 'touchStart';
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface MotionHeldClientOptions {
|
|
14
|
+
socketPath: string;
|
|
15
|
+
nonce: string;
|
|
16
|
+
actionLabel: string;
|
|
17
|
+
timeoutMs?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** CDP-shaped client that routes a composed recording's commands through its session collector host. */
|
|
21
|
+
export class MotionHeldClient {
|
|
22
|
+
private readonly defaultTimeoutMs: number;
|
|
23
|
+
private suppressNextMousePressMark = false;
|
|
24
|
+
|
|
25
|
+
constructor(private readonly options: MotionHeldClientOptions) {
|
|
26
|
+
this.defaultTimeoutMs = options.timeoutMs ?? 60_000;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
suppressNextFocusClickMark(): void { this.suppressNextMousePressMark = true; }
|
|
30
|
+
|
|
31
|
+
async send(method: string, params: Record<string, unknown> = {}, timeoutMs?: number, _sessionId?: string): Promise<unknown> {
|
|
32
|
+
return (await this.dispatch(method, params, undefined, timeoutMs)).result;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async sendMarked(method: string, params: Record<string, unknown>, mark: string): Promise<unknown> {
|
|
36
|
+
return (await this.request(method, params, mark)).result;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async waitEvent(eventName: string, timeoutMs?: number): Promise<unknown> {
|
|
40
|
+
const response = await sendHostRequest(this.options.socketPath, {
|
|
41
|
+
type: 'cdp', nonce: this.options.nonce, waitEvent: eventName, timeoutMs: timeoutMs ?? this.defaultTimeoutMs,
|
|
42
|
+
}, (timeoutMs ?? this.defaultTimeoutMs) + 5_000);
|
|
43
|
+
if (!response.ok) throw new Error(`collector-host wait-event "${eventName}" failed: ${response.error}`);
|
|
44
|
+
return response.event;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async dispatch(method: string, params: Record<string, unknown> = {}, waitEvent?: string, timeoutMs?: number, observeDocumentResponse = false): Promise<{ result: unknown; event?: unknown; waitOutcome?: 'observed' | 'bounded-timeout'; documentResponse?: { status: number; url: string } }> {
|
|
48
|
+
const suppressMousePress = method === 'Input.dispatchMouseEvent' && params.type === 'mousePressed' && this.suppressNextMousePressMark;
|
|
49
|
+
if (suppressMousePress) this.suppressNextMousePressMark = false;
|
|
50
|
+
const annotation = !suppressMousePress && isMarkableActionMethod(method, params) ? this.options.actionLabel : undefined;
|
|
51
|
+
return this.request(method, params, annotation, waitEvent, timeoutMs, observeDocumentResponse);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async flushHar(): Promise<void> {
|
|
55
|
+
const response = await sendHostRequest(this.options.socketPath, { type: 'har-flush', nonce: this.options.nonce });
|
|
56
|
+
if (!response.ok) throw new Error(`collector host HAR flush failed: ${response.error}`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async collectStyleSheetHeaders(): Promise<Array<{ styleSheetId: string; sourceURL: string }>> {
|
|
60
|
+
const result = await this.send('Capture.collectStyleSheetHeaders') as { headers?: unknown };
|
|
61
|
+
if (!Array.isArray(result?.headers)) throw new Error('collector host stylesheet-header collection returned an invalid response');
|
|
62
|
+
return result.headers.flatMap(header => {
|
|
63
|
+
const value = header as { styleSheetId?: unknown; sourceURL?: unknown };
|
|
64
|
+
return typeof value.styleSheetId === 'string' && typeof value.sourceURL === 'string' && value.sourceURL.length > 0
|
|
65
|
+
? [{ styleSheetId: value.styleSheetId, sourceURL: value.sourceURL }]
|
|
66
|
+
: [];
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
on(_event: string, _handler: (params: unknown) => void): void {}
|
|
71
|
+
onDisconnect(_handler: () => void): void {}
|
|
72
|
+
close(): void {}
|
|
73
|
+
|
|
74
|
+
private async request(method: string, params: Record<string, unknown>, annotation?: string, waitEvent?: string, timeoutMs?: number, observeDocumentResponse = false): Promise<{ result: unknown; event?: unknown; waitOutcome?: 'observed' | 'bounded-timeout'; documentResponse?: { status: number; url: string } }> {
|
|
75
|
+
const response = await sendHostRequest(this.options.socketPath, {
|
|
76
|
+
type: 'cdp', nonce: this.options.nonce, method, params, annotation, waitEvent, timeoutMs: timeoutMs ?? this.defaultTimeoutMs, observeDocumentResponse,
|
|
77
|
+
}, (timeoutMs ?? this.defaultTimeoutMs) + 5_000);
|
|
78
|
+
if (!response.ok) throw new Error(`collector-host CDP call "${method}"${waitEvent ? ` with wait-event "${waitEvent}"` : ''} failed: ${response.error}`);
|
|
79
|
+
const documentResponse = response.documentResponse;
|
|
80
|
+
return {
|
|
81
|
+
result: response.result,
|
|
82
|
+
event: response.event,
|
|
83
|
+
waitOutcome: response.waitOutcome as 'observed' | 'bounded-timeout' | undefined,
|
|
84
|
+
documentResponse: documentResponse && typeof documentResponse === 'object' && typeof (documentResponse as { status?: unknown }).status === 'number' && typeof (documentResponse as { url?: unknown }).url === 'string'
|
|
85
|
+
? documentResponse as { status: number; url: string }
|
|
86
|
+
: undefined,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function isMotionHeldClient(client: unknown): client is MotionHeldClient { return client instanceof MotionHeldClient; }
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import { HeapCollector, reconstructHeapSnapshot } from './collectors/heap.js';
|
|
4
|
+
import { InterceptCollector, parseMockCollectorConfig } from './collectors/intercept.js';
|
|
5
|
+
import { MotionCollector } from './collectors/motion.js';
|
|
6
|
+
import { TraceCollector } from './collectors/trace.js';
|
|
7
|
+
import type { CollectorKind, CollectorKindEntry, DrainOutcome } from './collector.js';
|
|
8
|
+
|
|
9
|
+
function motionReconstruct(dir: string): DrainOutcome {
|
|
10
|
+
const framesDir = path.join(dir, 'frames');
|
|
11
|
+
const frames = fs.existsSync(framesDir) ? fs.readdirSync(framesDir).filter(name => name.endsWith('.png')).length : 0;
|
|
12
|
+
const bytes = (name: string): number => {
|
|
13
|
+
try { return fs.statSync(path.join(dir, name)).size; } catch { return 0; }
|
|
14
|
+
};
|
|
15
|
+
const files = ['events.jsonl', 'rects.jsonl', 'markers.json'].filter(name => fs.existsSync(path.join(dir, name))).map(name => ({ name, bytes: bytes(name) }));
|
|
16
|
+
return { summary: { frames, state: 'orphaned-finalized' }, files };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const motion: CollectorKindEntry = {
|
|
20
|
+
kind: 'motion',
|
|
21
|
+
idSegments: ['motion', 'recs'],
|
|
22
|
+
idPrefix: 'rec',
|
|
23
|
+
label: 'recording',
|
|
24
|
+
parseConfig(raw) {
|
|
25
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw) || typeof (raw as { harId?: unknown }).harId !== 'string' || !(raw as { harId: string }).harId) {
|
|
26
|
+
throw new Error('motion config requires a session HAR recording id');
|
|
27
|
+
}
|
|
28
|
+
return raw;
|
|
29
|
+
},
|
|
30
|
+
create() { return new MotionCollector(); },
|
|
31
|
+
reconstruct: motionReconstruct,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const trace: CollectorKindEntry = {
|
|
35
|
+
kind: 'trace',
|
|
36
|
+
idSegments: ['perf', 'traces'],
|
|
37
|
+
idPrefix: 'trace',
|
|
38
|
+
label: 'trace',
|
|
39
|
+
parseConfig(raw) {
|
|
40
|
+
if (raw !== undefined && (raw === null || typeof raw !== 'object' || Array.isArray(raw))) throw new Error('trace config must be an object');
|
|
41
|
+
return raw ?? {};
|
|
42
|
+
},
|
|
43
|
+
create() { return new TraceCollector(); },
|
|
44
|
+
reconstruct(dir) {
|
|
45
|
+
const file = path.join(dir, 'trace.json');
|
|
46
|
+
const bytes = fs.existsSync(file) ? fs.statSync(file).size : 0;
|
|
47
|
+
return { summary: { events: 0, windowMs: 0, navigations: 0, categories: 'devtools-default' }, files: bytes ? [{ name: 'trace.json', bytes }] : [] };
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const heap: CollectorKindEntry = {
|
|
52
|
+
kind: 'heap',
|
|
53
|
+
idSegments: ['heap', 'snapshots'],
|
|
54
|
+
idPrefix: 'heap',
|
|
55
|
+
label: 'heap snapshot',
|
|
56
|
+
parseConfig(raw) {
|
|
57
|
+
if (raw !== undefined && (raw === null || typeof raw !== 'object' || Array.isArray(raw))) throw new Error('heap snapshot config must be an object');
|
|
58
|
+
return raw ?? {};
|
|
59
|
+
},
|
|
60
|
+
create() { return new HeapCollector(); },
|
|
61
|
+
reconstruct: reconstructHeapSnapshot,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const unsupported = (kind: Exclude<CollectorKind, 'motion' | 'trace' | 'heap'>, idSegments: string[], idPrefix: string, label: string): CollectorKindEntry => ({
|
|
65
|
+
kind,
|
|
66
|
+
idSegments,
|
|
67
|
+
idPrefix,
|
|
68
|
+
label,
|
|
69
|
+
parseConfig(raw) { return raw; },
|
|
70
|
+
create() { throw new Error(`${kind} collector is not installed`); },
|
|
71
|
+
reconstruct(dir) { return { summary: {}, files: [] }; },
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const intercept: CollectorKindEntry = {
|
|
75
|
+
kind: 'intercept',
|
|
76
|
+
idSegments: ['network', 'mocks'],
|
|
77
|
+
idPrefix: 'mock',
|
|
78
|
+
label: 'mock',
|
|
79
|
+
parseConfig: parseMockCollectorConfig,
|
|
80
|
+
create(config) { return new InterceptCollector(config as ReturnType<typeof parseMockCollectorConfig>); },
|
|
81
|
+
reconstruct(dir) {
|
|
82
|
+
const bytes = (name: string): number => {
|
|
83
|
+
try { return fs.statSync(path.join(dir, name)).size; } catch { return 0; }
|
|
84
|
+
};
|
|
85
|
+
const files = ['rules.json', 'interceptions.jsonl'].filter(name => fs.existsSync(path.join(dir, name))).map(name => ({ name, bytes: bytes(name) }));
|
|
86
|
+
return { summary: { state: 'orphaned-finalized' }, files };
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export const COLLECTOR_KINDS: Record<CollectorKind, CollectorKindEntry> = {
|
|
91
|
+
motion,
|
|
92
|
+
trace,
|
|
93
|
+
heap,
|
|
94
|
+
intercept,
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
export function collectorKind(kind: CollectorKind): CollectorKindEntry { return COLLECTOR_KINDS[kind]; }
|