@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,33 @@
|
|
|
1
|
+
export type SnapStateName = 'hover' | 'focus' | 'active' | 'checked' | 'open' | 'disabled' | 'invalid';
|
|
2
|
+
export type SnapSelection =
|
|
3
|
+
| { readonly kind: 'fresh-url'; readonly requestedUrl: string; readonly requestedPort: number | null; readonly navigationTimeoutMs: number }
|
|
4
|
+
| { readonly kind: 'named-session'; readonly sessionId: string }
|
|
5
|
+
| { readonly kind: 'explicit-target'; readonly targetToken: string; readonly requestedPort: number | null }
|
|
6
|
+
| { readonly kind: 'active-session'; readonly scopeKey: string };
|
|
7
|
+
export interface SnapAcquisitionRequest { readonly selection: SnapSelection; readonly settleTimeoutMs: number; readonly freezeAnimations: boolean; readonly captureUnsettled: boolean; readonly pixels: boolean; readonly states: readonly { readonly name: SnapStateName; readonly selector: string | null }[]; readonly viewport: { readonly width: number; readonly height: number } | null; }
|
|
8
|
+
export interface SnapInputErrorShape { readonly code: string; readonly field?: string; readonly received: unknown; readonly expected: string; readonly next_action: 'capture measure snap -h'; }
|
|
9
|
+
export class SnapInputError extends Error { constructor(readonly detail: SnapInputErrorShape) { super(detail.code); this.name = 'SnapInputError'; } }
|
|
10
|
+
const states: readonly SnapStateName[] = ['hover', 'focus', 'active', 'checked', 'open', 'disabled', 'invalid'];
|
|
11
|
+
const invalid = (code: string, received: unknown, expected: string, field?: string): never => { throw new SnapInputError({ code, field, received, expected, next_action: 'capture measure snap -h' }); };
|
|
12
|
+
const positive = (value: string, field: string, max: number): number => /^\d+$/.test(value) && Number(value) >= 1 && Number(value) <= max ? Number(value) : invalid('invalid_option', value, `integer 1..${max}`, field);
|
|
13
|
+
/** Raw, side-effect-free argv parser. Ambient CDP variables intentionally never participate. */
|
|
14
|
+
export function parseSnapRawArgv(argv: readonly string[], env: Readonly<NodeJS.ProcessEnv> = process.env): SnapAcquisitionRequest {
|
|
15
|
+
const values = new Map<string, string[]>(); const positionals: string[] = []; const booleans = new Set<string>();
|
|
16
|
+
const takes = new Set(['--session', '--target', '--port', '--navigation-timeout', '--settle-timeout', '--viewport', '--state']); const flags = new Set([...takes, '--pixels', '--freeze-animations', '--capture-unsettled']);
|
|
17
|
+
for (let i = 0; i < argv.length; i++) { const token = argv[i]; if (!token.startsWith('--')) { positionals.push(token); continue; } if (!flags.has(token)) invalid('unknown_option', token, 'a measure snap option', token); if (takes.has(token)) { const value = argv[++i]; if (value === undefined || value.startsWith('--')) invalid('missing_option_value', token, `a value for ${token}`, token); const entries = values.get(token) ?? []; entries.push(value); values.set(token, entries); } else { if (booleans.has(token)) invalid('duplicate_option', token, 'one occurrence', token); booleans.add(token); } }
|
|
18
|
+
for (const [name, found] of values) if (name !== '--state' && found.length > 1) invalid('duplicate_option', found[1], `one ${name}`, name);
|
|
19
|
+
if (positionals.length > 1) invalid('unexpected_positional', positionals[1], 'at most one URL positional');
|
|
20
|
+
const one = (name: string): string | undefined => values.get(name)?.[0]; const session = one('--session'); const target = one('--target'); const portText = one('--port'); const url = positionals[0];
|
|
21
|
+
const port = portText === undefined ? null : positive(portText, '--port', 65535); const settleTimeoutMs = one('--settle-timeout') === undefined ? 1000 : positive(one('--settle-timeout')!, '--settle-timeout', 60000); const navigationTimeoutMs = one('--navigation-timeout') === undefined ? 10000 : positive(one('--navigation-timeout')!, '--navigation-timeout', 60000);
|
|
22
|
+
let viewport: { width: number; height: number } | null = null; if (one('--viewport')) { const match = /^(\d+)x(\d+)$/.exec(one('--viewport')!); if (!match) invalid('invalid_option', one('--viewport')!, 'WIDTHxHEIGHT', '--viewport'); viewport = { width: positive(match[1], '--viewport', 100000), height: positive(match[2], '--viewport', 100000) }; }
|
|
23
|
+
if (url && !/^https?:\/\//.test(url)) invalid('invalid_option', url, 'an absolute http: or https: URL', 'url');
|
|
24
|
+
if ((url ? 1 : 0) + (session ? 1 : 0) + (target ? 1 : 0) > 1) invalid('snapshot_target_conflict', { url, session, target }, 'exactly one target selection');
|
|
25
|
+
if (session && port !== null) invalid('port_conflicts_with_session', portText!, 'no --port with --session', '--port');
|
|
26
|
+
if (!url && !target && port !== null) invalid('port_requires_url_or_target', portText!, '--port with URL or --target', '--port');
|
|
27
|
+
if (one('--navigation-timeout') && !url) invalid('navigation_timeout_requires_fresh_url', one('--navigation-timeout')!, 'a fresh URL selection', '--navigation-timeout');
|
|
28
|
+
const rawStates = values.get('--state') ?? []; const selected: { name: SnapStateName; selector: string | null }[] = [];
|
|
29
|
+
for (const raw of rawStates) { const colon = raw.indexOf(':'); const name = (colon < 0 ? raw : raw.slice(0, colon)); const selector = colon < 0 ? null : raw.slice(colon + 1); if (name === 'all') { if (rawStates.length !== 1) invalid('snapshot_target_conflict', raw, '--state all alone', '--state'); for (const state of states) selected.push({ name: state, selector: null }); continue; } if (!(states as readonly string[]).includes(name) || selector === '') invalid('invalid_option', raw, 'STATE or STATE:SELECTOR', '--state'); selected.push({ name: name as SnapStateName, selector }); }
|
|
30
|
+
if (new Set(selected.map(s => `${s.name}\0${s.selector ?? ''}`)).size !== selected.length) invalid('duplicate_option', rawStates, 'unique state selections', '--state');
|
|
31
|
+
const selection: SnapSelection = url ? { kind: 'fresh-url', requestedUrl: url, requestedPort: port, navigationTimeoutMs } : session ? { kind: 'named-session', sessionId: session } : target ? { kind: 'explicit-target', targetToken: target, requestedPort: port } : { kind: 'active-session', scopeKey: env.CRTR_NODE_ID?.trim() || 'default' };
|
|
32
|
+
return { selection, settleTimeoutMs, freezeAnimations: booleans.has('--freeze-animations'), captureUnsettled: booleans.has('--capture-unsettled'), pixels: booleans.has('--pixels'), states: selected, viewport };
|
|
33
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import * as crypto from 'node:crypto';
|
|
2
|
+
import * as fs from 'node:fs';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import { CAPTURE_ROOT, DIR_MODE, ensurePrivateDir, writeJsonPrivate } from '../../../session/artifacts.js';
|
|
5
|
+
import { currentProcessStartIdentity } from '../../../output/artifact-lifecycle.js';
|
|
6
|
+
import { TargetIdentity, targetLockKey } from './target.js';
|
|
7
|
+
export interface ProcessOwner { readonly pid: number; readonly processStartIdentity: string | null; }
|
|
8
|
+
export interface ProcessLiveness { startIdentity(pid: number): string | null; }
|
|
9
|
+
const defaultLiveness: ProcessLiveness = { startIdentity: currentProcessStartIdentity };
|
|
10
|
+
/** Owner-attested target mutation lock. It has no elapsed-time staleness rule. */
|
|
11
|
+
export async function withTargetMutationLock<T>(identity: TargetIdentity, fn: () => Promise<T>, options: { readonly lockRoot?: string; readonly owner?: ProcessOwner; readonly liveness?: ProcessLiveness } = {}): Promise<T> {
|
|
12
|
+
const root = ensurePrivateDir(options.lockRoot ?? path.join(CAPTURE_ROOT, 'target-locks')); const lock = path.join(root, targetLockKey(identity)); const owner = options.owner ?? { pid: process.pid, processStartIdentity: currentProcessStartIdentity() }; const token = crypto.randomUUID(); const liveness = options.liveness ?? defaultLiveness;
|
|
13
|
+
for (;;) { try { fs.mkdirSync(lock, { mode: DIR_MODE }); writeJsonPrivate(path.join(lock, 'owner.json'), { ...owner, token }); break; } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error; let held: { pid?: unknown; processStartIdentity?: unknown } = {}; try { held = JSON.parse(fs.readFileSync(path.join(lock, 'owner.json'), 'utf8')); } catch { /* interrupted ownership is reclaimable */ } if (typeof held.pid === 'number' && typeof held.processStartIdentity === 'string' && liveness.startIdentity(held.pid) === held.processStartIdentity) throw new Error(`target lock is held for ${identity.fullTargetId}`); fs.rmSync(lock, { recursive: true, force: true }); } }
|
|
14
|
+
try { return await fn(); } finally { try { const held = JSON.parse(fs.readFileSync(path.join(lock, 'owner.json'), 'utf8')) as { token?: string }; if (held.token === token) fs.rmSync(lock, { recursive: true, force: true }); } catch { /* never delete a replacement owner */ } }
|
|
15
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import * as crypto from 'node:crypto';
|
|
2
|
+
export interface CdpEndpoint { readonly host: '127.0.0.1'; readonly port: number; }
|
|
3
|
+
export interface EndpointTarget { readonly endpoint: CdpEndpoint; readonly fullTargetId: string; readonly type: string; readonly url: string; readonly title: string; readonly attachable: boolean; readonly websocketUrl: string | null; }
|
|
4
|
+
export interface TargetIdentity extends CdpEndpoint { readonly fullTargetId: string; }
|
|
5
|
+
export interface ResolvedTarget { readonly identity: TargetIdentity; readonly observedUrl: string; readonly websocketUrl: string; }
|
|
6
|
+
export interface TargetSnapshotSource { discoverEndpoints(): Promise<readonly CdpEndpoint[]>; list(endpoint: CdpEndpoint): Promise<readonly EndpointTarget[]>; }
|
|
7
|
+
export class TargetResolutionError extends Error { constructor(readonly code: 'target_unavailable', message: string) { super(message); } }
|
|
8
|
+
export async function resolveExplicitTarget(token: string, port: number | null, source: TargetSnapshotSource): Promise<ResolvedTarget> {
|
|
9
|
+
const endpoints = (port === null ? await source.discoverEndpoints() : [{ host: '127.0.0.1' as const, port }]).slice().sort((a, b) => a.port - b.port);
|
|
10
|
+
const rows = (await Promise.all(endpoints.map(async endpoint => (await source.list(endpoint)).filter(row => row.endpoint.host === '127.0.0.1' && row.endpoint.port === endpoint.port && row.type === 'page' && row.attachable && typeof row.websocketUrl === 'string' && /^wss?:\/\//.test(row.websocketUrl))))).flat();
|
|
11
|
+
const choose = (candidates: readonly EndpointTarget[]): ResolvedTarget => { if (candidates.length !== 1) throw new TargetResolutionError('target_unavailable', `target ${JSON.stringify(token)} has ${candidates.length} endpoint-qualified page candidates`); const row = candidates[0]; return { identity: { host: '127.0.0.1', port: row.endpoint.port, fullTargetId: row.fullTargetId }, observedUrl: row.url, websocketUrl: row.websocketUrl! }; };
|
|
12
|
+
const exact = rows.filter(row => row.fullTargetId === token); if (exact.length) return choose(exact);
|
|
13
|
+
if (token.length < 4) throw new TargetResolutionError('target_unavailable', 'target prefixes require at least four characters');
|
|
14
|
+
return choose(rows.filter(row => row.fullTargetId.startsWith(token)));
|
|
15
|
+
}
|
|
16
|
+
export function targetLockKey(identity: TargetIdentity): string { return crypto.createHash('sha256').update(`127.0.0.1\0${identity.port}\0${identity.fullTargetId}`).digest('hex'); }
|
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
import type { RecRef } from '../../output/artifact.js';
|
|
2
|
+
import { readEvents, readMarkers, readMeta, readRects } from '../../output/artifact.js';
|
|
3
|
+
|
|
4
|
+
export interface Rect {
|
|
5
|
+
readonly x: number;
|
|
6
|
+
readonly y: number;
|
|
7
|
+
readonly w: number;
|
|
8
|
+
readonly h: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface JankFrame {
|
|
12
|
+
readonly frame: number;
|
|
13
|
+
readonly tMs: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface DroppedFrameFact {
|
|
17
|
+
readonly afterFrame: number;
|
|
18
|
+
readonly beforeFrame: number;
|
|
19
|
+
readonly startMs: number;
|
|
20
|
+
readonly endMs: number;
|
|
21
|
+
readonly intervalMs: number;
|
|
22
|
+
readonly estimatedDroppedFrames: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type LongTaskTimingDomain = 'recorder-performance' | 'trace-relative-first-event' | 'unavailable';
|
|
26
|
+
|
|
27
|
+
export interface LongTaskFact {
|
|
28
|
+
readonly source: 'observer' | 'trace';
|
|
29
|
+
readonly timingDomain: LongTaskTimingDomain;
|
|
30
|
+
/** Null when no synchronized recorder-relative observer baseline was retained. */
|
|
31
|
+
readonly startMs: number | null;
|
|
32
|
+
readonly durationMs: number;
|
|
33
|
+
/** Null when no synchronized recorder-relative observer baseline was retained. */
|
|
34
|
+
readonly endMs: number | null;
|
|
35
|
+
/** Null when task/frame timing is incomparable or unavailable. */
|
|
36
|
+
readonly overlapsDroppedFrames: readonly number[] | null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface LayoutShiftRectFact {
|
|
40
|
+
readonly elementId?: string;
|
|
41
|
+
readonly rect?: Rect;
|
|
42
|
+
readonly previousRect?: Rect;
|
|
43
|
+
readonly delta?: Rect;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type LayoutShiftAttribution = 'observer-sources' | 'frame-diff-inferred' | 'unavailable';
|
|
47
|
+
|
|
48
|
+
export interface LayoutShiftFact {
|
|
49
|
+
/** Null when no synchronized recorder-relative observer baseline was retained. */
|
|
50
|
+
readonly tMs: number | null;
|
|
51
|
+
readonly value: number;
|
|
52
|
+
readonly hadRecentInput?: boolean;
|
|
53
|
+
readonly attribution: LayoutShiftAttribution;
|
|
54
|
+
/** The two recorder-relative frame times used by a frame-diff inference. */
|
|
55
|
+
readonly beforeFrameMs?: number;
|
|
56
|
+
readonly afterFrameMs?: number;
|
|
57
|
+
readonly rects: readonly LayoutShiftRectFact[];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export type JankCount = 'dropped-frames' | 'long-task-records' | 'layout-shift-records';
|
|
61
|
+
|
|
62
|
+
export interface ArtifactLossFact {
|
|
63
|
+
readonly kind: string;
|
|
64
|
+
readonly count?: number;
|
|
65
|
+
readonly reason?: string;
|
|
66
|
+
readonly message?: string;
|
|
67
|
+
readonly affectedCounts: readonly JankCount[];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface MotionJankAnalysis {
|
|
71
|
+
readonly frameCount: number;
|
|
72
|
+
readonly cadenceMs: number | null;
|
|
73
|
+
readonly missingFrameSampleCount: number;
|
|
74
|
+
readonly droppedFrames: readonly DroppedFrameFact[];
|
|
75
|
+
readonly droppedFrameCount: number;
|
|
76
|
+
readonly droppedFramesIncomplete: boolean;
|
|
77
|
+
readonly longTasks: readonly LongTaskFact[];
|
|
78
|
+
readonly longTasksIncomplete: boolean;
|
|
79
|
+
readonly layoutShifts: readonly LayoutShiftFact[];
|
|
80
|
+
readonly layoutShiftsIncomplete: boolean;
|
|
81
|
+
readonly artifactLoss: readonly ArtifactLossFact[];
|
|
82
|
+
readonly frameTimestampUncertainty: '±frame' | 'unavailable';
|
|
83
|
+
readonly timingNote: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
interface Markers {
|
|
87
|
+
performanceNowMs?: unknown;
|
|
88
|
+
wallClockMs?: unknown;
|
|
89
|
+
firstTraceEventTsUs?: unknown;
|
|
90
|
+
/** An explicit synchronized trace/performance marker, if the recorder retained one. */
|
|
91
|
+
traceTimestampUs?: unknown;
|
|
92
|
+
tracePerformanceNowMs?: unknown;
|
|
93
|
+
baselinesPending?: unknown;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
type UnknownRecord = Record<string, unknown>;
|
|
97
|
+
|
|
98
|
+
function record(value: unknown): UnknownRecord | null {
|
|
99
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value) ? value as UnknownRecord : null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function finite(value: unknown): number | undefined {
|
|
103
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function rect(value: unknown): Rect | undefined {
|
|
107
|
+
const item = record(value);
|
|
108
|
+
if (!item) return undefined;
|
|
109
|
+
const x = finite(item.x ?? item.left);
|
|
110
|
+
const y = finite(item.y ?? item.top);
|
|
111
|
+
const w = finite(item.w ?? item.width);
|
|
112
|
+
const h = finite(item.h ?? item.height);
|
|
113
|
+
return x === undefined || y === undefined || w === undefined || h === undefined ? undefined : { x, y, w, h };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function delta(previous: Rect, current: Rect): Rect {
|
|
117
|
+
return { x: current.x - previous.x, y: current.y - previous.y, w: current.w - previous.w, h: current.h - previous.h };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Converts a native screencast timestamp into recorder-relative performance.now() milliseconds. */
|
|
121
|
+
export function screencastToPerformanceMs(timestampSec: number, markers: Markers): number | null {
|
|
122
|
+
const wallClockMs = finite(markers.wallClockMs);
|
|
123
|
+
if (wallClockMs === undefined) return null;
|
|
124
|
+
return timestampSec * 1000 - wallClockMs;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function observerToPerformanceMs(value: UnknownRecord, markers: Markers): number | null {
|
|
128
|
+
const performanceNow = finite(value.startTime ?? value.performanceNowMs ?? value.performanceNow);
|
|
129
|
+
const baseline = finite(markers.performanceNowMs);
|
|
130
|
+
if (performanceNow === undefined || baseline === undefined) return null;
|
|
131
|
+
return performanceNow - baseline;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function frameTime(value: UnknownRecord, markers: Markers): number | null {
|
|
135
|
+
const screencast = finite(value.screencastTimestamp ?? value.timestamp);
|
|
136
|
+
if (screencast !== undefined) return screencastToPerformanceMs(screencast, markers);
|
|
137
|
+
return observerToPerformanceMs(value, markers);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function flattenTraceEvents(events: readonly unknown[]): UnknownRecord[] {
|
|
141
|
+
const flattened: UnknownRecord[] = [];
|
|
142
|
+
for (const event of events) {
|
|
143
|
+
const item = record(event);
|
|
144
|
+
if (!item || item.kind !== 'trace' || !Array.isArray(item.events)) continue;
|
|
145
|
+
for (const traceEvent of item.events) {
|
|
146
|
+
const trace = record(traceEvent);
|
|
147
|
+
if (trace) flattened.push(trace);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return flattened;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function traceTime(timestampUs: number, markers: Markers, fallbackBaselineUs: number | undefined): { tMs: number; domain: LongTaskTimingDomain } | null {
|
|
154
|
+
const traceTimestampUs = finite(markers.traceTimestampUs);
|
|
155
|
+
const tracePerformanceNowMs = finite(markers.tracePerformanceNowMs);
|
|
156
|
+
const performanceNowMs = finite(markers.performanceNowMs);
|
|
157
|
+
if (traceTimestampUs !== undefined && tracePerformanceNowMs !== undefined && performanceNowMs !== undefined) {
|
|
158
|
+
return { tMs: (timestampUs - traceTimestampUs) / 1000 + tracePerformanceNowMs - performanceNowMs, domain: 'recorder-performance' };
|
|
159
|
+
}
|
|
160
|
+
const relativeBaselineUs = finite(markers.firstTraceEventTsUs) ?? fallbackBaselineUs;
|
|
161
|
+
return relativeBaselineUs === undefined ? null : { tMs: (timestampUs - relativeBaselineUs) / 1000, domain: 'trace-relative-first-event' };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function rectFactsFromObserverSources(event: UnknownRecord): LayoutShiftRectFact[] {
|
|
165
|
+
// Only PerformanceObserver LayoutShift.sources is element attribution. Other
|
|
166
|
+
// similarly-shaped event fields are not promoted to a source record.
|
|
167
|
+
if (!Array.isArray(event.sources)) return [];
|
|
168
|
+
const facts: LayoutShiftRectFact[] = [];
|
|
169
|
+
for (const raw of event.sources) {
|
|
170
|
+
const item = record(raw);
|
|
171
|
+
if (!item) continue;
|
|
172
|
+
const previousRect = rect(item.previousRect);
|
|
173
|
+
const currentRect = rect(item.currentRect);
|
|
174
|
+
const elementId = typeof item.backendNodeId === 'number' || typeof item.backendNodeId === 'string'
|
|
175
|
+
? String(item.backendNodeId)
|
|
176
|
+
: undefined;
|
|
177
|
+
if (previousRect || currentRect || elementId) facts.push({ elementId, rect: currentRect, previousRect, delta: previousRect && currentRect ? delta(previousRect, currentRect) : undefined });
|
|
178
|
+
}
|
|
179
|
+
return facts;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function inferredRectFacts(shiftAtMs: number, frames: readonly { tMs: number; elements: UnknownRecord[] }[]): { rects: LayoutShiftRectFact[]; beforeFrameMs?: number; afterFrameMs?: number } {
|
|
183
|
+
const before = [...frames].reverse().find((frame) => frame.tMs < shiftAtMs);
|
|
184
|
+
const after = frames.find((frame) => frame.tMs > shiftAtMs);
|
|
185
|
+
if (!before || !after) return { rects: [] };
|
|
186
|
+
const previousById = new Map<string, Rect>();
|
|
187
|
+
for (const element of before.elements) {
|
|
188
|
+
const id = element.backendNodeId ?? element.elementId;
|
|
189
|
+
const box = rect(element);
|
|
190
|
+
if ((typeof id === 'string' || typeof id === 'number') && box) previousById.set(String(id), box);
|
|
191
|
+
}
|
|
192
|
+
const rects: LayoutShiftRectFact[] = [];
|
|
193
|
+
for (const element of after.elements) {
|
|
194
|
+
const id = element.backendNodeId ?? element.elementId;
|
|
195
|
+
const currentRect = rect(element);
|
|
196
|
+
if ((typeof id !== 'string' && typeof id !== 'number') || !currentRect) continue;
|
|
197
|
+
const previousRect = previousById.get(String(id));
|
|
198
|
+
if (!previousRect) continue;
|
|
199
|
+
const changed = previousRect.x !== currentRect.x || previousRect.y !== currentRect.y || previousRect.w !== currentRect.w || previousRect.h !== currentRect.h;
|
|
200
|
+
if (changed) rects.push({ elementId: String(id), previousRect, rect: currentRect, delta: delta(previousRect, currentRect) });
|
|
201
|
+
}
|
|
202
|
+
return { rects, beforeFrameMs: before.tMs, afterFrameMs: after.tMs };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** The lower quartile keeps a frequent long interval from redefining the nominal frame cadence. */
|
|
206
|
+
function stableLowCadence(intervals: readonly number[]): number | null {
|
|
207
|
+
if (intervals.length < 2) return null;
|
|
208
|
+
const sorted = [...intervals].sort((a, b) => a - b);
|
|
209
|
+
return sorted[Math.floor((sorted.length - 1) * 0.25)] ?? null;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function missingFrameSamples(frames: readonly JankFrame[]): number {
|
|
213
|
+
let missing = 0;
|
|
214
|
+
for (let i = 1; i < frames.length; i++) {
|
|
215
|
+
missing += Math.max(0, frames[i].frame - frames[i - 1].frame - 1);
|
|
216
|
+
}
|
|
217
|
+
return missing;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** PerformanceObserver timestamps reset to a new document time origin after a navigation. Without a fresh synchronized baseline, records after the first navigation gap are deliberately withheld from the recorder-relative timeline. */
|
|
221
|
+
function observerRecords(events: readonly unknown[]): Array<{ item: UnknownRecord; timingAvailable: boolean }> {
|
|
222
|
+
const records: Array<{ item: UnknownRecord; timingAvailable: boolean }> = [];
|
|
223
|
+
let crossedNavigationGap = false;
|
|
224
|
+
for (const value of events) {
|
|
225
|
+
const item = record(value);
|
|
226
|
+
if (!item) continue;
|
|
227
|
+
if (item.kind === 'navigation-gap') {
|
|
228
|
+
crossedNavigationGap = true;
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
if (item.kind === 'performance') records.push({ item, timingAvailable: !crossedNavigationGap });
|
|
232
|
+
}
|
|
233
|
+
return records;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function artifactLoss(events: readonly unknown[]): ArtifactLossFact[] {
|
|
237
|
+
const facts: ArtifactLossFact[] = [];
|
|
238
|
+
for (const value of events) {
|
|
239
|
+
const event = record(value);
|
|
240
|
+
if (!event || typeof event.kind !== 'string') continue;
|
|
241
|
+
const count = finite(event.count);
|
|
242
|
+
const reason = typeof event.reason === 'string' ? event.reason : undefined;
|
|
243
|
+
const message = typeof event.message === 'string' ? event.message : undefined;
|
|
244
|
+
switch (event.kind) {
|
|
245
|
+
case 'trace-dropped':
|
|
246
|
+
facts.push({ kind: event.kind, count, reason, affectedCounts: ['long-task-records'] });
|
|
247
|
+
break;
|
|
248
|
+
case 'rect-sample-dropped':
|
|
249
|
+
// This is a count of rejected/truncated element facts within retained
|
|
250
|
+
// rect records, not a count of missing screencast frames or timestamps.
|
|
251
|
+
facts.push({ kind: event.kind, count, reason, affectedCounts: [] });
|
|
252
|
+
break;
|
|
253
|
+
case 'binding-dropped':
|
|
254
|
+
case 'navigation-gap':
|
|
255
|
+
facts.push({ kind: event.kind, count, reason, message, affectedCounts: ['long-task-records', 'layout-shift-records'] });
|
|
256
|
+
break;
|
|
257
|
+
case 'error': {
|
|
258
|
+
const lower = (message ?? '').toLowerCase();
|
|
259
|
+
const affectedCounts: JankCount[] = lower.includes('rect sample')
|
|
260
|
+
? ['dropped-frames']
|
|
261
|
+
: lower.includes('observer')
|
|
262
|
+
? ['long-task-records', 'layout-shift-records']
|
|
263
|
+
: lower.includes('trace')
|
|
264
|
+
? ['long-task-records']
|
|
265
|
+
: ['dropped-frames', 'long-task-records', 'layout-shift-records'];
|
|
266
|
+
facts.push({ kind: event.kind, count, reason, message, affectedCounts });
|
|
267
|
+
break;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return facts;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Pure artifact analysis, deliberately accepting the recorder's NDJSON records directly for fixture use. */
|
|
275
|
+
export function analyzeMotionJank(input: { rects: readonly unknown[]; events: readonly unknown[]; markers: Markers; state?: string }): MotionJankAnalysis {
|
|
276
|
+
const frameRecords = input.rects
|
|
277
|
+
.map(record)
|
|
278
|
+
.filter((item): item is UnknownRecord => item !== null)
|
|
279
|
+
.map((item) => ({ item, tMs: frameTime(item, input.markers) }))
|
|
280
|
+
.filter((item): item is { item: UnknownRecord; tMs: number } => item.tMs !== null)
|
|
281
|
+
.sort((a, b) => a.tMs - b.tMs);
|
|
282
|
+
const frames: JankFrame[] = frameRecords.map(({ item, tMs }, index) => ({ frame: finite(item.frame) ?? index, tMs }));
|
|
283
|
+
const intervals = frames.slice(1).map((frame, index) => frame.tMs - frames[index].tMs).filter((interval) => interval > 0);
|
|
284
|
+
const cadenceMs = stableLowCadence(intervals);
|
|
285
|
+
const missingFrameSampleCount = missingFrameSamples(frames);
|
|
286
|
+
const droppedFrames: DroppedFrameFact[] = [];
|
|
287
|
+
if (cadenceMs !== null && cadenceMs > 0) {
|
|
288
|
+
for (let i = 1; i < frames.length; i++) {
|
|
289
|
+
const intervalMs = frames[i].tMs - frames[i - 1].tMs;
|
|
290
|
+
const estimatedDroppedFrames = Math.max(0, Math.round(intervalMs / cadenceMs) - 1);
|
|
291
|
+
if (estimatedDroppedFrames > 0) droppedFrames.push({ afterFrame: frames[i].frame, beforeFrame: frames[i - 1].frame, startMs: frames[i - 1].tMs, endMs: frames[i].tMs, intervalMs, estimatedDroppedFrames });
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const traceEvents = flattenTraceEvents(input.events);
|
|
296
|
+
const observedTraceBaselineUs = traceEvents.map((event) => finite(event.ts)).filter((ts): ts is number => ts !== undefined).sort((a, b) => a - b)[0];
|
|
297
|
+
const observerEntries = observerRecords(input.events);
|
|
298
|
+
// The two distinct causes of unavailable observer timing, tracked separately so the
|
|
299
|
+
// user-visible note attributes each factually instead of blaming navigation for both.
|
|
300
|
+
let observerNavGapUnavailable = false; // null recorder-relative time: no synchronized baseline (post-navigation-gap).
|
|
301
|
+
let observerPreArmUnavailable = false; // negative recorder-relative time: buffered entry whose document timestamp predates the arm baseline.
|
|
302
|
+
const longTasks: LongTaskFact[] = [];
|
|
303
|
+
for (const { item, timingAvailable } of observerEntries) {
|
|
304
|
+
if (item.entryType !== 'longtask') continue;
|
|
305
|
+
const durationMs = finite(item.duration);
|
|
306
|
+
if (durationMs === undefined) continue;
|
|
307
|
+
// A negative recorder-relative start comes from a buffered entry whose document
|
|
308
|
+
// time origin predates the recorder arm baseline; it has no valid recorder-relative
|
|
309
|
+
// baseline, so route it (like a null or post-navigation-gap start) to 'unavailable'
|
|
310
|
+
// rather than emitting a negative time or silently dropping it.
|
|
311
|
+
const startMs = timingAvailable ? observerToPerformanceMs(item, input.markers) : null;
|
|
312
|
+
if (startMs === null || startMs < 0) {
|
|
313
|
+
if (startMs === null) observerNavGapUnavailable = true;
|
|
314
|
+
else observerPreArmUnavailable = true;
|
|
315
|
+
longTasks.push({ source: 'observer', timingDomain: 'unavailable', startMs: null, durationMs, endMs: null, overlapsDroppedFrames: null });
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
longTasks.push(longTask('observer', 'recorder-performance', startMs, durationMs, droppedFrames));
|
|
319
|
+
}
|
|
320
|
+
for (const trace of traceEvents) {
|
|
321
|
+
const name = typeof trace.name === 'string' ? trace.name : '';
|
|
322
|
+
const durationMs = finite(trace.dur);
|
|
323
|
+
const timestampUs = finite(trace.ts);
|
|
324
|
+
if (!/(longtask|run(task|microtasks)|task)/i.test(name) || durationMs === undefined || durationMs < 50_000 || timestampUs === undefined) continue;
|
|
325
|
+
const timing = traceTime(timestampUs, input.markers, observedTraceBaselineUs);
|
|
326
|
+
if (timing !== null) longTasks.push(longTask('trace', timing.domain, timing.tMs, durationMs / 1000, droppedFrames));
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const frameElementRecords = frameRecords.map(({ item, tMs }) => ({ tMs, elements: (Array.isArray(item.elements) ? item.elements : []).map(record).filter((v): v is UnknownRecord => v !== null) }));
|
|
330
|
+
const layoutShifts: LayoutShiftFact[] = [];
|
|
331
|
+
for (const { item, timingAvailable } of observerEntries) {
|
|
332
|
+
if (item.entryType !== 'layout-shift') continue;
|
|
333
|
+
const value = finite(item.value);
|
|
334
|
+
if (value === undefined) continue;
|
|
335
|
+
// A negative recorder-relative time comes from a buffered entry whose document
|
|
336
|
+
// time origin predates the recorder arm baseline; it has no valid recorder-relative
|
|
337
|
+
// baseline, so route it (like a null or post-navigation-gap time) to 'unavailable'
|
|
338
|
+
// rather than emitting a negative time or silently dropping it.
|
|
339
|
+
const tMs = timingAvailable ? observerToPerformanceMs(item, input.markers) : null;
|
|
340
|
+
if (tMs === null || tMs < 0) {
|
|
341
|
+
if (tMs === null) observerNavGapUnavailable = true;
|
|
342
|
+
else observerPreArmUnavailable = true;
|
|
343
|
+
layoutShifts.push({
|
|
344
|
+
tMs: null,
|
|
345
|
+
value,
|
|
346
|
+
hadRecentInput: typeof item.hadRecentInput === 'boolean' ? item.hadRecentInput : undefined,
|
|
347
|
+
attribution: 'unavailable',
|
|
348
|
+
rects: [],
|
|
349
|
+
});
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
const explicit = rectFactsFromObserverSources(item);
|
|
353
|
+
const inferred = explicit.length ? undefined : inferredRectFacts(tMs, frameElementRecords);
|
|
354
|
+
layoutShifts.push({
|
|
355
|
+
tMs,
|
|
356
|
+
value,
|
|
357
|
+
hadRecentInput: typeof item.hadRecentInput === 'boolean' ? item.hadRecentInput : undefined,
|
|
358
|
+
attribution: explicit.length ? 'observer-sources' : inferred?.rects.length ? 'frame-diff-inferred' : 'unavailable',
|
|
359
|
+
beforeFrameMs: inferred?.beforeFrameMs,
|
|
360
|
+
afterFrameMs: inferred?.afterFrameMs,
|
|
361
|
+
rects: explicit.length ? explicit : inferred?.rects ?? [],
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const losses = artifactLoss(input.events);
|
|
366
|
+
if (input.state === 'orphaned-finalized') {
|
|
367
|
+
losses.push({
|
|
368
|
+
kind: 'orphaned-finalized',
|
|
369
|
+
message: 'recorder was finalized best-effort from artifacts already flushed to disk',
|
|
370
|
+
affectedCounts: ['dropped-frames', 'long-task-records', 'layout-shift-records'],
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
const incomplete = new Set<JankCount>(losses.flatMap((loss) => loss.affectedCounts));
|
|
374
|
+
if (missingFrameSampleCount > 0 || cadenceMs === null) incomplete.add('dropped-frames');
|
|
375
|
+
const traceAligned = longTasks.some((task) => task.source === 'trace' && task.timingDomain === 'recorder-performance');
|
|
376
|
+
const traceRelative = longTasks.some((task) => task.source === 'trace' && task.timingDomain === 'trace-relative-first-event');
|
|
377
|
+
const frameTimestampUncertainty: '±frame' | 'unavailable' = frames.length ? '±frame' : 'unavailable';
|
|
378
|
+
// Attribute each unavailable-timing cause factually. A navigation gap and a pre-arm
|
|
379
|
+
// buffered entry both route to 'unavailable', but only the former involves a navigation;
|
|
380
|
+
// asserting navigation for a pre-arm entry would be false provenance.
|
|
381
|
+
const observerUnavailableCauses: string[] = [];
|
|
382
|
+
if (observerNavGapUnavailable) observerUnavailableCauses.push('some occurred after a navigation gap with no synchronized recorder-relative baseline');
|
|
383
|
+
if (observerPreArmUnavailable) observerUnavailableCauses.push('some were buffered from before the recorder arm baseline, with a document timestamp that predates it');
|
|
384
|
+
const observerTiming = observerUnavailableCauses.length
|
|
385
|
+
? `Observer entries with unavailable timing (${observerUnavailableCauses.join('; ')}) have no recorder-relative baseline; their timing, dropped-frame overlap, and frame-diff attribution are unavailable.`
|
|
386
|
+
: 'Observer and screencast timestamps are recorder-relative performance.now() milliseconds.';
|
|
387
|
+
const timingNote = traceRelative
|
|
388
|
+
? `${observerTiming} Frame-derived intervals have ±frame uncertainty. Trace timestamps are relative to the first trace event because no explicit trace/performance baseline marker was retained.`
|
|
389
|
+
: traceAligned
|
|
390
|
+
? `${observerTiming} Explicitly-baselined trace and screencast timestamps are recorder-relative performance.now() milliseconds; frame-derived intervals have ±frame uncertainty.`
|
|
391
|
+
: frames.length
|
|
392
|
+
? `${observerTiming} Frame-derived intervals have ±frame uncertainty.`
|
|
393
|
+
: `${observerTiming} No frame timestamps were available; screencast-derived timing is unavailable.`;
|
|
394
|
+
|
|
395
|
+
return {
|
|
396
|
+
frameCount: frames.length,
|
|
397
|
+
cadenceMs,
|
|
398
|
+
missingFrameSampleCount,
|
|
399
|
+
droppedFrames,
|
|
400
|
+
droppedFrameCount: droppedFrames.reduce((count, item) => count + item.estimatedDroppedFrames, 0),
|
|
401
|
+
droppedFramesIncomplete: incomplete.has('dropped-frames'),
|
|
402
|
+
longTasks,
|
|
403
|
+
longTasksIncomplete: incomplete.has('long-task-records'),
|
|
404
|
+
layoutShifts,
|
|
405
|
+
layoutShiftsIncomplete: incomplete.has('layout-shift-records'),
|
|
406
|
+
artifactLoss: losses,
|
|
407
|
+
frameTimestampUncertainty,
|
|
408
|
+
timingNote,
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function longTask(source: 'observer' | 'trace', timingDomain: LongTaskTimingDomain, startMs: number, durationMs: number, droppedFrames: readonly DroppedFrameFact[]): LongTaskFact {
|
|
413
|
+
const endMs = startMs + durationMs;
|
|
414
|
+
return {
|
|
415
|
+
source,
|
|
416
|
+
timingDomain,
|
|
417
|
+
startMs,
|
|
418
|
+
durationMs,
|
|
419
|
+
endMs,
|
|
420
|
+
overlapsDroppedFrames: timingDomain === 'recorder-performance'
|
|
421
|
+
? droppedFrames.filter((drop) => startMs < drop.endMs && endMs > drop.startMs).map((drop) => drop.afterFrame)
|
|
422
|
+
: null,
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
export function readMotionJank(ref: RecRef): { analysis: MotionJankAnalysis; meta: Record<string, unknown> } {
|
|
427
|
+
const meta = readMeta<Record<string, unknown>>(ref);
|
|
428
|
+
const state = typeof meta.state === 'string' ? meta.state : undefined;
|
|
429
|
+
if (state !== 'finalized' && state !== 'orphaned-finalized') {
|
|
430
|
+
throw new Error(`recording ${JSON.stringify(ref.id)} is state ${JSON.stringify(state ?? 'unknown')}, not finalized; finalize it with: capture motion rec --stop`);
|
|
431
|
+
}
|
|
432
|
+
return { analysis: analyzeMotionJank({ rects: readRects(ref), events: readEvents(ref), markers: readMarkers<Markers>(ref), state }), meta };
|
|
433
|
+
}
|