@crouton-kit/capture 1.3.7 → 1.3.9
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 +32786 -9989
- package/package.json +9 -3
- package/src/capture.ts +86 -163
- package/src/cdp/a11y.ts +113 -14
- package/src/cdp/args.ts +300 -116
- package/src/cdp/bridge/protocol.ts +187 -1
- package/src/cdp/bridge/server.ts +211 -84
- package/src/cdp/bridge/spawn.ts +110 -1
- package/src/cdp/client.ts +13 -3
- package/src/cdp/commands/bridge-serve.ts +35 -4
- package/src/cdp/commands/cdp.ts +266 -69
- package/src/cdp/commands/gate-guard.ts +43 -0
- package/src/cdp/commands/lib.ts +339 -213
- package/src/cdp/commands/measure/census.ts +87 -0
- package/src/cdp/commands/measure/check.ts +109 -0
- package/src/cdp/commands/measure/diff.ts +120 -0
- package/src/cdp/commands/measure/explain.ts +133 -0
- package/src/cdp/commands/measure/index.ts +106 -0
- package/src/cdp/commands/measure/map-ax.ts +52 -0
- package/src/cdp/commands/measure/map-focus.ts +52 -0
- package/src/cdp/commands/measure/map-layers.ts +59 -0
- package/src/cdp/commands/measure/map-scroll.ts +94 -0
- package/src/cdp/commands/measure/snap.ts +416 -0
- package/src/cdp/commands/measure/sweep.ts +227 -0
- package/src/cdp/commands/motion/index.ts +61 -0
- package/src/cdp/commands/motion/jank.ts +132 -0
- package/src/cdp/commands/motion/mask.ts +92 -0
- package/src/cdp/commands/motion/rec.ts +534 -0
- package/src/cdp/commands/motion/response.ts +76 -0
- package/src/cdp/commands/motion/timeline.ts +152 -0
- 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/coordinates.ts +158 -0
- package/src/cdp/detect.ts +42 -13
- package/src/cdp/dispatch.ts +76 -26
- package/src/cdp/domains.ts +75 -0
- 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 +1894 -0
- 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/source-map.ts +309 -0
- package/src/cdp/targets.ts +21 -1
- package/src/cdp/timing.ts +87 -0
- package/src/cdp/types.ts +87 -6
- 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 +526 -0
- package/src/output/diff.ts +111 -0
- package/src/output/exact-raw.ts +114 -0
- package/src/output/render.ts +987 -0
- package/src/output/selector.ts +185 -0
- package/src/output/structured.ts +211 -0
- package/src/registry.ts +208 -0
- package/src/session/artifacts.ts +481 -0
- package/src/session/commands.ts +1237 -368
- 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 +302 -34
- 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 -77
- package/src/tab-session.ts +0 -91
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
|
|
4
|
+
import { diffPngs } from '../../output/diff.js';
|
|
5
|
+
import { artifactPath, readEvents, readMarkers, readMeta, readRects, resolveRecRef, type RecRef } from '../../output/artifact.js';
|
|
6
|
+
import { ensurePrivateDir } from '../../session/artifacts.js';
|
|
7
|
+
|
|
8
|
+
export type ResponseStage = 'input' | 'mutation' | 'layout' | 'paint' | 'network' | 'longtask' | 'settle';
|
|
9
|
+
export type TimestampPrecision = 'exact' | 'frame';
|
|
10
|
+
|
|
11
|
+
export interface ResponsePoint {
|
|
12
|
+
readonly stage: ResponseStage;
|
|
13
|
+
/** Milliseconds in the recording-relative performance.now() domain. */
|
|
14
|
+
readonly timestampMs: number;
|
|
15
|
+
/** Delta from the selected input's start. */
|
|
16
|
+
readonly deltaMs: number;
|
|
17
|
+
readonly precision: TimestampPrecision;
|
|
18
|
+
/** Fixed, sanitized label for the evidence source. */
|
|
19
|
+
readonly source: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ResponseTimeline {
|
|
23
|
+
readonly action: string;
|
|
24
|
+
readonly inputStartMs: number;
|
|
25
|
+
readonly inputEndMs: number;
|
|
26
|
+
readonly actionWindowEndMs: number | null;
|
|
27
|
+
readonly points: readonly ResponsePoint[];
|
|
28
|
+
readonly unavailableStages: readonly string[];
|
|
29
|
+
readonly caveats: readonly string[];
|
|
30
|
+
readonly state: string;
|
|
31
|
+
readonly timingNote: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface Markers {
|
|
35
|
+
performanceNowMs: number;
|
|
36
|
+
wallClockMs?: number;
|
|
37
|
+
firstScreencastTimestampSec?: number | null;
|
|
38
|
+
firstTraceEventTsUs?: number | null;
|
|
39
|
+
tracePerformanceNowMs?: number | null;
|
|
40
|
+
traceTimestampUs?: number | null;
|
|
41
|
+
baselinesPending?: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface EventRecord {
|
|
45
|
+
kind?: unknown;
|
|
46
|
+
mark?: unknown;
|
|
47
|
+
action?: unknown;
|
|
48
|
+
startPerformanceNow?: unknown;
|
|
49
|
+
endPerformanceNow?: unknown;
|
|
50
|
+
performanceNowMs?: unknown;
|
|
51
|
+
performanceNow?: unknown;
|
|
52
|
+
startTime?: unknown;
|
|
53
|
+
duration?: unknown;
|
|
54
|
+
entryType?: unknown;
|
|
55
|
+
name?: unknown;
|
|
56
|
+
events?: unknown;
|
|
57
|
+
timestamp?: unknown;
|
|
58
|
+
reason?: unknown;
|
|
59
|
+
count?: unknown;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface FrameRecord {
|
|
63
|
+
frame?: unknown;
|
|
64
|
+
file?: unknown;
|
|
65
|
+
screencastTimestamp?: unknown;
|
|
66
|
+
timestamp?: unknown;
|
|
67
|
+
diffPixelCount?: unknown;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface ActionMark {
|
|
71
|
+
readonly label: string;
|
|
72
|
+
readonly occurrence: number;
|
|
73
|
+
readonly startMs: number;
|
|
74
|
+
readonly endMs: number;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface CandidatePoint extends Omit<ResponsePoint, 'deltaMs'> {}
|
|
78
|
+
|
|
79
|
+
function finite(value: unknown): value is number {
|
|
80
|
+
return typeof value === 'number' && Number.isFinite(value);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function count(value: unknown): number | null {
|
|
84
|
+
return finite(value) && value >= 0 ? value : null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function relativePerformance(timestamp: number, markers: Markers): number {
|
|
88
|
+
return timestamp - markers.performanceNowMs;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function observerPerformance(event: EventRecord, markers: Markers): number | null {
|
|
92
|
+
const timestamp = event.kind === 'performance'
|
|
93
|
+
? event.startTime ?? event.performanceNowMs ?? event.performanceNow
|
|
94
|
+
: event.performanceNowMs ?? event.performanceNow ?? event.startTime;
|
|
95
|
+
return finite(timestamp) ? relativePerformance(timestamp, markers) : null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function tracePerformance(timestampUs: number, markers: Markers): number | null {
|
|
99
|
+
const traceAnchorUs = finite(markers.traceTimestampUs) ? markers.traceTimestampUs : null;
|
|
100
|
+
const traceAnchorPerf = finite(markers.tracePerformanceNowMs) ? markers.tracePerformanceNowMs : null;
|
|
101
|
+
if (traceAnchorUs !== null && traceAnchorPerf !== null) return (timestampUs - traceAnchorUs) / 1000 + relativePerformance(traceAnchorPerf, markers);
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function framePerformance(timestampSec: number, markers: Markers): number | null {
|
|
106
|
+
if (!finite(markers.wallClockMs)) return null;
|
|
107
|
+
return timestampSec * 1000 - markers.wallClockMs;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function inWindow(point: CandidatePoint, startMs: number, endMs: number | null): boolean {
|
|
111
|
+
return point.timestampMs >= startMs && (endMs === null || point.timestampMs < endMs);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function traceEvents(events: readonly EventRecord[], markers: Markers, caveats: string[]): Array<{ name: string; timestampMs: number; durationMs?: number }> {
|
|
115
|
+
const output: Array<{ name: string; timestampMs: number; durationMs?: number }> = [];
|
|
116
|
+
for (const event of events) {
|
|
117
|
+
if (event.kind !== 'trace' || !Array.isArray(event.events)) continue;
|
|
118
|
+
for (const raw of event.events) {
|
|
119
|
+
if (!raw || typeof raw !== 'object') continue;
|
|
120
|
+
const trace = raw as { name?: unknown; ts?: unknown; dur?: unknown };
|
|
121
|
+
if (!finite(trace.ts) || typeof trace.name !== 'string') continue;
|
|
122
|
+
const timestampMs = tracePerformance(trace.ts, markers);
|
|
123
|
+
if (timestampMs === null) continue;
|
|
124
|
+
output.push({ name: trace.name, timestampMs, durationMs: finite(trace.dur) ? trace.dur / 1000 : undefined });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (events.some((event) => event.kind === 'trace') && output.length === 0) {
|
|
128
|
+
caveats.push('trace timing unavailable: markers.json has no real trace timestamp/performance alignment marker; trace-derived response rows are omitted.');
|
|
129
|
+
}
|
|
130
|
+
return output;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function inputMarks(events: readonly EventRecord[], markers: Markers): ActionMark[] {
|
|
134
|
+
const seen = new Map<string, number>();
|
|
135
|
+
const marks: ActionMark[] = [];
|
|
136
|
+
for (const event of events) {
|
|
137
|
+
if (event.kind !== 'input' || typeof event.mark !== 'string' || !finite(event.startPerformanceNow)) continue;
|
|
138
|
+
// New artifacts retain the verbatim action separately from the internal
|
|
139
|
+
// structural mark. Legacy artifacts have only `mark`.
|
|
140
|
+
const label = typeof event.action === 'string' ? event.action : event.mark;
|
|
141
|
+
if (typeof label !== 'string') continue;
|
|
142
|
+
const occurrence = (seen.get(label) ?? 0) + 1;
|
|
143
|
+
seen.set(label, occurrence);
|
|
144
|
+
const startMs = relativePerformance(event.startPerformanceNow, markers);
|
|
145
|
+
const endMs = finite(event.endPerformanceNow) ? relativePerformance(event.endPerformanceNow, markers) : startMs;
|
|
146
|
+
marks.push({ label, occurrence, startMs, endMs });
|
|
147
|
+
}
|
|
148
|
+
return marks.sort((a, b) => a.startMs - b.startMs || a.occurrence - b.occurrence);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function selectAction(actions: readonly ActionMark[], action: string, occurrence?: number): ActionMark {
|
|
152
|
+
const matches = actions.filter((item) => item.label === action);
|
|
153
|
+
if (matches.length === 0) throw new Error(`No input mark exists for action ${JSON.stringify(action)}.`);
|
|
154
|
+
if (occurrence !== undefined) {
|
|
155
|
+
const match = matches.find((item) => item.occurrence === occurrence);
|
|
156
|
+
if (match) return match;
|
|
157
|
+
throw new ResponseActionSelectionError(matches, `Action ${JSON.stringify(action)} has no occurrence ${occurrence}; ${matches.length} occurrence(s) available.`);
|
|
158
|
+
}
|
|
159
|
+
if (matches.length === 1) return matches[0];
|
|
160
|
+
throw new ResponseActionSelectionError(matches, `Action ${JSON.stringify(action)} appears ${matches.length} times; pass --occurrence <n> to select one.`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function eventDropCaveats(events: readonly EventRecord[]): string[] {
|
|
164
|
+
const caveats: string[] = [];
|
|
165
|
+
for (const event of events) {
|
|
166
|
+
if (event.kind !== 'trace-dropped' && event.kind !== 'binding-dropped' && event.kind !== 'rect-sample-dropped') continue;
|
|
167
|
+
const dropped = count(event.count);
|
|
168
|
+
const reason = typeof event.reason === 'string' ? event.reason : 'unknown reason';
|
|
169
|
+
caveats.push(`${event.kind}: ${dropped ?? 'unknown'} record(s) dropped (${reason}); response evidence may be truncated.`);
|
|
170
|
+
}
|
|
171
|
+
return caveats;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
interface FrameDelta {
|
|
175
|
+
readonly timestampMs: number;
|
|
176
|
+
readonly diffPixelCount: number;
|
|
177
|
+
readonly source: string;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function frameRecordTime(frame: FrameRecord, markers: Markers): number | null {
|
|
181
|
+
const native = finite(frame.screencastTimestamp) ? frame.screencastTimestamp : finite(frame.timestamp) ? frame.timestamp : null;
|
|
182
|
+
return native === null ? null : framePerformance(native, markers);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function frameDeltasFromRecords(frames: readonly FrameRecord[], markers: Markers): FrameDelta[] {
|
|
186
|
+
return frames
|
|
187
|
+
.map((frame, index) => ({ frame, index, timestampMs: frameRecordTime(frame, markers), diffPixelCount: count(frame.diffPixelCount) }))
|
|
188
|
+
.filter((item): item is { frame: FrameRecord; index: number; timestampMs: number; diffPixelCount: number } => item.timestampMs !== null && item.diffPixelCount !== null)
|
|
189
|
+
.sort((a, b) => a.timestampMs - b.timestampMs)
|
|
190
|
+
.map((item) => ({ timestampMs: item.timestampMs, diffPixelCount: item.diffPixelCount, source: `frame delta ${item.index}` }));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function firstPaintDelta(frameDeltas: readonly FrameDelta[], startMs: number, endMs: number | null): CandidatePoint | null {
|
|
194
|
+
const delta = frameDeltas.find((item) => item.diffPixelCount > 0 && item.timestampMs >= startMs && (endMs === null || item.timestampMs < endMs));
|
|
195
|
+
return delta ? { stage: 'paint', timestampMs: delta.timestampMs, precision: 'frame', source: `${delta.source} (${delta.diffPixelCount} changed pixel(s))` } : null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function settlePoint(frameDeltas: readonly FrameDelta[], candidates: readonly CandidatePoint[], startMs: number, endMs: number | null): CandidatePoint | null {
|
|
199
|
+
const lastSignalMs = Math.max(startMs, ...candidates.filter((point) => inWindow(point, startMs, endMs)).map((point) => point.timestampMs));
|
|
200
|
+
let consecutiveIdentical = 0;
|
|
201
|
+
for (const delta of frameDeltas) {
|
|
202
|
+
if (delta.timestampMs < lastSignalMs || (endMs !== null && delta.timestampMs >= endMs)) continue;
|
|
203
|
+
if (delta.diffPixelCount === 0) consecutiveIdentical += 1;
|
|
204
|
+
else consecutiveIdentical = 0;
|
|
205
|
+
if (consecutiveIdentical >= 2 && delta.timestampMs - lastSignalMs >= 300) {
|
|
206
|
+
return { stage: 'settle', timestampMs: delta.timestampMs, precision: 'frame', source: 'two consecutive identical frame deltas; no scoped DOM/network/layout/paint signal for ≥300ms' };
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function firstStage(candidates: readonly CandidatePoint[], stage: ResponseStage, startMs: number, endMs: number | null): CandidatePoint | null {
|
|
213
|
+
return candidates.filter((point) => point.stage === stage && inWindow(point, startMs, endMs)).sort((a, b) => a.timestampMs - b.timestampMs)[0] ?? null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Reduces recorder artifacts into response facts. It never invents a stage:
|
|
218
|
+
* absent/unusable evidence is reported as unavailable, and settle is emitted
|
|
219
|
+
* only from action-scoped identical-frame + quiet-window evidence.
|
|
220
|
+
*/
|
|
221
|
+
export function responseTimelineFromArtifacts(
|
|
222
|
+
action: string,
|
|
223
|
+
events: readonly EventRecord[],
|
|
224
|
+
frames: readonly FrameRecord[],
|
|
225
|
+
markers: Markers,
|
|
226
|
+
state: string,
|
|
227
|
+
occurrence?: number,
|
|
228
|
+
): ResponseTimeline {
|
|
229
|
+
if (!finite(markers.performanceNowMs)) throw new Error('markers.json has no finite performanceNowMs baseline; re-record with `capture motion rec`.');
|
|
230
|
+
const caveats = eventDropCaveats(events);
|
|
231
|
+
if (markers.baselinesPending) caveats.push('markers.json still has baselinesPending=true; timing sources without anchors are omitted rather than aligned approximately.');
|
|
232
|
+
if (state === 'orphaned-finalized') caveats.push('recording state is orphaned-finalized; artifacts are a partial best-effort recording from data flushed before recorder exit.');
|
|
233
|
+
if (state === 'partial') caveats.push('recording state is partial (no_frames); retained source records are readable, while frame-derived paint and settle facts are unavailable.');
|
|
234
|
+
if (!finite(markers.wallClockMs)) caveats.push('screencast wall-clock anchor unavailable; frame-derived paint and settle timing are unavailable.');
|
|
235
|
+
|
|
236
|
+
const actions = inputMarks(events, markers);
|
|
237
|
+
const selected = selectAction(actions, action, occurrence);
|
|
238
|
+
const nextAction = actions.find((item) => item.startMs > selected.startMs);
|
|
239
|
+
const actionWindowEndMs = nextAction?.startMs ?? null;
|
|
240
|
+
|
|
241
|
+
const candidates: CandidatePoint[] = [];
|
|
242
|
+
for (const event of events) {
|
|
243
|
+
const timestampMs = observerPerformance(event, markers);
|
|
244
|
+
if (timestampMs === null) continue;
|
|
245
|
+
if (event.kind === 'mutation') candidates.push({ stage: 'mutation', timestampMs, precision: 'exact', source: 'MutationObserver' });
|
|
246
|
+
if (event.kind === 'resize' || (event.kind === 'performance' && event.entryType === 'layout')) candidates.push({ stage: 'layout', timestampMs, precision: 'exact', source: event.kind === 'resize' ? 'ResizeObserver' : 'PerformanceObserver layout entry' });
|
|
247
|
+
if (event.kind === 'performance' && event.entryType === 'paint') candidates.push({ stage: 'paint', timestampMs, precision: 'exact', source: 'PerformanceObserver paint entry' });
|
|
248
|
+
if (event.kind === 'network') candidates.push({ stage: 'network', timestampMs, precision: 'exact', source: 'network event' });
|
|
249
|
+
if (event.kind === 'performance' && event.entryType === 'longtask') candidates.push({ stage: 'longtask', timestampMs, precision: 'exact', source: 'PerformanceObserver longtask entry' });
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
for (const trace of traceEvents(events, markers, caveats)) {
|
|
253
|
+
if (/layout|updatelayouttree/i.test(trace.name)) candidates.push({ stage: 'layout', timestampMs: trace.timestampMs, precision: 'exact', source: `Tracing ${trace.name}` });
|
|
254
|
+
else if (/paint|composite|drawframe/i.test(trace.name)) candidates.push({ stage: 'paint', timestampMs: trace.timestampMs, precision: 'exact', source: `Tracing ${trace.name}` });
|
|
255
|
+
else if (/network|resource(send|receive|finish)|load/i.test(trace.name)) candidates.push({ stage: 'network', timestampMs: trace.timestampMs, precision: 'exact', source: `Tracing ${trace.name}` });
|
|
256
|
+
else if (/(longtask|run(task|microtasks)|task)/i.test(trace.name) && (trace.durationMs ?? 0) >= 50) candidates.push({ stage: 'longtask', timestampMs: trace.timestampMs, precision: 'exact', source: `Tracing ${trace.name}` });
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const frameDeltas = frameDeltasFromRecords(frames, markers);
|
|
260
|
+
const paintFromFrames = firstPaintDelta(frameDeltas, selected.startMs, actionWindowEndMs);
|
|
261
|
+
if (paintFromFrames) candidates.push(paintFromFrames);
|
|
262
|
+
|
|
263
|
+
const points: ResponsePoint[] = [{ stage: 'input', timestampMs: selected.startMs, deltaMs: 0, precision: 'exact', source: `input dispatch mark (occurrence ${selected.occurrence})` }];
|
|
264
|
+
for (const stage of ['mutation', 'layout', 'paint', 'network', 'longtask'] as const) {
|
|
265
|
+
const point = firstStage(candidates, stage, selected.startMs, actionWindowEndMs);
|
|
266
|
+
if (point) points.push({ ...point, deltaMs: point.timestampMs - selected.startMs });
|
|
267
|
+
}
|
|
268
|
+
const settle = settlePoint(frameDeltas, candidates, selected.startMs, actionWindowEndMs);
|
|
269
|
+
if (settle) points.push({ ...settle, deltaMs: settle.timestampMs - selected.startMs });
|
|
270
|
+
|
|
271
|
+
points.sort((a, b) => a.timestampMs - b.timestampMs || a.stage.localeCompare(b.stage));
|
|
272
|
+
|
|
273
|
+
const present = new Set(points.map((point) => point.stage));
|
|
274
|
+
const unavailableStages = ['mutation', 'layout', 'paint', 'network', 'settle'].filter((stage) => !present.has(stage as ResponseStage));
|
|
275
|
+
if (unavailableStages.includes('paint') && frameDeltas.length === 0) caveats.push('paint-different frame unavailable: no readable adjacent frame deltas with wall-clock-aligned timestamps.');
|
|
276
|
+
if (unavailableStages.includes('settle')) caveats.push('settle unavailable: this action window has no two consecutive identical frame deltas after ≥300ms without scoped response signals.');
|
|
277
|
+
|
|
278
|
+
return {
|
|
279
|
+
action: selected.label,
|
|
280
|
+
inputStartMs: selected.startMs,
|
|
281
|
+
inputEndMs: selected.endMs,
|
|
282
|
+
actionWindowEndMs,
|
|
283
|
+
points,
|
|
284
|
+
unavailableStages,
|
|
285
|
+
caveats,
|
|
286
|
+
state,
|
|
287
|
+
timingNote: 'Timestamps use performance.now() relative to recorder arm; observer points use PerformanceEntry.startTime when present, trace points require a real trace alignment marker, and frame-derived points use the screencast wall-clock anchor with ±1 frame uncertainty.',
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export interface LoadedResponseTimeline {
|
|
292
|
+
readonly ref: RecRef;
|
|
293
|
+
readonly timeline: ResponseTimeline;
|
|
294
|
+
readonly availableActions: readonly string[];
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function withFrameDeltas(ref: RecRef, rects: readonly FrameRecord[], markers: Markers): FrameRecord[] {
|
|
298
|
+
const framesDir = artifactPath(ref, 'frames', { mustExist: false });
|
|
299
|
+
if (!fs.existsSync(framesDir) || !fs.statSync(framesDir).isDirectory()) return [...rects];
|
|
300
|
+
const files = fs.readdirSync(framesDir).filter((name) => name.endsWith('.png')).sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
|
|
301
|
+
if (files.length < 2) return [...rects];
|
|
302
|
+
const byFile = new Map(rects.filter((record) => typeof record.file === 'string').map((record) => [record.file as string, record]));
|
|
303
|
+
const workDir = path.join(ref.dir, '.response-diff-work');
|
|
304
|
+
ensurePrivateDir(workDir);
|
|
305
|
+
try {
|
|
306
|
+
const output: FrameRecord[] = [...rects];
|
|
307
|
+
for (let i = 0; i < files.length - 1; i++) {
|
|
308
|
+
const before = path.join(framesDir, files[i]);
|
|
309
|
+
const after = path.join(framesDir, files[i + 1]);
|
|
310
|
+
const diffPath = path.join(workDir, `pair-${String(i).padStart(6, '0')}.png`);
|
|
311
|
+
const diff = diffPngs(before, after, diffPath, { diffMask: true });
|
|
312
|
+
if (!diff.ok) continue;
|
|
313
|
+
const afterRecord = byFile.get(files[i + 1]);
|
|
314
|
+
if (afterRecord) output.push({ ...afterRecord, diffPixelCount: diff.diffPixelCount });
|
|
315
|
+
else output.push({ file: files[i + 1], screencastTimestamp: undefined, timestamp: undefined, frame: i + 1, diffPixelCount: diff.diffPixelCount });
|
|
316
|
+
}
|
|
317
|
+
return output;
|
|
318
|
+
} finally {
|
|
319
|
+
fs.rmSync(workDir, { recursive: true, force: true });
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Resolves recording artifacts that contain a completed evidence inventory before reading its response facts. */
|
|
324
|
+
export function loadResponseTimeline(rec: string, action?: string, occurrence?: number): LoadedResponseTimeline {
|
|
325
|
+
const ref = resolveRecRef(rec);
|
|
326
|
+
const meta = readMeta<{ state?: unknown; reason?: unknown }>(ref);
|
|
327
|
+
const state = typeof meta.state === 'string' ? meta.state : 'unknown';
|
|
328
|
+
const readablePartial = state === 'partial' && meta.reason === 'no_frames';
|
|
329
|
+
if (state !== 'finalized' && state !== 'orphaned-finalized' && !readablePartial) {
|
|
330
|
+
throw new Error(`Recording ${JSON.stringify(ref.id)} is state ${JSON.stringify(state)}; finalize it with \`capture motion rec --stop\` before querying response.`);
|
|
331
|
+
}
|
|
332
|
+
const events = readEvents<EventRecord>(ref);
|
|
333
|
+
const markers = readMarkers<Markers>(ref);
|
|
334
|
+
if (!finite(markers.performanceNowMs)) throw new Error('markers.json has no finite performanceNowMs baseline; re-record with `capture motion rec`.');
|
|
335
|
+
const actions = inputMarks(events, markers);
|
|
336
|
+
if (!action && actions.length !== 1) throw new ResponseActionSelectionError(actions);
|
|
337
|
+
const selected = action ?? actions[0]?.label;
|
|
338
|
+
if (!selected) throw new ResponseActionSelectionError(actions);
|
|
339
|
+
const rects = readRects<FrameRecord>(ref);
|
|
340
|
+
const frames = withFrameDeltas(ref, rects, markers);
|
|
341
|
+
return { ref, timeline: responseTimelineFromArtifacts(selected, events, frames, markers, state, occurrence), availableActions: actions.map((item) => actionOccurrenceLabel(item)) };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function actionOccurrenceLabel(action: ActionMark): string {
|
|
345
|
+
return `${action.label} (occurrence ${action.occurrence}, t=${action.startMs.toFixed(2)}ms)`;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
export class ResponseActionSelectionError extends Error {
|
|
349
|
+
readonly actions: readonly string[];
|
|
350
|
+
constructor(actions: readonly ActionMark[] | readonly string[], message?: string) {
|
|
351
|
+
const labels = actions.map((action) => typeof action === 'string' ? action : actionOccurrenceLabel(action));
|
|
352
|
+
super(message ?? (labels.length === 0 ? 'Recording has no injected input marks.' : 'Recording has multiple input marks; pass --action <unique action>.'));
|
|
353
|
+
this.name = 'ResponseActionSelectionError';
|
|
354
|
+
this.actions = labels;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import {
|
|
2
|
+
readEvents,
|
|
3
|
+
readMarkers,
|
|
4
|
+
readMeta,
|
|
5
|
+
readRects,
|
|
6
|
+
type RecRef,
|
|
7
|
+
} from '../../output/artifact.js';
|
|
8
|
+
|
|
9
|
+
export interface TimelineRect {
|
|
10
|
+
readonly tag?: unknown;
|
|
11
|
+
readonly id?: unknown;
|
|
12
|
+
readonly classes?: unknown;
|
|
13
|
+
readonly backendNodeId?: unknown;
|
|
14
|
+
readonly identityUnresolved?: unknown;
|
|
15
|
+
readonly x?: unknown;
|
|
16
|
+
readonly y?: unknown;
|
|
17
|
+
readonly width?: unknown;
|
|
18
|
+
readonly height?: unknown;
|
|
19
|
+
readonly scrollTop?: unknown;
|
|
20
|
+
readonly scrollLeft?: unknown;
|
|
21
|
+
readonly properties?: unknown;
|
|
22
|
+
readonly computed?: unknown;
|
|
23
|
+
readonly [key: string]: unknown;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface TimelineFrameRecord {
|
|
27
|
+
readonly frame?: unknown;
|
|
28
|
+
readonly screencastTimestamp?: unknown;
|
|
29
|
+
readonly elements?: unknown;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface MarkerRecord {
|
|
33
|
+
readonly firstScreencastTimestampSec?: unknown;
|
|
34
|
+
readonly performanceNowMs?: unknown;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface TimelinePoint {
|
|
38
|
+
readonly frame: number;
|
|
39
|
+
/** Milliseconds from the recording's first sampled frame, in its screencast clock. */
|
|
40
|
+
readonly timeMs: number | null;
|
|
41
|
+
readonly timestampUncertainty: '±frame';
|
|
42
|
+
readonly elementId: number | null;
|
|
43
|
+
readonly tag: string | null;
|
|
44
|
+
readonly id: string | null;
|
|
45
|
+
readonly classes: string | null;
|
|
46
|
+
readonly x: number;
|
|
47
|
+
readonly y: number;
|
|
48
|
+
readonly width: number;
|
|
49
|
+
readonly height: number;
|
|
50
|
+
readonly scrollTop?: number;
|
|
51
|
+
readonly scrollLeft?: number;
|
|
52
|
+
readonly property?: { readonly name: string; readonly value: string | number | boolean | null };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface TimelineAnalysis {
|
|
56
|
+
readonly points: readonly TimelinePoint[];
|
|
57
|
+
readonly selectedBackendNodeId: number | null;
|
|
58
|
+
readonly selectionMethod: 'backend-node-id' | 'sample-label';
|
|
59
|
+
readonly frameCount: number;
|
|
60
|
+
readonly eventsRead: number;
|
|
61
|
+
readonly propertyAvailable: boolean;
|
|
62
|
+
readonly timingDomain: 'screencast-relative' | 'frame-order-only';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function finiteNumber(value: unknown): number | null {
|
|
66
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function stringOrNull(value: unknown): string | null {
|
|
70
|
+
return typeof value === 'string' ? value : null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function roundMilliseconds(value: number): number {
|
|
74
|
+
return Math.round(value * 1000) / 1000;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function classTokens(value: unknown): string[] {
|
|
78
|
+
return typeof value === 'string' ? value.split(/\s+/).filter(Boolean) : [];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Recorder samples preserve tag/id/class labels and, where the identity
|
|
83
|
+
* bridge resolves it, backendNodeId. They do not preserve a DOM tree, so a
|
|
84
|
+
* full CSS selector cannot be evaluated after recording. This intentionally
|
|
85
|
+
* supports the label grammar that can be measured from that artifact and
|
|
86
|
+
* refuses unsupported combinators/attributes rather than pretending an
|
|
87
|
+
* arbitrary selector was matched.
|
|
88
|
+
*/
|
|
89
|
+
export function matchesRecordedSelector(element: TimelineRect, selector: string): boolean {
|
|
90
|
+
const trimmed = selector.trim();
|
|
91
|
+
if (!trimmed || /[\s>+~\[\]:,*]/.test(trimmed)) return false;
|
|
92
|
+
|
|
93
|
+
const match = /^(?<tag>[A-Za-z][A-Za-z0-9-]*)?(?<id>#[A-Za-z_][A-Za-z0-9_-]*)?(?<classes>(?:\.[A-Za-z_][A-Za-z0-9_-]*)*)$/.exec(trimmed);
|
|
94
|
+
if (!match?.groups) return false;
|
|
95
|
+
const tag = match.groups.tag;
|
|
96
|
+
const requestedId = match.groups.id?.slice(1);
|
|
97
|
+
const requestedClasses = [...(match.groups.classes?.matchAll(/\.([A-Za-z_][A-Za-z0-9_-]*)/g) ?? [])].map((m) => m[1]);
|
|
98
|
+
if (!tag && !requestedId && requestedClasses.length === 0) return false;
|
|
99
|
+
|
|
100
|
+
if (tag && stringOrNull(element.tag)?.toLowerCase() !== tag.toLowerCase()) return false;
|
|
101
|
+
if (requestedId && stringOrNull(element.id) !== requestedId) return false;
|
|
102
|
+
const classes = new Set(classTokens(element.classes));
|
|
103
|
+
return requestedClasses.every((name) => classes.has(name));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function elementLabel(element: TimelineRect): string {
|
|
107
|
+
return [stringOrNull(element.tag) ?? '', stringOrNull(element.id) ?? '', stringOrNull(element.classes) ?? ''].join('\u0000');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function propertyValue(element: TimelineRect, property: string): string | number | boolean | null | undefined {
|
|
111
|
+
const geometry: Record<string, unknown> = {
|
|
112
|
+
x: element.x,
|
|
113
|
+
y: element.y,
|
|
114
|
+
width: element.width,
|
|
115
|
+
height: element.height,
|
|
116
|
+
left: element.x,
|
|
117
|
+
top: element.y,
|
|
118
|
+
right: finiteNumber(element.x) !== null && finiteNumber(element.width) !== null ? finiteNumber(element.x)! + finiteNumber(element.width)! : undefined,
|
|
119
|
+
bottom: finiteNumber(element.y) !== null && finiteNumber(element.height) !== null ? finiteNumber(element.y)! + finiteNumber(element.height)! : undefined,
|
|
120
|
+
scrollTop: element.scrollTop,
|
|
121
|
+
scrollLeft: element.scrollLeft,
|
|
122
|
+
};
|
|
123
|
+
const direct = geometry[property];
|
|
124
|
+
if (typeof direct === 'string' || typeof direct === 'number' || typeof direct === 'boolean' || direct === null) return direct;
|
|
125
|
+
for (const container of [element.properties, element.computed]) {
|
|
126
|
+
if (container && typeof container === 'object') {
|
|
127
|
+
const value = (container as Record<string, unknown>)[property];
|
|
128
|
+
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' || value === null) return value;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Reads the finalized recording's sampled bounding boxes and produces the
|
|
135
|
+
* narrow, read-only timeline substrate used by the command leaf. */
|
|
136
|
+
export function analyzeMotionTimeline(ref: RecRef, selector: string, property?: string): TimelineAnalysis {
|
|
137
|
+
const records = readRects<TimelineFrameRecord>(ref);
|
|
138
|
+
// events.jsonl is a required finalized-recording artifact. Reading it here
|
|
139
|
+
// makes a missing/corrupt event stream a structured resolver error instead
|
|
140
|
+
// of silently treating a partial recording as complete.
|
|
141
|
+
const events = readEvents<Record<string, unknown>>(ref);
|
|
142
|
+
const markers = readMarkers<MarkerRecord>(ref);
|
|
143
|
+
|
|
144
|
+
const frames = records
|
|
145
|
+
.map((record, index) => ({
|
|
146
|
+
frame: finiteNumber(record.frame) ?? index,
|
|
147
|
+
timestamp: finiteNumber(record.screencastTimestamp),
|
|
148
|
+
elements: Array.isArray(record.elements) ? record.elements.filter((e): e is TimelineRect => typeof e === 'object' && e !== null) : [],
|
|
149
|
+
}))
|
|
150
|
+
.sort((a, b) => a.frame - b.frame);
|
|
151
|
+
|
|
152
|
+
let selected: TimelineRect | undefined;
|
|
153
|
+
for (const frame of frames) {
|
|
154
|
+
selected = frame.elements.find((element) => matchesRecordedSelector(element, selector));
|
|
155
|
+
if (selected) break;
|
|
156
|
+
}
|
|
157
|
+
if (!selected) {
|
|
158
|
+
throw new MotionTimelineSelectionError(selector, frames.length);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const selectedBackendNodeId = finiteNumber(selected.backendNodeId);
|
|
162
|
+
const selectionMethod: TimelineAnalysis['selectionMethod'] = selectedBackendNodeId === null ? 'sample-label' : 'backend-node-id';
|
|
163
|
+
const selectedLabel = elementLabel(selected);
|
|
164
|
+
const baseline = finiteNumber(markers.firstScreencastTimestampSec)
|
|
165
|
+
?? frames.find((frame) => frame.timestamp !== null)?.timestamp
|
|
166
|
+
?? null;
|
|
167
|
+
const timingDomain: TimelineAnalysis['timingDomain'] = baseline === null ? 'frame-order-only' : 'screencast-relative';
|
|
168
|
+
let propertyAvailable = property === undefined;
|
|
169
|
+
|
|
170
|
+
const points: TimelinePoint[] = [];
|
|
171
|
+
for (const frame of frames) {
|
|
172
|
+
const element = selectedBackendNodeId !== null
|
|
173
|
+
? frame.elements.find((candidate) => finiteNumber(candidate.backendNodeId) === selectedBackendNodeId)
|
|
174
|
+
: frame.elements.find((candidate) => elementLabel(candidate) === selectedLabel);
|
|
175
|
+
if (!element) continue;
|
|
176
|
+
const x = finiteNumber(element.x);
|
|
177
|
+
const y = finiteNumber(element.y);
|
|
178
|
+
const width = finiteNumber(element.width);
|
|
179
|
+
const height = finiteNumber(element.height);
|
|
180
|
+
if (x === null || y === null || width === null || height === null) continue;
|
|
181
|
+
|
|
182
|
+
const value = property === undefined ? undefined : propertyValue(element, property);
|
|
183
|
+
if (property !== undefined && value !== undefined) propertyAvailable = true;
|
|
184
|
+
points.push({
|
|
185
|
+
frame: frame.frame,
|
|
186
|
+
timeMs: baseline !== null && frame.timestamp !== null ? roundMilliseconds((frame.timestamp - baseline) * 1000) : null,
|
|
187
|
+
timestampUncertainty: '±frame',
|
|
188
|
+
elementId: finiteNumber(element.backendNodeId),
|
|
189
|
+
tag: stringOrNull(element.tag),
|
|
190
|
+
id: stringOrNull(element.id),
|
|
191
|
+
classes: stringOrNull(element.classes),
|
|
192
|
+
x,
|
|
193
|
+
y,
|
|
194
|
+
width,
|
|
195
|
+
height,
|
|
196
|
+
...(finiteNumber(element.scrollTop) !== null ? { scrollTop: finiteNumber(element.scrollTop)! } : {}),
|
|
197
|
+
...(finiteNumber(element.scrollLeft) !== null ? { scrollLeft: finiteNumber(element.scrollLeft)! } : {}),
|
|
198
|
+
...(property !== undefined && value !== undefined ? { property: { name: property, value } } : {}),
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return { points, selectedBackendNodeId, selectionMethod, frameCount: frames.length, eventsRead: events.length, propertyAvailable, timingDomain };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export class MotionTimelineSelectionError extends Error {
|
|
206
|
+
constructor(readonly selector: string, readonly frameCount: number) {
|
|
207
|
+
super(`No sampled element matched ${JSON.stringify(selector)} across ${frameCount} frame(s). Recorder timeline matching supports a tag, #id, .class, or their simple combination because rects.jsonl does not retain a DOM tree.`);
|
|
208
|
+
this.name = 'MotionTimelineSelectionError';
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function readTimelineMeta(ref: RecRef): { state: string; durationMs: number | null } {
|
|
213
|
+
const meta = readMeta<{ state?: unknown; durationMs?: unknown }>(ref);
|
|
214
|
+
return { state: typeof meta.state === 'string' ? meta.state : 'unknown', durationMs: finiteNumber(meta.durationMs) };
|
|
215
|
+
}
|