@crouton-kit/capture 1.4.2 → 1.4.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +178 -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,123 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import { stopBridge } from '../bridge/spawn.js';
|
|
4
|
+
import { processPidBirthProvider, readPrivateFile, removeArtifactTree, sameBirth, unlinkPrivateFile, writeJsonPrivate } from '../../session/artifacts.js';
|
|
5
|
+
import { collectorKind, COLLECTOR_KINDS } from './kinds.js';
|
|
6
|
+
import { sendHostRequest } from './client.js';
|
|
7
|
+
import { scanCollectorHost, type CollectorHostHandle } from './handle.js';
|
|
8
|
+
import type { CollectorKind, TeardownOutcome } from './collector.js';
|
|
9
|
+
|
|
10
|
+
export type CollectorHostTeardown =
|
|
11
|
+
| { status: 'absent' }
|
|
12
|
+
| { status: 'drained'; outcomes: readonly TeardownOutcome[] }
|
|
13
|
+
| { status: 'reaped'; outcomes: readonly TeardownOutcome[] }
|
|
14
|
+
| { status: 'terminal'; error: string };
|
|
15
|
+
|
|
16
|
+
export class TerminalCollectorHostStopFailure extends Error {
|
|
17
|
+
constructor(readonly outcome: Extract<CollectorHostTeardown, { status: 'terminal' }>) {
|
|
18
|
+
super(`collector host teardown failed: ${outcome.error}`);
|
|
19
|
+
this.name = 'TerminalCollectorHostStopFailure';
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function isTerminalCollectorHostStopFailure(error: unknown): error is TerminalCollectorHostStopFailure {
|
|
24
|
+
return error instanceof TerminalCollectorHostStopFailure;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function isCollectorKind(value: unknown): value is CollectorKind {
|
|
28
|
+
return typeof value === 'string' && value in COLLECTOR_KINDS;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function readCollecting(dir: string): { id: string; kind: CollectorKind; startedAt: string } | null {
|
|
32
|
+
try {
|
|
33
|
+
const raw = JSON.parse(readPrivateFile(path.join(dir, 'collecting.json')).toString('utf8')) as Record<string, unknown>;
|
|
34
|
+
if (typeof raw.id !== 'string' || !isCollectorKind(raw.kind) || typeof raw.startedAt !== 'string') return null;
|
|
35
|
+
return { id: raw.id, kind: raw.kind, startedAt: raw.startedAt };
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function collectingDirectories(root: string): string[] {
|
|
42
|
+
const dirs: string[] = [];
|
|
43
|
+
const walk = (dir: string): void => {
|
|
44
|
+
let entries: fs.Dirent[];
|
|
45
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
46
|
+
if (entries.some(entry => entry.isFile() && entry.name === 'collecting.json')) dirs.push(dir);
|
|
47
|
+
for (const entry of entries) if (entry.isDirectory()) walk(path.join(dir, entry.name));
|
|
48
|
+
};
|
|
49
|
+
walk(root);
|
|
50
|
+
return dirs;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function reapOrphanedCollectors(sessionDir: string): TeardownOutcome[] {
|
|
54
|
+
const outcomes: TeardownOutcome[] = [];
|
|
55
|
+
for (const dir of collectingDirectories(sessionDir)) {
|
|
56
|
+
const collecting = readCollecting(dir);
|
|
57
|
+
if (!collecting) continue;
|
|
58
|
+
try {
|
|
59
|
+
const reconstructed = collectorKind(collecting.kind).reconstruct(dir);
|
|
60
|
+
const summary = reconstructed.summary && typeof reconstructed.summary === 'object' && !Array.isArray(reconstructed.summary)
|
|
61
|
+
? reconstructed.summary as Record<string, unknown>
|
|
62
|
+
: { value: reconstructed.summary };
|
|
63
|
+
writeJsonPrivate(path.join(dir, 'meta.json'), {
|
|
64
|
+
id: collecting.id,
|
|
65
|
+
kind: collecting.kind,
|
|
66
|
+
completion: 'orphaned',
|
|
67
|
+
reason: 'host_died',
|
|
68
|
+
startedAt: collecting.startedAt,
|
|
69
|
+
endedAt: new Date().toISOString(),
|
|
70
|
+
files: reconstructed.files,
|
|
71
|
+
summary,
|
|
72
|
+
...summary,
|
|
73
|
+
});
|
|
74
|
+
unlinkPrivateFile(path.join(dir, 'collecting.json'));
|
|
75
|
+
outcomes.push({ status: 'reaped', id: collecting.id, kind: collecting.kind, dir });
|
|
76
|
+
} catch (error) {
|
|
77
|
+
outcomes.push({ status: 'terminal', id: collecting.id, kind: collecting.kind, dir, error: error instanceof Error ? error.message : String(error) });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return outcomes;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function waitForHostExit(handle: CollectorHostHandle, timeoutMs = 2_000): Promise<void> {
|
|
84
|
+
const deadline = Date.now() + timeoutMs;
|
|
85
|
+
for (;;) {
|
|
86
|
+
const observed = processPidBirthProvider.read(handle.pid);
|
|
87
|
+
if (observed.status === 'absent' || (observed.status === 'found' && !sameBirth(observed.identity, handle.birth))) return;
|
|
88
|
+
if (Date.now() >= deadline) {
|
|
89
|
+
stopBridge(handle.pid, handle.socketPath);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
await new Promise(resolve => setTimeout(resolve, 25));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function terminal(error: string): CollectorHostTeardown {
|
|
97
|
+
return { status: 'terminal', error };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Stops a session's host before immutable bundle construction. A terminal result means the caller must not commit. */
|
|
101
|
+
export async function stopAndReapCollectorHostAtSessionStop(sessionDir: string): Promise<CollectorHostTeardown> {
|
|
102
|
+
const scanned = scanCollectorHost(sessionDir);
|
|
103
|
+
if (scanned.classification === 'absent') return { status: 'absent' };
|
|
104
|
+
if (!scanned.handle) return terminal(`collector host handle is ${scanned.classification}`);
|
|
105
|
+
if (scanned.classification === 'unknown') return terminal('collector host liveness is unknown');
|
|
106
|
+
if (scanned.classification === 'dead') {
|
|
107
|
+
const outcomes = reapOrphanedCollectors(sessionDir);
|
|
108
|
+
removeArtifactTree(scanned.path);
|
|
109
|
+
const failure = outcomes.find(outcome => outcome.status === 'terminal');
|
|
110
|
+
return failure ? terminal(failure.error) : { status: 'reaped', outcomes };
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
const response = await sendHostRequest(scanned.handle.socketPath, { type: 'session-teardown', nonce: scanned.handle.nonce }, 30_000);
|
|
114
|
+
if (!response.ok) return terminal(response.error ?? 'collector host refused session teardown');
|
|
115
|
+
const outcomes = Array.isArray(response.outcomes) ? response.outcomes as TeardownOutcome[] : [];
|
|
116
|
+
const failure = outcomes.find(outcome => outcome.status === 'terminal');
|
|
117
|
+
if (failure) return terminal(failure.error);
|
|
118
|
+
await waitForHostExit(scanned.handle);
|
|
119
|
+
return { status: 'drained', outcomes };
|
|
120
|
+
} catch (error) {
|
|
121
|
+
return terminal(error instanceof Error ? error.message : String(error));
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import * as crypto from 'node:crypto';
|
|
2
|
+
import * as fs from 'node:fs';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import { CDPClient } from '../client.js';
|
|
5
|
+
import { findTabByIdAcrossEndpoints } from '../targets.js';
|
|
6
|
+
import { closeNdjsonSocket, installProcessCleanup, listenNdjsonSocket } from '../bridge/server.js';
|
|
7
|
+
import { collectorHostSocketPath } from '../bridge/spawn.js';
|
|
8
|
+
import { processPidBirthProvider, removeArtifactTree, writeJsonPrivate } from '../../session/artifacts.js';
|
|
9
|
+
import { readSessionState } from '../../session-context.js';
|
|
10
|
+
import { applyActiveSessionNetworkConditions } from '../connection.js';
|
|
11
|
+
import { CollectorHost, type HostSnapshot } from './core.js';
|
|
12
|
+
import type { CollectorKind } from './collector.js';
|
|
13
|
+
|
|
14
|
+
export interface RunCollectorHostOptions { socketPath: string; sessionDir: string; targetId: string; port: number; }
|
|
15
|
+
|
|
16
|
+
function sameNonce(expected: string, actual: unknown): boolean {
|
|
17
|
+
if (typeof actual !== 'string') return false;
|
|
18
|
+
const a = Buffer.from(expected); const b = Buffer.from(actual);
|
|
19
|
+
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function runCollectorHost(options: RunCollectorHostOptions): Promise<void> {
|
|
23
|
+
if (options.socketPath !== collectorHostSocketPath(options.sessionDir)) throw new Error('collector host socket path must match its session');
|
|
24
|
+
const target = await findTabByIdAcrossEndpoints(options.targetId, options.port);
|
|
25
|
+
if (!target?.tab.webSocketDebuggerUrl) throw new Error(`No tab found for target "${options.targetId}" on port ${options.port}.`);
|
|
26
|
+
const birth = processPidBirthProvider.read(process.pid);
|
|
27
|
+
if (birth.status !== 'found') throw new Error('could not establish collector host process identity');
|
|
28
|
+
const nonce = crypto.randomBytes(32).toString('hex');
|
|
29
|
+
const client = new CDPClient(target.tab.webSocketDebuggerUrl);
|
|
30
|
+
await client.waitReady();
|
|
31
|
+
const session = readSessionState(options.sessionDir);
|
|
32
|
+
if (session.targetId !== options.targetId) throw new Error('collector host target does not match session state');
|
|
33
|
+
await applyActiveSessionNetworkConditions(client, session, options.targetId);
|
|
34
|
+
let server: import('node:net').Server | undefined;
|
|
35
|
+
const handlePath = path.join(options.sessionDir, '.collector-host.json');
|
|
36
|
+
const publish = (snapshot: HostSnapshot): void => writeJsonPrivate(handlePath, {
|
|
37
|
+
pid: process.pid, birth: birth.identity, socketPath: options.socketPath, targetId: options.targetId, nonce, startedAt: new Date().toISOString(), ...snapshot,
|
|
38
|
+
});
|
|
39
|
+
const cleanup = (): void => {
|
|
40
|
+
if (server) closeNdjsonSocket(server, options.socketPath);
|
|
41
|
+
try { client.close(); } catch {}
|
|
42
|
+
};
|
|
43
|
+
const host = new CollectorHost(client, options.sessionDir, { pid: process.pid, birth: birth.identity, targetId: options.targetId }, publish, () => {
|
|
44
|
+
cleanup();
|
|
45
|
+
try { removeArtifactTree(handlePath); } catch {}
|
|
46
|
+
});
|
|
47
|
+
publish(host.snapshot());
|
|
48
|
+
server = await listenNdjsonSocket(options.socketPath, async (line, socket) => {
|
|
49
|
+
let request: Record<string, unknown>;
|
|
50
|
+
try { request = JSON.parse(line) as Record<string, unknown>; } catch { return; }
|
|
51
|
+
const reqId = typeof request.reqId === 'number' ? request.reqId : 0;
|
|
52
|
+
const type = typeof request.type === 'string' ? request.type : 'cdp';
|
|
53
|
+
const answer = (body: Record<string, unknown>): void => { socket.write(JSON.stringify({ reqId, type, ...body }) + '\n'); };
|
|
54
|
+
if (!sameNonce(nonce, request.nonce)) { answer({ ok: false, error: 'unauthorized' }); return; }
|
|
55
|
+
try {
|
|
56
|
+
switch (type) {
|
|
57
|
+
case 'host-status': answer({ ok: true, ...host.snapshot() }); return;
|
|
58
|
+
case 'claim-reserve': {
|
|
59
|
+
const claims = Array.isArray(request.claims) ? request.claims as never : [];
|
|
60
|
+
const reservation = host.reserve(claims, String(request.holderLabel ?? 'external command'), Number(request.pid), request.birth as never, request.exclusive === true);
|
|
61
|
+
answer({ ok: true, reservation }); return;
|
|
62
|
+
}
|
|
63
|
+
case 'claim-release': host.releaseReservation(String(request.token ?? '')); answer({ ok: true }); return;
|
|
64
|
+
case 'collector-start': {
|
|
65
|
+
const row = await host.start(request.kind as CollectorKind, request.config);
|
|
66
|
+
answer({ ok: true, collector: row }); return;
|
|
67
|
+
}
|
|
68
|
+
case 'collector-stop': answer({ ok: true, ...(await host.stop(String(request.id ?? ''))) }); return;
|
|
69
|
+
case 'session-teardown': answer({ ok: true, outcomes: await host.teardown('session-stop') }); cleanup(); return;
|
|
70
|
+
case 'har-flush': await host.control({ type: 'har-flush' }); answer({ ok: true }); return;
|
|
71
|
+
case 'cdp': {
|
|
72
|
+
const dispatched = await host.dispatch(
|
|
73
|
+
String(request.method ?? ''),
|
|
74
|
+
request.params as Record<string, unknown> ?? {},
|
|
75
|
+
typeof request.annotation === 'string' ? request.annotation : undefined,
|
|
76
|
+
typeof request.waitEvent === 'string' ? request.waitEvent : undefined,
|
|
77
|
+
typeof request.timeoutMs === 'number' ? request.timeoutMs : undefined,
|
|
78
|
+
request.observeDocumentResponse === true,
|
|
79
|
+
) as { result?: unknown; event?: unknown; waitOutcome?: unknown; documentResponse?: unknown };
|
|
80
|
+
answer({ ok: true, ...(dispatched && typeof dispatched === 'object' ? dispatched as Record<string, unknown> : { result: dispatched }) });
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
default: answer({ ok: false, error: `unknown collector host request: ${type}` }); return;
|
|
84
|
+
}
|
|
85
|
+
} catch (error) { answer({ ok: false, error: error instanceof Error ? error.message : String(error) }); }
|
|
86
|
+
});
|
|
87
|
+
installProcessCleanup(() => { host.abandon(); cleanup(); }, client);
|
|
88
|
+
}
|
package/src/cdp/measure/check.ts
CHANGED
|
@@ -12,6 +12,7 @@ export type CheckName = (typeof CHECK_NAMES)[number];
|
|
|
12
12
|
export interface CheckFinding {
|
|
13
13
|
readonly kind: CheckName;
|
|
14
14
|
readonly elementId?: string;
|
|
15
|
+
readonly backendNodeId?: number;
|
|
15
16
|
readonly selector?: string;
|
|
16
17
|
readonly rect?: Rect;
|
|
17
18
|
readonly detail: string;
|
|
@@ -214,19 +215,166 @@ function readRequired<T>(ref: SnapRef, filename: string): T {
|
|
|
214
215
|
}
|
|
215
216
|
|
|
216
217
|
function finding(kind: CheckName, element: GeometryElement | undefined, detail: string, provenance?: string): Omit<CheckFinding, 'caveats'> {
|
|
217
|
-
return { kind, elementId: element?.id, selector: element?.selector, rect: element ? rectOf(element.rect) : undefined, detail, provenance };
|
|
218
|
+
return { kind, elementId: element?.id, backendNodeId: element?.backendNodeId ?? undefined, selector: element?.selector, rect: element ? rectOf(element.rect) : undefined, detail, provenance };
|
|
218
219
|
}
|
|
219
220
|
|
|
220
|
-
function
|
|
221
|
-
|
|
222
|
-
return
|
|
221
|
+
function isSelfOrDescendant(target: GeometryElement, receiver: GeometryElement): boolean {
|
|
222
|
+
if (target.backendNodeId != null && target.backendNodeId === receiver.backendNodeId) return true;
|
|
223
|
+
return target.domPath !== undefined && receiver.domPath !== undefined && receiver.domPath.startsWith(`${target.domPath}/`);
|
|
223
224
|
}
|
|
225
|
+
|
|
226
|
+
interface Rgba { readonly red: number; readonly green: number; readonly blue: number; readonly alpha: number }
|
|
227
|
+
interface StyleElement { readonly selector?: string; readonly backendNodeId?: number | null; readonly computed?: Record<string, string | null> }
|
|
228
|
+
|
|
229
|
+
function bounded(value: number): number { return Math.min(1, Math.max(0, value)); }
|
|
230
|
+
|
|
231
|
+
function rgbChannel(token: string): number {
|
|
232
|
+
const value = token.trim();
|
|
233
|
+
return value.endsWith('%') ? Number(value.slice(0, -1)) * 2.55 : Number(value);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function fromOklab(lightness: number, a: number, b: number, alpha: number): Rgba | undefined {
|
|
237
|
+
if (![lightness, a, b, alpha].every(Number.isFinite) || alpha < 0 || alpha > 1) return undefined;
|
|
238
|
+
const l = (lightness + 0.3963377774 * a + 0.2158037573 * b) ** 3;
|
|
239
|
+
const m = (lightness - 0.1055613458 * a - 0.0638541728 * b) ** 3;
|
|
240
|
+
const s = (lightness - 0.0894841775 * a - 1.2914855480 * b) ** 3;
|
|
241
|
+
const linear = [4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s];
|
|
242
|
+
const channel = (value: number): number => 255 * (value <= 0.0031308 ? 12.92 * bounded(value) : 1.055 * bounded(value) ** (1 / 2.4) - 0.055);
|
|
243
|
+
return { red: channel(linear[0]!), green: channel(linear[1]!), blue: channel(linear[2]!), alpha };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function rgba(value: string | null | undefined): Rgba | undefined {
|
|
247
|
+
const input = value?.trim().toLowerCase();
|
|
248
|
+
if (!input) return undefined;
|
|
249
|
+
if (input === 'transparent') return { red: 0, green: 0, blue: 0, alpha: 0 };
|
|
250
|
+
const hex = /^#([\da-f]{3,4}|[\da-f]{6}|[\da-f]{8})$/i.exec(input);
|
|
251
|
+
if (hex) {
|
|
252
|
+
const digits = hex[1]!.length <= 4 ? hex[1]!.split('').map((digit) => digit + digit).join('') : hex[1]!;
|
|
253
|
+
return { red: parseInt(digits.slice(0, 2), 16), green: parseInt(digits.slice(2, 4), 16), blue: parseInt(digits.slice(4, 6), 16), alpha: digits.length === 8 ? parseInt(digits.slice(6, 8), 16) / 255 : 1 };
|
|
254
|
+
}
|
|
255
|
+
const rgb = /^rgba?\(\s*([^,\s]+)(?:\s*,\s*|\s+)([^,\s]+)(?:\s*,\s*|\s+)([^,\s]+)(?:\s*(?:,|\/)\s*([^\s)]+))?\s*\)$/i.exec(input);
|
|
256
|
+
if (rgb) {
|
|
257
|
+
const channels = [rgbChannel(rgb[1]!), rgbChannel(rgb[2]!), rgbChannel(rgb[3]!)];
|
|
258
|
+
const alpha = rgb[4] === undefined ? 1 : parseAlphaToken(rgb[4]);
|
|
259
|
+
return channels.some((channel) => !Number.isFinite(channel) || channel < 0 || channel > 255) || !Number.isFinite(alpha) || alpha < 0 || alpha > 1 ? undefined : { red: channels[0]!, green: channels[1]!, blue: channels[2]!, alpha };
|
|
260
|
+
}
|
|
261
|
+
const oklab = /^oklab\(\s*([\d.]+)(?:\s*,\s*|\s+)([-\d.]+)(?:\s*,\s*|\s+)([-\d.]+)(?:\s*(?:,|\/)\s*([^\s)]+))?\s*\)$/i.exec(input);
|
|
262
|
+
if (oklab) return fromOklab(Number(oklab[1]), Number(oklab[2]), Number(oklab[3]), oklab[4] === undefined ? 1 : parseAlphaToken(oklab[4]));
|
|
263
|
+
const oklch = /^oklch\(\s*([\d.]+)(?:\s*,\s*|\s+)([\d.]+)(?:\s*,\s*|\s+)([-\d.]+)(?:\s*(?:,|\/)\s*([^\s)]+))?\s*\)$/i.exec(input);
|
|
264
|
+
if (oklch) {
|
|
265
|
+
const chroma = Number(oklch[2]);
|
|
266
|
+
const hue = Number(oklch[3]) * Math.PI / 180;
|
|
267
|
+
return fromOklab(Number(oklch[1]), chroma * Math.cos(hue), chroma * Math.sin(hue), oklch[4] === undefined ? 1 : parseAlphaToken(oklch[4]));
|
|
268
|
+
}
|
|
269
|
+
return undefined;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function composite(over: Rgba, under: Rgba): Rgba {
|
|
273
|
+
const alpha = over.alpha + under.alpha * (1 - over.alpha);
|
|
274
|
+
if (alpha === 0) return { red: 0, green: 0, blue: 0, alpha: 0 };
|
|
275
|
+
return {
|
|
276
|
+
red: (over.red * over.alpha + under.red * under.alpha * (1 - over.alpha)) / alpha,
|
|
277
|
+
green: (over.green * over.alpha + under.green * under.alpha * (1 - over.alpha)) / alpha,
|
|
278
|
+
blue: (over.blue * over.alpha + under.blue * under.alpha * (1 - over.alpha)) / alpha,
|
|
279
|
+
alpha,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function applyOpacity(paint: Rgba, opacity: number): Rgba {
|
|
284
|
+
return { ...paint, alpha: paint.alpha * opacity };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function styleFor(element: GeometryElement, stylesByNode: Map<number, StyleElement>, stylesBySelector: Map<string, StyleElement>): StyleElement | undefined {
|
|
288
|
+
return (element.backendNodeId === null || element.backendNodeId === undefined ? undefined : stylesByNode.get(element.backendNodeId)) ?? (element.selector === undefined ? undefined : stylesBySelector.get(element.selector)) ?? (element.tag === undefined ? undefined : stylesBySelector.get(element.tag));
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function remainingOpacityIsOne(element: GeometryElement, byPath: Map<string, GeometryElement>): boolean {
|
|
292
|
+
let ancestor = element.domPath === undefined ? undefined : byPath.get(parentPath(element.domPath) ?? '');
|
|
293
|
+
while (ancestor) {
|
|
294
|
+
if ((ancestor.visibility?.opacity ?? 1) !== 1) return false;
|
|
295
|
+
ancestor = ancestor.domPath === undefined ? undefined : byPath.get(parentPath(ancestor.domPath) ?? '');
|
|
296
|
+
}
|
|
297
|
+
return true;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function effectiveBackground(target: GeometryElement, byPath: Map<string, GeometryElement>, stylesByNode: Map<number, StyleElement>, stylesBySelector: Map<string, StyleElement>): { readonly color: [number, number, number]; readonly source: GeometryElement } | undefined {
|
|
301
|
+
let element: GeometryElement | undefined = target;
|
|
302
|
+
let background: Rgba = { red: 0, green: 0, blue: 0, alpha: 0 };
|
|
303
|
+
let source: GeometryElement | undefined;
|
|
304
|
+
while (element) {
|
|
305
|
+
const style = styleFor(element, stylesByNode, stylesBySelector);
|
|
306
|
+
const paint = rgba(style?.computed?.backgroundColor ?? style?.computed?.['background-color']);
|
|
307
|
+
if (!paint) return undefined;
|
|
308
|
+
const wasOpaque = background.alpha === 1;
|
|
309
|
+
background = applyOpacity(composite(background, paint), element.visibility?.opacity ?? 1);
|
|
310
|
+
if (!wasOpaque && background.alpha === 1) source = element;
|
|
311
|
+
else if (background.alpha < 1) source = undefined;
|
|
312
|
+
if (background.alpha === 1 && source && remainingOpacityIsOne(element, byPath)) return { color: [Math.round(background.red), Math.round(background.green), Math.round(background.blue)], source };
|
|
313
|
+
element = element.domPath === undefined ? undefined : byPath.get(parentPath(element.domPath) ?? '');
|
|
314
|
+
}
|
|
315
|
+
return undefined;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function effectiveForeground(foreground: Rgba, target: GeometryElement, byPath: Map<string, GeometryElement>, stylesByNode: Map<number, StyleElement>, stylesBySelector: Map<string, StyleElement>): [number, number, number] | undefined {
|
|
319
|
+
let element: GeometryElement | undefined = target;
|
|
320
|
+
let background: Rgba = { red: 0, green: 0, blue: 0, alpha: 0 };
|
|
321
|
+
let rendered = foreground;
|
|
322
|
+
while (element) {
|
|
323
|
+
const style = styleFor(element, stylesByNode, stylesBySelector);
|
|
324
|
+
const paint = rgba(style?.computed?.backgroundColor ?? style?.computed?.['background-color']);
|
|
325
|
+
if (!paint) return undefined;
|
|
326
|
+
const opacity = element.visibility?.opacity ?? 1;
|
|
327
|
+
background = applyOpacity(composite(background, paint), opacity);
|
|
328
|
+
rendered = applyOpacity(composite(rendered, paint), opacity);
|
|
329
|
+
if (background.alpha === 1 && remainingOpacityIsOne(element, byPath)) return [Math.round(rendered.red), Math.round(rendered.green), Math.round(rendered.blue)];
|
|
330
|
+
element = element.domPath === undefined ? undefined : byPath.get(parentPath(element.domPath) ?? '');
|
|
331
|
+
}
|
|
332
|
+
return undefined;
|
|
333
|
+
}
|
|
334
|
+
|
|
224
335
|
function contrastRatio(foreground: [number, number, number], background: [number, number, number]): number {
|
|
225
336
|
const lum = (channels: [number, number, number]) => channels.reduce((sum, channel, index) => { const v = channel / 255; return sum + (v <= .03928 ? v / 12.92 : ((v + .055) / 1.055) ** 2.4) * [0.2126, 0.7152, 0.0722][index]; }, 0);
|
|
226
337
|
const a = lum(foreground), b = lum(background);
|
|
227
338
|
return (Math.max(a, b) + .05) / (Math.min(a, b) + .05);
|
|
228
339
|
}
|
|
229
340
|
|
|
341
|
+
export type ElementContrastMeasurement =
|
|
342
|
+
| { readonly available: true; readonly foreground: readonly [number, number, number]; readonly background: readonly [number, number, number]; readonly ratio: number; readonly backgroundSource: string; readonly backgroundSourceElementId: string; readonly provenance: string }
|
|
343
|
+
| { readonly available: false; readonly uncertainty: string };
|
|
344
|
+
|
|
345
|
+
/** Computes one selected element's effective rendered color pair from the snapshot's recorded styles and ancestor paint. */
|
|
346
|
+
export function measureElementContrast(ref: SnapRef, elementId: string): ElementContrastMeasurement {
|
|
347
|
+
const elements = readGeometry<{ elements?: GeometryElement[] }>(ref).elements ?? [];
|
|
348
|
+
const element = elements.find((candidate) => candidate.id === elementId);
|
|
349
|
+
if (!element) return { available: false, uncertainty: 'the selected geometry record is not present in geometry.json' };
|
|
350
|
+
const styles = readRequired<{ elements?: StyleElement[] }>(ref, 'styles.json').elements ?? [];
|
|
351
|
+
const stylesByNode = new Map<number, StyleElement>();
|
|
352
|
+
const stylesBySelector = new Map<string, StyleElement>();
|
|
353
|
+
const byPath = new Map<string, GeometryElement>();
|
|
354
|
+
for (const candidate of elements) if (candidate.domPath) byPath.set(candidate.domPath, candidate);
|
|
355
|
+
for (const style of styles) {
|
|
356
|
+
if (style.backendNodeId != null) stylesByNode.set(style.backendNodeId, style);
|
|
357
|
+
if (style.selector) stylesBySelector.set(style.selector, style);
|
|
358
|
+
}
|
|
359
|
+
const style = styleFor(element, stylesByNode, stylesBySelector);
|
|
360
|
+
const foreground = rgba(style?.computed?.color);
|
|
361
|
+
if (!foreground) return { available: false, uncertainty: 'the selected element has no parseable recorded computed color' };
|
|
362
|
+
const background = effectiveBackground(element, byPath, stylesByNode, stylesBySelector);
|
|
363
|
+
if (!background) return { available: false, uncertainty: 'an opaque effective background could not be resolved from recorded ancestor paint and opacity' };
|
|
364
|
+
const renderedForeground = effectiveForeground(foreground, element, byPath, stylesByNode, stylesBySelector);
|
|
365
|
+
if (!renderedForeground) return { available: false, uncertainty: 'the effective foreground could not be resolved through recorded ancestor paint and opacity' };
|
|
366
|
+
const backgroundSource = label(background.source);
|
|
367
|
+
return {
|
|
368
|
+
available: true,
|
|
369
|
+
foreground: renderedForeground,
|
|
370
|
+
background: background.color,
|
|
371
|
+
ratio: contrastRatio(renderedForeground, background.color),
|
|
372
|
+
backgroundSource,
|
|
373
|
+
backgroundSourceElementId: background.source.id,
|
|
374
|
+
provenance: `computed color; effective background supplied by ${backgroundSource}'s opaque paint after compositing recorded descendant backgrounds`,
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
|
|
230
378
|
/** Read-only analysis over a completed snapshot. Browser driving belongs solely to measure snap. */
|
|
231
379
|
export function checkSnapshot(ref: SnapRef, requested: readonly CheckName[]): { findings: CheckFinding[]; elementCount: number; settled: boolean; viewport: { width: number; height: number } } {
|
|
232
380
|
const geometry = readGeometry<{ elements: GeometryElement[] }>(ref);
|
|
@@ -270,24 +418,41 @@ export function checkSnapshot(ref: SnapRef, requested: readonly CheckName[]): {
|
|
|
270
418
|
if (selected.has('tap-targets')) for (const e of visible) if (/^(button|a|input|select|textarea)$/i.test((e as { tag?: string }).tag ?? '') && (e.rect.width < 44 || e.rect.height < 44)) findings.push(finding('tap-targets', e, `${label(e)} measures ${e.rect.width}×${e.rect.height}px; threshold is 44×44px`));
|
|
271
419
|
|
|
272
420
|
if (selected.has('contrast')) {
|
|
273
|
-
const styles = readRequired<{ elements?:
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
421
|
+
const styles = readRequired<{ elements?: StyleElement[] }>(ref, 'styles.json').elements ?? [];
|
|
422
|
+
const stylesByNode = new Map<number, StyleElement>();
|
|
423
|
+
const stylesBySelector = new Map<string, StyleElement>();
|
|
424
|
+
const byPath = new Map<string, GeometryElement>();
|
|
425
|
+
for (const element of elements) if (element.domPath) byPath.set(element.domPath, element);
|
|
426
|
+
for (const style of styles) {
|
|
427
|
+
if (style.backendNodeId != null) stylesByNode.set(style.backendNodeId, style);
|
|
428
|
+
if (style.selector) stylesBySelector.set(style.selector, style);
|
|
429
|
+
}
|
|
430
|
+
for (const style of styles) {
|
|
431
|
+
const foreground = rgba(style.computed?.color);
|
|
432
|
+
const element = elements.find((candidate) => (style.backendNodeId != null && candidate.backendNodeId === style.backendNodeId) || candidate.selector === style.selector);
|
|
433
|
+
if (!foreground || !element) continue;
|
|
434
|
+
const background = effectiveBackground(element, byPath, stylesByNode, stylesBySelector);
|
|
435
|
+
const renderedForeground = effectiveForeground(foreground, element, byPath, stylesByNode, stylesBySelector);
|
|
436
|
+
if (!background || !renderedForeground) continue;
|
|
437
|
+
const ratio = contrastRatio(renderedForeground, background.color);
|
|
279
438
|
if (ratio < 4.5) {
|
|
280
|
-
const
|
|
281
|
-
findings.push(finding('contrast',
|
|
439
|
+
const source = label(background.source);
|
|
440
|
+
findings.push(finding('contrast', element, `contrast ratio ${ratio.toFixed(2)}:1 — ${style.selector ?? label(element)} foreground ${style.computed?.color} against composited background rgb(${background.color.join(', ')})`, `computed color; effective background supplied by ${source}'s opaque paint after compositing recorded descendant backgrounds`));
|
|
282
441
|
}
|
|
283
442
|
}
|
|
284
443
|
}
|
|
285
444
|
if (selected.has('hit-test')) {
|
|
286
|
-
const hit = readHittest<{ elements?: Array<{ selector?: string; backendNodeId?: number | null;
|
|
287
|
-
for (const sample of hit.elements ?? [])
|
|
445
|
+
const hit = readHittest<{ elements?: Array<{ selector?: string; backendNodeId?: number | null; points?: Array<{ result?: { topReceiver?: { selector?: string; backendNodeId?: number | null } | null; x?: number; y?: number; stack?: Array<{ selector?: string; pointerEvents?: string; opacity?: number }> } }> }> }>(ref);
|
|
446
|
+
for (const sample of hit.elements ?? []) {
|
|
288
447
|
const e = elements.find((x) => (sample.backendNodeId != null && x.backendNodeId === sample.backendNodeId) || (sample.selector && x.selector === sample.selector));
|
|
289
|
-
|
|
290
|
-
|
|
448
|
+
if (!e) continue;
|
|
449
|
+
const point = sample.points?.find((p) => {
|
|
450
|
+
const receiver = p.result?.topReceiver;
|
|
451
|
+
const receiverElement = receiver && elements.find((x) => receiver.backendNodeId != null && x.backendNodeId === receiver.backendNodeId);
|
|
452
|
+
return receiverElement !== undefined && !isSelfOrDescendant(e, receiverElement);
|
|
453
|
+
})?.result;
|
|
454
|
+
if (!point?.topReceiver) continue;
|
|
455
|
+
findings.push(finding('hit-test', e, `${label(e)} sampled point (${point.x},${point.y}) resolves to non-descendant receiver ${point.topReceiver.selector ?? 'unidentified element'}`, point.stack?.map((x) => `${x.selector ?? x.pointerEvents ?? 'element'}${x.opacity === 0 ? ' opacity 0' : ''}`).join(', ')));
|
|
291
456
|
}
|
|
292
457
|
}
|
|
293
458
|
if (selected.has('truncation')) for (const t of readText<{ elements?: Array<{ selector?: string; backendNodeId?: number | null; truncated?: boolean; scrollWidth?: number; clientWidth?: number }> }>(ref).elements ?? []) if (t.truncated) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `hittest.json` collector — point-sampled hit-test results (what element
|
|
3
|
-
* actually receives a click at a given coordinate): a 9-point lattice
|
|
4
|
-
* (center + 4 corners + 4 edge-midpoints) per walked element, plus a
|
|
3
|
+
* actually receives a click at a given coordinate): a 9-point interior lattice
|
|
4
|
+
* (center + 4 inset corners + 4 inset edge-midpoints) per walked element, plus a
|
|
5
5
|
* coarse whole-viewport grid independent of any element.
|
|
6
6
|
*
|
|
7
7
|
* The ENTIRE walk + sampling happens inside ONE `Runtime.evaluate` script
|
|
@@ -330,16 +330,22 @@ function buildHittestScript(maxElements: number, latticeStep: number, latticeMax
|
|
|
330
330
|
function pointLabelsFor(rect) {
|
|
331
331
|
var cx = rect.x + rect.width / 2;
|
|
332
332
|
var cy = rect.y + rect.height / 2;
|
|
333
|
+
// Exact border coordinates belong to the adjacent element or outside
|
|
334
|
+
// the viewport under CSS hit testing. Sample the box interior instead.
|
|
335
|
+
var insetX = Math.min(1, rect.width / 4);
|
|
336
|
+
var insetY = Math.min(1, rect.height / 4);
|
|
337
|
+
var left = rect.x + insetX, right = rect.x + rect.width - insetX;
|
|
338
|
+
var top = rect.y + insetY, bottom = rect.y + rect.height - insetY;
|
|
333
339
|
return [
|
|
334
340
|
{ label: 'center', x: cx, y: cy },
|
|
335
|
-
{ label: 'top-left', x:
|
|
336
|
-
{ label: 'top-right', x:
|
|
337
|
-
{ label: 'bottom-left', x:
|
|
338
|
-
{ label: 'bottom-right', x:
|
|
339
|
-
{ label: 'top-mid', x: cx, y:
|
|
340
|
-
{ label: 'bottom-mid', x: cx, y:
|
|
341
|
-
{ label: 'left-mid', x:
|
|
342
|
-
{ label: 'right-mid', x:
|
|
341
|
+
{ label: 'top-left', x: left, y: top },
|
|
342
|
+
{ label: 'top-right', x: right, y: top },
|
|
343
|
+
{ label: 'bottom-left', x: left, y: bottom },
|
|
344
|
+
{ label: 'bottom-right', x: right, y: bottom },
|
|
345
|
+
{ label: 'top-mid', x: cx, y: top },
|
|
346
|
+
{ label: 'bottom-mid', x: cx, y: bottom },
|
|
347
|
+
{ label: 'left-mid', x: left, y: cy },
|
|
348
|
+
{ label: 'right-mid', x: right, y: cy },
|
|
343
349
|
];
|
|
344
350
|
}
|
|
345
351
|
|
|
@@ -121,7 +121,12 @@ const SCROLL_TOPOLOGY_SCRIPT = `/* __captureScrollTopology */
|
|
|
121
121
|
}
|
|
122
122
|
return { items: out, truncated: i < kids.length };
|
|
123
123
|
}
|
|
124
|
-
function
|
|
124
|
+
function visibleChildrenAt(el, requestedTop) {
|
|
125
|
+
// A projected rect is wrong for sticky/fixed and scroll-reactive content.
|
|
126
|
+
// Measure the actual layout at each offset; the outer finally restores
|
|
127
|
+
// every original offset before this one evaluation returns.
|
|
128
|
+
el.scrollTop = requestedTop;
|
|
129
|
+
var actualTop = el.scrollTop;
|
|
125
130
|
var band = el.getBoundingClientRect();
|
|
126
131
|
var out = [];
|
|
127
132
|
var kids = el.children;
|
|
@@ -129,9 +134,9 @@ const SCROLL_TOPOLOGY_SCRIPT = `/* __captureScrollTopology */
|
|
|
129
134
|
for (i = 0; i < kids.length && out.length < 30; i++) {
|
|
130
135
|
var r = kids[i].getBoundingClientRect();
|
|
131
136
|
var intersects = r.bottom > band.top && r.top < band.bottom && r.right > band.left && r.left < band.right;
|
|
132
|
-
if (intersects) out.push({ scrollId: tagId(kids[i]), selector: selectorOf(kids[i]), rect:
|
|
137
|
+
if (intersects) out.push({ scrollId: tagId(kids[i]), selector: selectorOf(kids[i]), rect: { x: r.x, y: r.y, width: r.width, height: r.height } });
|
|
133
138
|
}
|
|
134
|
-
return { items: out, truncated: i < kids.length };
|
|
139
|
+
return { offsetTop: actualTop, items: out, truncated: i < kids.length };
|
|
135
140
|
}
|
|
136
141
|
|
|
137
142
|
var root = document.scrollingElement || document.documentElement;
|
|
@@ -186,10 +191,13 @@ const SCROLL_TOPOLOGY_SCRIPT = `/* __captureScrollTopology */
|
|
|
186
191
|
[originalTop, 0, maxTop].forEach(function(v) { if (!(v in seen)) { seen[v] = true; offsetsToSample.push(v); } });
|
|
187
192
|
for (var s = 0; s < offsetsToSample.length; s++) {
|
|
188
193
|
var top = offsetsToSample[s];
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
samples.push({ offsetTop: top, visibleChildren: vc.items, visibleChildrenTruncated: vc.truncated });
|
|
194
|
+
var vc = isRoot ? { offsetTop: top, items: [], truncated: false } : visibleChildrenAt(el, top);
|
|
195
|
+
samples.push({ offsetTop: vc.offsetTop, visibleChildren: vc.items, visibleChildrenTruncated: vc.truncated });
|
|
192
196
|
}
|
|
197
|
+
// Sampling above leaves el scrolled to its last sampled offset;
|
|
198
|
+
// restore before measuring sticky/fixed/snap descendants so their
|
|
199
|
+
// rects reflect the container's real (unscrolled) position, not
|
|
200
|
+
// whichever sample offset was visited last.
|
|
193
201
|
el.scrollTop = originalTop;
|
|
194
202
|
el.scrollLeft = originalLeft;
|
|
195
203
|
|
|
@@ -461,7 +469,7 @@ function toContainerOut(c: ScrollContainerRaw, backendById: Map<string, number>)
|
|
|
461
469
|
async function resolveMarkerBackendIds(client: CDPClient, markerAttr: string): Promise<Map<string, number>> {
|
|
462
470
|
const map = new Map<string, number>();
|
|
463
471
|
try {
|
|
464
|
-
const doc = (await client.send('DOM.getDocument', { depth:
|
|
472
|
+
const doc = (await client.send('DOM.getDocument', { depth: 0, pierce: false })) as { root?: { nodeId?: number } };
|
|
465
473
|
const rootId = doc.root?.nodeId;
|
|
466
474
|
if (rootId === undefined) return map;
|
|
467
475
|
const res = (await client.send('DOM.querySelectorAll', { nodeId: rootId, selector: `[${markerAttr}]` })) as { nodeIds?: number[] };
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
import { resolveAuthoredSourceLocation, type ResolvedSourceLocation } from '../../source-map.js';
|
|
39
39
|
import { sanitizeString } from '../redaction.js';
|
|
40
40
|
import type { CDPClient } from '../../client.js';
|
|
41
|
+
import { isMotionHeldClient } from '../../host/held-client.js';
|
|
41
42
|
|
|
42
43
|
// ============================================================================
|
|
43
44
|
// Output shape
|
|
@@ -597,6 +598,16 @@ export async function captureStyleSheetHeaders(
|
|
|
597
598
|
client: CDPClient,
|
|
598
599
|
): Promise<{ urls: Map<string, string>; stop: () => void; available: boolean; reason?: string }> {
|
|
599
600
|
const urls = new Map<string, string>();
|
|
601
|
+
// A recorder-routed command cannot subscribe through its one-request socket;
|
|
602
|
+
// ask the recorder's owning CDP connection to collect the same redelivery.
|
|
603
|
+
if (isMotionHeldClient(client)) {
|
|
604
|
+
try {
|
|
605
|
+
for (const header of await client.collectStyleSheetHeaders()) urls.set(header.styleSheetId, header.sourceURL);
|
|
606
|
+
return { urls, stop: () => {}, available: true };
|
|
607
|
+
} catch {
|
|
608
|
+
return { urls, stop: () => {}, available: false, reason: 'stylesheet-header-redelivery-failed' };
|
|
609
|
+
}
|
|
610
|
+
}
|
|
600
611
|
if (typeof client.on !== 'function') {
|
|
601
612
|
return { urls, stop: () => {}, available: false, reason: 'client-lacks-event-support' };
|
|
602
613
|
}
|
|
@@ -123,10 +123,9 @@ export type StylesUnavailableReason = 'styles-evaluate-returned-no-value' | 'sty
|
|
|
123
123
|
// In-page script — cssPath + computed style facts, no DOM domain calls
|
|
124
124
|
// ============================================================================
|
|
125
125
|
|
|
126
|
-
// `html
|
|
127
|
-
//
|
|
128
|
-
|
|
129
|
-
const EXCLUDED_TAGS = ['html', 'body', 'script', 'style', 'head', 'meta', 'link', 'title', 'template', 'noscript', 'br', 'wbr'];
|
|
126
|
+
// `html` is excluded because `cssPathFromBody` is rooted at `document.body`, so it would produce
|
|
127
|
+
// an out-of-subtree selector. `body` remains: it supplies the canvas background behind transparent descendants.
|
|
128
|
+
const EXCLUDED_TAGS = ['html', 'script', 'style', 'head', 'meta', 'link', 'title', 'template', 'noscript', 'br', 'wbr'];
|
|
130
129
|
|
|
131
130
|
interface StylesFact {
|
|
132
131
|
cssPath: string;
|
|
@@ -149,6 +148,7 @@ const STYLES_SCRIPT = `/* __captureStylesInventory */
|
|
|
149
148
|
for (var e = 0; e < EXCLUDED.length; e++) excludedSet[EXCLUDED[e]] = true;
|
|
150
149
|
|
|
151
150
|
function cssPathFromBody(el) {
|
|
151
|
+
if (el === document.body) return 'body';
|
|
152
152
|
var parts = [];
|
|
153
153
|
var node = el;
|
|
154
154
|
var depth = 0;
|
|
@@ -116,7 +116,16 @@ export interface ExplainMissingSelector {
|
|
|
116
116
|
readonly available: MissingSelectorRecovery;
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
-
export
|
|
119
|
+
export interface ExplainAmbiguousSelector {
|
|
120
|
+
readonly kind: 'ambiguous-selector';
|
|
121
|
+
readonly ref: SnapRef;
|
|
122
|
+
readonly selector: string;
|
|
123
|
+
readonly meta: { readonly settled?: boolean; readonly settleMs?: number };
|
|
124
|
+
readonly matchCount: number;
|
|
125
|
+
readonly candidates: readonly ElementRecord[];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export type ExplainSnapshotResult = ExplainSuccess | ExplainMissingSelector | ExplainAmbiguousSelector;
|
|
120
129
|
|
|
121
130
|
type RawSection = { kind: ExplainSection['kind']; facts: ExplainFact[] };
|
|
122
131
|
|
|
@@ -333,7 +342,7 @@ function stackingClippingSection(target: GeometryElement, geometry: readonly Geo
|
|
|
333
342
|
: `paint extents could not be resolved: ${ink.unresolved.join(', ')}`;
|
|
334
343
|
facts.push(factFor(target, fact`Ink box (${source.provenance}) was not calculated because ${reason}.`));
|
|
335
344
|
} else {
|
|
336
|
-
facts.push(factFor(target,
|
|
345
|
+
facts.push(factFor(target, fact`Ink box was not calculated because ${source.unavailable}.`));
|
|
337
346
|
}
|
|
338
347
|
|
|
339
348
|
const overflowAncestors = ancestors.filter((element) => {
|
|
@@ -567,6 +576,9 @@ export function explainSnapshot(ref: SnapRef, selector: string, detailOpts: Expl
|
|
|
567
576
|
if (!matches.length) {
|
|
568
577
|
return { kind: 'missing-selector', ref, selector, meta, available: recoveryForms(selectable, selector) };
|
|
569
578
|
}
|
|
579
|
+
if (matches.length > 1) {
|
|
580
|
+
return { kind: 'ambiguous-selector', ref, selector, meta, matchCount: matches.length, candidates: matches.slice(0, RECOVERY_CANDIDATE_LIMIT) };
|
|
581
|
+
}
|
|
570
582
|
|
|
571
583
|
const selectedId = matches[0]!.id;
|
|
572
584
|
const target = geometry.find((element) => element.id === selectedId)!;
|