@applitools/eyes-results 0.2.0
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/CHANGELOG.md +8 -0
- package/LICENSE +26 -0
- package/dist/api/api.js +17 -0
- package/dist/api/compare-step.js +20 -0
- package/dist/api/diff-fetch.js +13 -0
- package/dist/api/dom-diff.js +139 -0
- package/dist/api/dom-fetch.js +39 -0
- package/dist/api/find-regions.js +24 -0
- package/dist/api/fs-cache.js +19 -0
- package/dist/api/gzip.js +12 -0
- package/dist/api/image-fetch.js +31 -0
- package/dist/api/polling.js +117 -0
- package/dist/api/req-results.js +77 -0
- package/dist/api/same-diff-as.js +25 -0
- package/dist/api/selector-mapping.js +15 -0
- package/dist/api/session-fetch.js +35 -0
- package/dist/api/session-history-fetch.js +73 -0
- package/dist/api/session-updates.js +37 -0
- package/dist/api/tasks.js +48 -0
- package/dist/api/url-parsing.js +33 -0
- package/dist/common/common.js +2 -0
- package/dist/common/diff-rects.js +64 -0
- package/dist/common/region-mapping.js +50 -0
- package/dist/geometry/geometry.js +72 -0
- package/dist/index.js +14 -0
- package/dist/lib/api-key-provider.js +26 -0
- package/dist/lib/env.js +26 -0
- package/dist/lib/phase-tracker.js +223 -0
- package/dist/sessions/batch.js +98 -0
- package/dist/sessions/diffs.js +210 -0
- package/dist/sessions/dom-history.js +107 -0
- package/dist/sessions/dom-search.js +1150 -0
- package/dist/sessions/dom.js +513 -0
- package/dist/sessions/match.js +56 -0
- package/dist/sessions/regions.js +280 -0
- package/dist/sessions/session.js +115 -0
- package/llm-docs/dom-search.md +164 -0
- package/package.json +69 -0
- package/types/api/api.d.ts +17 -0
- package/types/api/compare-step.d.ts +52 -0
- package/types/api/diff-fetch.d.ts +21 -0
- package/types/api/dom-diff.d.ts +53 -0
- package/types/api/dom-fetch.d.ts +29 -0
- package/types/api/find-regions.d.ts +26 -0
- package/types/api/fs-cache.d.ts +12 -0
- package/types/api/gzip.d.ts +5 -0
- package/types/api/image-fetch.d.ts +29 -0
- package/types/api/polling.d.ts +41 -0
- package/types/api/req-results.d.ts +37 -0
- package/types/api/same-diff-as.d.ts +38 -0
- package/types/api/selector-mapping.d.ts +28 -0
- package/types/api/session-fetch.d.ts +34 -0
- package/types/api/session-history-fetch.d.ts +56 -0
- package/types/api/session-updates.d.ts +26 -0
- package/types/api/tasks.d.ts +33 -0
- package/types/api/url-parsing.d.ts +20 -0
- package/types/common/common.d.ts +2 -0
- package/types/common/diff-rects.d.ts +39 -0
- package/types/common/region-mapping.d.ts +26 -0
- package/types/geometry/geometry.d.ts +24 -0
- package/types/index.d.ts +14 -0
- package/types/lib/api-key-provider.d.ts +31 -0
- package/types/lib/env.d.ts +14 -0
- package/types/lib/phase-tracker.d.ts +146 -0
- package/types/sessions/batch.d.ts +67 -0
- package/types/sessions/diffs.d.ts +26 -0
- package/types/sessions/dom-history.d.ts +119 -0
- package/types/sessions/dom-search.d.ts +113 -0
- package/types/sessions/dom.d.ts +263 -0
- package/types/sessions/match.d.ts +72 -0
- package/types/sessions/regions.d.ts +171 -0
- package/types/sessions/session.d.ts +97 -0
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as os from 'os';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import * as crypto from 'crypto';
|
|
5
|
+
const RETRYABLE_CODES = new Set(['EBUSY', 'EPERM', 'EACCES', 'EAGAIN']);
|
|
6
|
+
const RETRY_ATTEMPTS = 5;
|
|
7
|
+
const RETRY_BASE_DELAY_MS = 20;
|
|
8
|
+
/**
|
|
9
|
+
* A synchronous blocking sleep (`Atomics.wait` against a throwaway shared buffer) — matches
|
|
10
|
+
* the rest of this codebase's synchronous fs style (`fs.readFileSync`/`writeFileSync`
|
|
11
|
+
* throughout `commands.ts`), rather than forcing every phase-tracker call to be async merely
|
|
12
|
+
* to await a short backoff delay.
|
|
13
|
+
*/
|
|
14
|
+
function busyWaitSleep(ms) {
|
|
15
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
16
|
+
}
|
|
17
|
+
export const defaultPhaseTrackerDeps = {
|
|
18
|
+
readFile: p => (fs.existsSync(p) ? fs.readFileSync(p, 'utf8') : null),
|
|
19
|
+
writeFile: (p, contents) => fs.writeFileSync(p, contents),
|
|
20
|
+
appendFile: (p, contents) => fs.appendFileSync(p, contents),
|
|
21
|
+
deleteFile: p => fs.rmSync(p, { force: true }),
|
|
22
|
+
mkdir: dir => fs.mkdirSync(dir, { recursive: true }),
|
|
23
|
+
sleep: busyWaitSleep,
|
|
24
|
+
stateDir: path.join(os.tmpdir(), 'eyes-resolve'),
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* Retries `fn` on a transient file-lock error (a momentarily-locked file must never crash a
|
|
28
|
+
* workflow call — see `lib.md`), with a short exponential backoff between attempts. Any other
|
|
29
|
+
* error (including a non-`Error` throw with no `.code`) propagates immediately, unretried.
|
|
30
|
+
*/
|
|
31
|
+
function withRetry(fn, deps) {
|
|
32
|
+
for (let attempt = 0;; attempt++) {
|
|
33
|
+
try {
|
|
34
|
+
return fn();
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
const code = error?.code;
|
|
38
|
+
if (attempt >= RETRY_ATTEMPTS - 1 || !code || !RETRYABLE_CODES.has(code))
|
|
39
|
+
throw error;
|
|
40
|
+
deps.sleep(RETRY_BASE_DELAY_MS * 2 ** attempt);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function hashId(id) {
|
|
45
|
+
return crypto.createHash('sha256').update(id).digest('hex');
|
|
46
|
+
}
|
|
47
|
+
// path.posix.join/dirname (not path.join/dirname) so the result is deterministic across
|
|
48
|
+
// platforms — Node's fs APIs accept forward slashes on Windows too (same convention as
|
|
49
|
+
// `api/fs-cache.ts`'s `cacheFilePath`).
|
|
50
|
+
function recordPath(deps, kind, id) {
|
|
51
|
+
return path.posix.join(deps.stateDir, kind, `${hashId(id)}.json`);
|
|
52
|
+
}
|
|
53
|
+
function readJson(deps, filePath) {
|
|
54
|
+
const contents = withRetry(() => deps.readFile(filePath), deps);
|
|
55
|
+
return contents === null ? undefined : JSON.parse(contents);
|
|
56
|
+
}
|
|
57
|
+
function writeJson(deps, filePath, value) {
|
|
58
|
+
withRetry(() => deps.mkdir(path.posix.dirname(filePath)), deps);
|
|
59
|
+
withRetry(() => deps.writeFile(filePath, JSON.stringify(value)), deps);
|
|
60
|
+
}
|
|
61
|
+
/** The recorded completion for `id`, or `undefined` if `<level>_end` was never (successfully) called for it. */
|
|
62
|
+
export function getCompletion(id, deps = defaultPhaseTrackerDeps) {
|
|
63
|
+
return readJson(deps, recordPath(deps, 'completions', id));
|
|
64
|
+
}
|
|
65
|
+
export function isCompleted(id, deps = defaultPhaseTrackerDeps) {
|
|
66
|
+
return getCompletion(id, deps) !== undefined;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Persists `payload` as `id`'s completion, stamped with the current time — overwriting
|
|
70
|
+
* whatever was recorded for `id` before, so revising a summary (or refreshing a stale
|
|
71
|
+
* completion) is just calling this again.
|
|
72
|
+
*/
|
|
73
|
+
export function recordCompletion(id, payload, deps = defaultPhaseTrackerDeps) {
|
|
74
|
+
const completion = { payload, completedAt: new Date().toISOString() };
|
|
75
|
+
writeJson(deps, recordPath(deps, 'completions', id), completion);
|
|
76
|
+
return completion;
|
|
77
|
+
}
|
|
78
|
+
/** `parentId`'s full accumulated child-findings record, read as-is with no live-data filtering. `{}` if no child has ever reported into it. */
|
|
79
|
+
export function getChildFindings(parentId, deps = defaultPhaseTrackerDeps) {
|
|
80
|
+
return readJson(deps, recordPath(deps, 'child-findings', parentId)) ?? {};
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Writes (or overwrites) `childId`'s entry in `parentId`'s accumulated findings record,
|
|
84
|
+
* creating that record the first time any child reports into it. Called once a child's own
|
|
85
|
+
* `_end` succeeds — independent of whether `childId` is still among `parentId`'s currently-live
|
|
86
|
+
* children, so a child that later drops out of the live set never loses its findings.
|
|
87
|
+
*/
|
|
88
|
+
export function appendChildFinding(parentId, childId, payload, deps = defaultPhaseTrackerDeps) {
|
|
89
|
+
const record = getChildFindings(parentId, deps);
|
|
90
|
+
record[childId] = payload;
|
|
91
|
+
writeJson(deps, recordPath(deps, 'child-findings', parentId), record);
|
|
92
|
+
}
|
|
93
|
+
/** Appends one entry to `scopeId`'s invocation log — called as a side effect of every eyes-inspect data tool. */
|
|
94
|
+
export function logInvocation(scopeId, toolName, args, deps = defaultPhaseTrackerDeps) {
|
|
95
|
+
const filePath = recordPath(deps, 'invocations', scopeId);
|
|
96
|
+
const log = readJson(deps, filePath) ?? [];
|
|
97
|
+
log.push({ toolName, args, at: new Date().toISOString() });
|
|
98
|
+
writeJson(deps, filePath, log);
|
|
99
|
+
}
|
|
100
|
+
/** Whether `scopeId`'s invocation log contains a call to `toolName` whose `args` satisfy `matcher` — the per-item invocation-verification check backing `step_end`/`diff_end`'s hard gates. */
|
|
101
|
+
export function verifyInvocation(scopeId, toolName, matcher, deps = defaultPhaseTrackerDeps) {
|
|
102
|
+
const log = readJson(deps, recordPath(deps, 'invocations', scopeId)) ?? [];
|
|
103
|
+
return log.some(entry => entry.toolName === toolName && matcher(entry.args));
|
|
104
|
+
}
|
|
105
|
+
/** Persists `mode` as the mode `scopeId` is currently being reviewed in — overwriting whatever was recorded before, so re-entering with the same mode simply re-states it. */
|
|
106
|
+
export function recordScopeMode(scopeId, mode, deps = defaultPhaseTrackerDeps) {
|
|
107
|
+
writeJson(deps, recordPath(deps, 'modes', scopeId), mode);
|
|
108
|
+
}
|
|
109
|
+
/** The mode last recorded for `scopeId` via `recordScopeMode`, or `undefined` if never recorded. */
|
|
110
|
+
export function getScopeMode(scopeId, deps = defaultPhaseTrackerDeps) {
|
|
111
|
+
return readJson(deps, recordPath(deps, 'modes', scopeId));
|
|
112
|
+
}
|
|
113
|
+
/** Discards `id`'s completion, as if `recordCompletion` had never been called for it. */
|
|
114
|
+
export function clearCompletion(id, deps = defaultPhaseTrackerDeps) {
|
|
115
|
+
withRetry(() => deps.deleteFile(recordPath(deps, 'completions', id)), deps);
|
|
116
|
+
}
|
|
117
|
+
/** Discards `parentId`'s entire accumulated child-findings record — every child's entry, not just one. */
|
|
118
|
+
export function clearChildFindings(parentId, deps = defaultPhaseTrackerDeps) {
|
|
119
|
+
withRetry(() => deps.deleteFile(recordPath(deps, 'child-findings', parentId)), deps);
|
|
120
|
+
}
|
|
121
|
+
/** Discards `scopeId`'s recorded mode, as if `recordScopeMode` had never been called for it. */
|
|
122
|
+
export function clearScopeMode(scopeId, deps = defaultPhaseTrackerDeps) {
|
|
123
|
+
withRetry(() => deps.deleteFile(recordPath(deps, 'modes', scopeId)), deps);
|
|
124
|
+
}
|
|
125
|
+
/** Persists the keys of `scopeId`'s children still unready to be finalized, as of its own latest live check — overwriting whatever was recorded before, so a child that has since become ready (or a newly-discovered unready one) is reflected exactly, never accumulated across checks. */
|
|
126
|
+
export function recordUnreadyChildren(scopeId, childKeys, deps = defaultPhaseTrackerDeps) {
|
|
127
|
+
writeJson(deps, recordPath(deps, 'unready-children', scopeId), childKeys);
|
|
128
|
+
}
|
|
129
|
+
/** The child keys `recordUnreadyChildren` last recorded as unready for `scopeId`, or `[]` if never recorded. */
|
|
130
|
+
export function getUnreadyChildren(scopeId, deps = defaultPhaseTrackerDeps) {
|
|
131
|
+
return readJson(deps, recordPath(deps, 'unready-children', scopeId)) ?? [];
|
|
132
|
+
}
|
|
133
|
+
/** Discards `scopeId`'s recorded unready-children snapshot, as if `recordUnreadyChildren` had never been called for it. */
|
|
134
|
+
export function clearUnreadyChildren(scopeId, deps = defaultPhaseTrackerDeps) {
|
|
135
|
+
withRetry(() => deps.deleteFile(recordPath(deps, 'unready-children', scopeId)), deps);
|
|
136
|
+
}
|
|
137
|
+
/** `scopeId`'s queued child keys — the stable, append-only set of children discovered live so far, investigated from regardless of what a later live fetch would show. `[]` if none have ever been queued. */
|
|
138
|
+
export function getQueuedChildKeys(scopeId, deps = defaultPhaseTrackerDeps) {
|
|
139
|
+
return readJson(deps, recordPath(deps, 'children', scopeId)) ?? [];
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Appends any of `liveKeys` not already queued, preserving the existing queue's order with
|
|
143
|
+
* newcomers appended in their given order — never removes a key, so a child that later drops out
|
|
144
|
+
* of a live fetch (resolved via propagation, or otherwise) stays queued and gets visited. Returns
|
|
145
|
+
* the full merged queue; writes only when something actually changed.
|
|
146
|
+
*/
|
|
147
|
+
export function queueChildKeys(scopeId, liveKeys, deps = defaultPhaseTrackerDeps) {
|
|
148
|
+
const existing = getQueuedChildKeys(scopeId, deps);
|
|
149
|
+
const existingSet = new Set(existing);
|
|
150
|
+
const fresh = liveKeys.filter(k => !existingSet.has(k));
|
|
151
|
+
if (fresh.length === 0)
|
|
152
|
+
return existing;
|
|
153
|
+
const merged = [...existing, ...fresh];
|
|
154
|
+
writeJson(deps, recordPath(deps, 'children', scopeId), merged);
|
|
155
|
+
return merged;
|
|
156
|
+
}
|
|
157
|
+
/** Discards `scopeId`'s entire queued-children record, as if nothing had ever been queued for it. */
|
|
158
|
+
export function clearQueuedChildKeys(scopeId, deps = defaultPhaseTrackerDeps) {
|
|
159
|
+
withRetry(() => deps.deleteFile(recordPath(deps, 'children', scopeId)), deps);
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Persists `payload` as everything `scopeId`'s own live fetch most recently discovered — e.g. a
|
|
163
|
+
* batch's full session list, expensive to fetch and shared by everything nested under it —
|
|
164
|
+
* overwriting whatever was recorded before, so a caller that decides to refresh simply calls this
|
|
165
|
+
* again. Unlike `queueChildKeys`/`getQueuedChildKeys` (a stable, append-only set of keys a caller
|
|
166
|
+
* has committed to investigating), this is the raw discovery result itself — including entries
|
|
167
|
+
* (e.g. still-`Running` sessions) that may not be ready to investigate at all — so a lower-level
|
|
168
|
+
* scope nested underneath `scopeId` can re-derive its own children from it without repeating the
|
|
169
|
+
* fetch.
|
|
170
|
+
*/
|
|
171
|
+
export function recordDiscoveredChildren(scopeId, payload, deps = defaultPhaseTrackerDeps) {
|
|
172
|
+
writeJson(deps, recordPath(deps, 'discovered-children', scopeId), payload);
|
|
173
|
+
}
|
|
174
|
+
/** What `recordDiscoveredChildren` last recorded for `scopeId`, or `undefined` if never recorded. */
|
|
175
|
+
export function getDiscoveredChildren(scopeId, deps = defaultPhaseTrackerDeps) {
|
|
176
|
+
return readJson(deps, recordPath(deps, 'discovered-children', scopeId));
|
|
177
|
+
}
|
|
178
|
+
/** Discards `scopeId`'s recorded discovery, as if `recordDiscoveredChildren` had never been called for it. */
|
|
179
|
+
export function clearDiscoveredChildren(scopeId, deps = defaultPhaseTrackerDeps) {
|
|
180
|
+
withRetry(() => deps.deleteFile(recordPath(deps, 'discovered-children', scopeId)), deps);
|
|
181
|
+
}
|
|
182
|
+
function debugLogDir(deps) {
|
|
183
|
+
return path.posix.join(deps.stateDir, 'debug');
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* The exact filesystem path `log` appends `scopeId`'s entries to — a caller (or a human trying to
|
|
187
|
+
* find the right file by hand) never has to reimplement this module's own hashing scheme just to
|
|
188
|
+
* locate it.
|
|
189
|
+
*/
|
|
190
|
+
export function getLogFilePath(scopeId, deps = defaultPhaseTrackerDeps) {
|
|
191
|
+
return path.posix.join(debugLogDir(deps), `${hashId(scopeId)}.log`);
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* A generic call-trace log: any caller can record one labeled entry, with no fixed vocabulary for
|
|
195
|
+
* what a call even is — `label` is whatever convention the caller adopts (today, resolution.ts
|
|
196
|
+
* labels each entry with the traced function's own name; a wholly different caller could start
|
|
197
|
+
* labeling its own entries tomorrow with no change here). Appends to `scopeId`'s own log file,
|
|
198
|
+
* partitioned by hashed id the same way every other record in this module is — `scopeId` is just
|
|
199
|
+
* as opaque to this module as everywhere else it appears, so it's entirely the caller's own choice
|
|
200
|
+
* whether to pass the same id (e.g. an investigation's root) on every call, consolidating every
|
|
201
|
+
* entry raised anywhere within it into one file, or a more granular one per call, splitting the
|
|
202
|
+
* trace up instead. `label` is the only other required field; `input`/`output`/`error`/`message`
|
|
203
|
+
* are all optional and independent — a call-and-response entry sets `input`/`output`, a failed one
|
|
204
|
+
* sets `error` (instead of stuffing it into `output`), and anything that isn't a call at all (a
|
|
205
|
+
* one-off event, a snapshot) just sets `message` to whatever shape fits. Every entry lands as one
|
|
206
|
+
* line (a JSON-encoded `DebugLogEntry`) in a single NDJSON file at `getLogFilePath(scopeId, deps)`.
|
|
207
|
+
* There is no reader function: this is meant to be opened directly (as a file on disk, or via
|
|
208
|
+
* `deps.readFile` in a test) rather than queried back through this module.
|
|
209
|
+
*
|
|
210
|
+
* Safe under concurrent callers by construction, not by locking: this never reads the file before
|
|
211
|
+
* appending (so there's no read-modify-write window for two callers to race), and each entry is
|
|
212
|
+
* written as a single `appendFile` call — opening for append and writing is one atomic
|
|
213
|
+
* seek-to-end-and-write at the OS level, so concurrent appends interleave whole lines and never
|
|
214
|
+
* corrupt or split one another's. The only remaining failure mode is a transient lock on the file
|
|
215
|
+
* itself (e.g. a concurrent antivirus scan) — `mkdir`/`appendFile` are wrapped in the same
|
|
216
|
+
* lock-retry backoff (`withRetry`) as every other record this module writes, so a caller never
|
|
217
|
+
* has to poll for the file to become available itself; a momentarily-busy append just retries.
|
|
218
|
+
*/
|
|
219
|
+
export function log(scopeId, label, fields = {}, deps = defaultPhaseTrackerDeps) {
|
|
220
|
+
const entry = { at: new Date().toISOString(), label, ...fields };
|
|
221
|
+
withRetry(() => deps.mkdir(debugLogDir(deps)), deps);
|
|
222
|
+
withRetry(() => deps.appendFile(getLogFilePath(scopeId, deps), `${JSON.stringify(entry)}\n`), deps);
|
|
223
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/** Shapes a raw batch entity's fields (see `BatchInfo` — fetched via `api`'s `fetchBatchInfo`) into `BatchStats`. */
|
|
2
|
+
export function toBatchStats(raw) {
|
|
3
|
+
return {
|
|
4
|
+
total: (raw.completedCount ?? 0) + (raw.runningCount ?? 0) + (raw.abortedCount ?? 0),
|
|
5
|
+
unresolved: raw.unresolvedCount ?? 0,
|
|
6
|
+
passed: raw.passedCount ?? 0,
|
|
7
|
+
failed: raw.failedCount ?? 0,
|
|
8
|
+
aborted: raw.abortedCount ?? 0,
|
|
9
|
+
unsaved: raw.changedCount ?? 0,
|
|
10
|
+
totalSteps: raw.stepsCount ?? 0,
|
|
11
|
+
unresolvedSteps: raw.unresolvedStepsCount ?? 0,
|
|
12
|
+
unsavedSteps: raw.changedStepsCount ?? 0,
|
|
13
|
+
missingSteps: raw.missingStepsCount ?? 0,
|
|
14
|
+
newSteps: raw.newStepsCount ?? 0,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Computes the same `BatchStats` shape as `toBatchStats`, but by counting over a set of
|
|
19
|
+
* already-`simplifySession`-shaped sessions (`sessions/session`) instead of reading the
|
|
20
|
+
* (eventually consistent) batch aggregate — so it's always current, at the cost of only
|
|
21
|
+
* reflecting whatever subset of sessions `sessions` contains (the whole batch, or an
|
|
22
|
+
* already-filtered slice of it).
|
|
23
|
+
*
|
|
24
|
+
* Takes `SimplifiedSession[]`, not raw sessions, for two reasons: it reuses whatever
|
|
25
|
+
* `simplifySession` call the caller already made (e.g. to also build a session listing)
|
|
26
|
+
* instead of re-simplifying every session a second time just for this; and it counts a
|
|
27
|
+
* step's `resolution`/`isUnsaved` exactly as `simplifySession` derived them, with no second,
|
|
28
|
+
* possibly-diverging notion of what "resolved" means computed from the raw
|
|
29
|
+
* `appOutputResolution` fields directly. Every entry in `sessions` must have been simplified
|
|
30
|
+
* with `steps` included (the default) — one shaped with `{ includeSteps: false }` (`steps:
|
|
31
|
+
* null`) would silently contribute 0 to every step-level count.
|
|
32
|
+
*/
|
|
33
|
+
export function computeBatchStats(sessions) {
|
|
34
|
+
const stats = {
|
|
35
|
+
total: sessions.length,
|
|
36
|
+
unresolved: 0,
|
|
37
|
+
passed: 0,
|
|
38
|
+
failed: 0,
|
|
39
|
+
aborted: 0,
|
|
40
|
+
unsaved: 0,
|
|
41
|
+
totalSteps: 0,
|
|
42
|
+
unresolvedSteps: 0,
|
|
43
|
+
unsavedSteps: 0,
|
|
44
|
+
missingSteps: 0,
|
|
45
|
+
newSteps: 0,
|
|
46
|
+
};
|
|
47
|
+
for (const session of sessions) {
|
|
48
|
+
const status = (session.status ?? '').toLowerCase();
|
|
49
|
+
if (status === 'unresolved')
|
|
50
|
+
stats.unresolved++;
|
|
51
|
+
else if (status === 'passed')
|
|
52
|
+
stats.passed++;
|
|
53
|
+
else if (status === 'failed')
|
|
54
|
+
stats.failed++;
|
|
55
|
+
if (session.isAborted)
|
|
56
|
+
stats.aborted++;
|
|
57
|
+
if (session.isUnsaved)
|
|
58
|
+
stats.unsaved++;
|
|
59
|
+
for (const step of session.steps ?? []) {
|
|
60
|
+
stats.totalSteps++;
|
|
61
|
+
if (!step.isMatching && step.resolution === 'none')
|
|
62
|
+
stats.unresolvedSteps++;
|
|
63
|
+
if (step.isUnsaved)
|
|
64
|
+
stats.unsavedSteps++;
|
|
65
|
+
if (step.checkpoint.id === null)
|
|
66
|
+
stats.missingSteps++;
|
|
67
|
+
if (step.baseline.id === null)
|
|
68
|
+
stats.newSteps++;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return stats;
|
|
72
|
+
}
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// Session-level filtering
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
export const BATCH_FILTERS = ['unresolved', 'unsaved', 'mismatching'];
|
|
77
|
+
/**
|
|
78
|
+
* Narrows `sessions` to those matching `filter` — every session, unfiltered, when `filter` is
|
|
79
|
+
* omitted. `"mismatching"` keeps only sessions with at least one step that's `isMatching: false`,
|
|
80
|
+
* missing a checkpoint (`checkpoint.id === null` — the test stopped capturing this checkpoint),
|
|
81
|
+
* or new (`baseline.id === null` — the test started capturing a checkpoint with no prior baseline
|
|
82
|
+
* to compare against) — a missing/new step still needs a decision even though its own
|
|
83
|
+
* `isMatching` isn't a reliable signal either way, so it counts regardless of what `isMatching`
|
|
84
|
+
* reads. Also regardless of a step's own `resolution`: `isMatching` is fixed at comparison time
|
|
85
|
+
* and never changes afterward, including once a step is accepted/rejected and saved to the
|
|
86
|
+
* baseline, so it stays a reliable "did this ever differ" signal rather than one a decision
|
|
87
|
+
* clears. Requires `sessions` to have been simplified with `steps` included (the default); one
|
|
88
|
+
* shaped with `{ includeSteps: false }` never matches (`steps` is `null`).
|
|
89
|
+
*/
|
|
90
|
+
export function filterSessions(sessions, filter) {
|
|
91
|
+
if (!filter)
|
|
92
|
+
return sessions;
|
|
93
|
+
if (filter === 'unresolved')
|
|
94
|
+
return sessions.filter(s => s.status.toLowerCase() === 'unresolved');
|
|
95
|
+
if (filter === 'unsaved')
|
|
96
|
+
return sessions.filter(s => s.isUnsaved);
|
|
97
|
+
return sessions.filter(s => (s.steps ?? []).some(step => !step.isMatching || step.checkpoint.id === null || step.baseline.id === null));
|
|
98
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { rectContains, rectsIntersect, boundingRect } from '../geometry/geometry.js';
|
|
2
|
+
/**
|
|
3
|
+
* Returns coarse page areas containing diffs. Each rect is expanded to at least
|
|
4
|
+
* `hPad`×`vPad` (centered) before checking for overlap; overlapping inflated rects
|
|
5
|
+
* are merged into the bounding rect of their originals (not the inflated ones).
|
|
6
|
+
*/
|
|
7
|
+
export function diffAreas(rects, hPad = 50, vPad = 25) {
|
|
8
|
+
let result = rects.map(r => ({ ...r, diffCount: 1 }));
|
|
9
|
+
let merged = true;
|
|
10
|
+
while (merged) {
|
|
11
|
+
merged = false;
|
|
12
|
+
const inflated = result.map((r) => {
|
|
13
|
+
const w = Math.max(r.width, hPad);
|
|
14
|
+
const h = Math.max(r.height, vPad);
|
|
15
|
+
return { left: r.left + (r.width - w) / 2, top: r.top + (r.height - h) / 2, width: w, height: h };
|
|
16
|
+
});
|
|
17
|
+
outer: for (let i = 0; i < result.length; i++) {
|
|
18
|
+
for (let j = i + 1; j < result.length; j++) {
|
|
19
|
+
if (rectsIntersect(inflated[i], inflated[j])) {
|
|
20
|
+
const mergedRect = {
|
|
21
|
+
...boundingRect(result[i], result[j]),
|
|
22
|
+
diffCount: result[i].diffCount + result[j].diffCount,
|
|
23
|
+
};
|
|
24
|
+
result = [...result.slice(0, i), ...result.slice(i + 1, j), ...result.slice(j + 1), mergedRect];
|
|
25
|
+
merged = true;
|
|
26
|
+
break outer;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return result;
|
|
32
|
+
}
|
|
33
|
+
const COL_WIDTH = 200; // target column width; numCols = round(boundsWidth / COL_WIDTH)
|
|
34
|
+
const ROW_HEIGHT = 700; // target row height; numRows = round(boundsHeight / ROW_HEIGHT)
|
|
35
|
+
const DENSE_COVERAGE = 0.3; // min ratio of diff pixel coverage to cell area to mark a cell dense
|
|
36
|
+
const MAX_MERGE_GAP_ROWS = 1;
|
|
37
|
+
const MAX_MERGE_GAP_COLS = 1;
|
|
38
|
+
function cellKey(row, col) {
|
|
39
|
+
return `${row},${col}`;
|
|
40
|
+
}
|
|
41
|
+
function parseKey(key) {
|
|
42
|
+
const [row, col] = key.split(',').map(Number);
|
|
43
|
+
return [row, col];
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Condenses a large number of DiffAreas into grouped ContainerDiffArea objects.
|
|
47
|
+
* When the number of areas is <= threshold, returns the original array unchanged.
|
|
48
|
+
*
|
|
49
|
+
* Each area is assigned to every grid cell it overlaps. A cell is dense when the
|
|
50
|
+
* total area of diff pixels covering it meets DENSE_COVERAGE (ratio to cell area).
|
|
51
|
+
* Dense cells are flood-filled into connected components, each producing a single
|
|
52
|
+
* ContainerDiffArea bounding box. Adjacent containers (gap ≤ 1 cell, or one contains
|
|
53
|
+
* the other) are merged. Finally, any unclaimed area whose rect falls completely inside
|
|
54
|
+
* a container is absorbed into it.
|
|
55
|
+
*/
|
|
56
|
+
export function condenseDiffAreas(areas, threshold = 20) {
|
|
57
|
+
const { containers, individuals } = condenseDiffAreasDetailed(areas, threshold);
|
|
58
|
+
return [...containers, ...individuals];
|
|
59
|
+
}
|
|
60
|
+
export function condenseDiffAreasDetailed(areas, threshold = 20) {
|
|
61
|
+
if (areas.length <= threshold)
|
|
62
|
+
return { containers: [], individuals: areas };
|
|
63
|
+
// Compute bounding rect of all diff areas and derive a uniform grid.
|
|
64
|
+
// numCols/numRows are chosen so each cell is as close to COL_WIDTH × ROW_HEIGHT as possible;
|
|
65
|
+
// edge cells are never narrow/short because the grid divides the bounds evenly.
|
|
66
|
+
const boundsLeft = Math.min(...areas.map(a => a.left));
|
|
67
|
+
const boundsTop = Math.min(...areas.map(a => a.top));
|
|
68
|
+
const boundsRight = Math.max(...areas.map(a => a.left + a.width));
|
|
69
|
+
const boundsBottom = Math.max(...areas.map(a => a.top + a.height));
|
|
70
|
+
const boundsWidth = boundsRight - boundsLeft;
|
|
71
|
+
const boundsHeight = boundsBottom - boundsTop;
|
|
72
|
+
const numCols = Math.max(1, Math.round(boundsWidth / COL_WIDTH));
|
|
73
|
+
const numRows = Math.max(1, Math.round(boundsHeight / ROW_HEIGHT));
|
|
74
|
+
const colW = Math.max(1, boundsWidth / numCols);
|
|
75
|
+
const rowH = Math.max(1, boundsHeight / numRows);
|
|
76
|
+
const cellArea = colW * rowH;
|
|
77
|
+
// Assign each area to every cell it overlaps; accumulate intersection pixel area per cell
|
|
78
|
+
const cellCoverage = new Map();
|
|
79
|
+
for (const a of areas) {
|
|
80
|
+
const colMin = Math.max(0, Math.floor((a.left - boundsLeft) / colW));
|
|
81
|
+
const colMax = Math.min(numCols - 1, Math.ceil((a.left + a.width - boundsLeft) / colW) - 1);
|
|
82
|
+
const rowMin = Math.max(0, Math.floor((a.top - boundsTop) / rowH));
|
|
83
|
+
const rowMax = Math.min(numRows - 1, Math.ceil((a.top + a.height - boundsTop) / rowH) - 1);
|
|
84
|
+
for (let row = rowMin; row <= rowMax; row++) {
|
|
85
|
+
for (let col = colMin; col <= colMax; col++) {
|
|
86
|
+
const cellLeft = boundsLeft + col * colW;
|
|
87
|
+
const cellTop = boundsTop + row * rowH;
|
|
88
|
+
const iW = Math.min(a.left + a.width, cellLeft + colW) - Math.max(a.left, cellLeft);
|
|
89
|
+
const iH = Math.min(a.top + a.height, cellTop + rowH) - Math.max(a.top, cellTop);
|
|
90
|
+
const iArea = Math.max(0, iW) * Math.max(0, iH);
|
|
91
|
+
if (iArea > 0) {
|
|
92
|
+
const key = cellKey(row, col);
|
|
93
|
+
cellCoverage.set(key, (cellCoverage.get(key) ?? 0) + iArea);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
// Mark cells whose coverage ratio meets the density threshold
|
|
99
|
+
const denseCells = new Set();
|
|
100
|
+
for (const [key, coverage] of cellCoverage) {
|
|
101
|
+
if (coverage / cellArea >= DENSE_COVERAGE)
|
|
102
|
+
denseCells.add(key);
|
|
103
|
+
}
|
|
104
|
+
// 4-connected flood-fill over dense cells into components
|
|
105
|
+
const visited = new Set();
|
|
106
|
+
const components = [];
|
|
107
|
+
for (const startKey of denseCells) {
|
|
108
|
+
if (visited.has(startKey))
|
|
109
|
+
continue;
|
|
110
|
+
const component = new Set();
|
|
111
|
+
const queue = [startKey];
|
|
112
|
+
while (queue.length > 0) {
|
|
113
|
+
const key = queue.pop();
|
|
114
|
+
if (visited.has(key))
|
|
115
|
+
continue;
|
|
116
|
+
visited.add(key);
|
|
117
|
+
component.add(key);
|
|
118
|
+
const [row, col] = parseKey(key);
|
|
119
|
+
for (const [dr, dc] of [
|
|
120
|
+
[-1, 0],
|
|
121
|
+
[1, 0],
|
|
122
|
+
[0, -1],
|
|
123
|
+
[0, 1],
|
|
124
|
+
]) {
|
|
125
|
+
const neighborKey = cellKey(row + dr, col + dc);
|
|
126
|
+
if (denseCells.has(neighborKey) && !visited.has(neighborKey)) {
|
|
127
|
+
queue.push(neighborKey);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
components.push(component);
|
|
132
|
+
}
|
|
133
|
+
// For each component, claim every area that overlaps any cell in the component
|
|
134
|
+
const claimed = new Set();
|
|
135
|
+
const containers = [];
|
|
136
|
+
for (const component of components) {
|
|
137
|
+
const collectedIndices = [];
|
|
138
|
+
for (let i = 0; i < areas.length; i++) {
|
|
139
|
+
const a = areas[i];
|
|
140
|
+
const colMin = Math.max(0, Math.floor((a.left - boundsLeft) / colW));
|
|
141
|
+
const colMax = Math.min(numCols - 1, Math.ceil((a.left + a.width - boundsLeft) / colW) - 1);
|
|
142
|
+
const rowMin = Math.max(0, Math.floor((a.top - boundsTop) / rowH));
|
|
143
|
+
const rowMax = Math.min(numRows - 1, Math.ceil((a.top + a.height - boundsTop) / rowH) - 1);
|
|
144
|
+
let found = false;
|
|
145
|
+
outer: for (let row = rowMin; row <= rowMax; row++) {
|
|
146
|
+
for (let col = colMin; col <= colMax; col++) {
|
|
147
|
+
if (component.has(cellKey(row, col))) {
|
|
148
|
+
found = true;
|
|
149
|
+
break outer;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (found)
|
|
154
|
+
collectedIndices.push(i);
|
|
155
|
+
}
|
|
156
|
+
if (collectedIndices.length === 0)
|
|
157
|
+
continue;
|
|
158
|
+
for (const idx of collectedIndices)
|
|
159
|
+
claimed.add(idx);
|
|
160
|
+
let left = Infinity, top = Infinity, right = -Infinity, bottom = -Infinity;
|
|
161
|
+
let totalDiffCount = 0;
|
|
162
|
+
for (const idx of collectedIndices) {
|
|
163
|
+
const a = areas[idx];
|
|
164
|
+
left = Math.min(left, a.left);
|
|
165
|
+
top = Math.min(top, a.top);
|
|
166
|
+
right = Math.max(right, a.left + a.width);
|
|
167
|
+
bottom = Math.max(bottom, a.top + a.height);
|
|
168
|
+
totalDiffCount += a.diffCount;
|
|
169
|
+
}
|
|
170
|
+
containers.push({ left, top, width: right - left, height: bottom - top, diffCount: totalDiffCount });
|
|
171
|
+
}
|
|
172
|
+
// Merge containers that are gap-adjacent (≤ 1 cell in either axis) or where one contains the other
|
|
173
|
+
let anyMerge = true;
|
|
174
|
+
while (anyMerge) {
|
|
175
|
+
anyMerge = false;
|
|
176
|
+
outer: for (let i = 0; i < containers.length; i++) {
|
|
177
|
+
for (let j = i + 1; j < containers.length; j++) {
|
|
178
|
+
const a = containers[i];
|
|
179
|
+
const b = containers[j];
|
|
180
|
+
const hGap = Math.max(0, Math.max(a.left, b.left) - Math.min(a.left + a.width, b.left + b.width));
|
|
181
|
+
const vGap = Math.max(0, Math.max(a.top, b.top) - Math.min(a.top + a.height, b.top + b.height));
|
|
182
|
+
const contained = rectContains(a, b) || rectContains(b, a);
|
|
183
|
+
if (!contained && (hGap > MAX_MERGE_GAP_COLS * colW || vGap > MAX_MERGE_GAP_ROWS * rowH))
|
|
184
|
+
continue;
|
|
185
|
+
const left = Math.min(a.left, b.left);
|
|
186
|
+
const top = Math.min(a.top, b.top);
|
|
187
|
+
const right = Math.max(a.left + a.width, b.left + b.width);
|
|
188
|
+
const bottom = Math.max(a.top + a.height, b.top + b.height);
|
|
189
|
+
containers.splice(j, 1);
|
|
190
|
+
containers.splice(i, 1);
|
|
191
|
+
containers.push({ left, top, width: right - left, height: bottom - top, diffCount: a.diffCount + b.diffCount });
|
|
192
|
+
anyMerge = true;
|
|
193
|
+
break outer;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
// Absorb any unclaimed area whose rect falls completely within a container
|
|
198
|
+
for (let i = 0; i < areas.length; i++) {
|
|
199
|
+
if (claimed.has(i))
|
|
200
|
+
continue;
|
|
201
|
+
for (const container of containers) {
|
|
202
|
+
if (rectContains(container, areas[i])) {
|
|
203
|
+
container.diffCount += areas[i].diffCount;
|
|
204
|
+
claimed.add(i);
|
|
205
|
+
break;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return { containers, individuals: areas.filter((_, i) => !claimed.has(i)) };
|
|
210
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { extractNodeDiff } from './dom.js';
|
|
2
|
+
/**
|
|
3
|
+
* Seeds one tracker per suspect nodeId, anchored at `currentDomId`. Doesn't verify the nodeId
|
|
4
|
+
* actually exists in the current DOM — that would need fetching the whole tree just to check one
|
|
5
|
+
* id — so a bad id is simply carried through and only surfaces once (if) a real historic
|
|
6
|
+
* candidate is actually walked (`applyHistoricDom` catches it there for free, off an alignment it
|
|
7
|
+
* already has to compute). `currentStep`/`currentCapturedAt` identify and date the tracker's
|
|
8
|
+
* initial anchor, so the very first diff entry (if any) correctly attributes its checkpoint side
|
|
9
|
+
* to the current step rather than leaving it implicit.
|
|
10
|
+
*/
|
|
11
|
+
export function createHistoricDomNodeTrackers(currentDomId, nodeIds, currentStep, currentCapturedAt) {
|
|
12
|
+
return nodeIds.map(nodeId => ({
|
|
13
|
+
nodeId,
|
|
14
|
+
anchorDomId: currentDomId,
|
|
15
|
+
anchorNodeId: nodeId,
|
|
16
|
+
anchorStep: currentStep,
|
|
17
|
+
anchorCapturedAt: currentCapturedAt,
|
|
18
|
+
diffs: [],
|
|
19
|
+
}));
|
|
20
|
+
}
|
|
21
|
+
/** Groups trackers by their shared anchor domId so the caller can align a historic capture
|
|
22
|
+
* against that domId once and reuse it for every tracker in the group, instead of once per
|
|
23
|
+
* tracker. */
|
|
24
|
+
function groupByAnchorDomId(trackers) {
|
|
25
|
+
const groups = new Map();
|
|
26
|
+
for (const tracker of trackers) {
|
|
27
|
+
const group = groups.get(tracker.anchorDomId);
|
|
28
|
+
if (group)
|
|
29
|
+
group.push(tracker);
|
|
30
|
+
else
|
|
31
|
+
groups.set(tracker.anchorDomId, [tracker]);
|
|
32
|
+
}
|
|
33
|
+
return groups;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Applies exactly one older `HistoricDom` to every tracker that still has fewer than
|
|
37
|
+
* `targetDiffCount` non-empty diffs, mutating each in place — the unit of lazy evaluation a
|
|
38
|
+
* caller feeds historic DOMs into one at a time as it discovers them (see `history.ts`'s
|
|
39
|
+
* `walkHistoricSources`), so it can stop pulling further history the moment every tracker has
|
|
40
|
+
* enough evidence, rather than pre-fetching a fixed batch before knowing how much is needed.
|
|
41
|
+
*
|
|
42
|
+
* Each hop diffs a tracker's current anchor against `historicDom.domId` via a *global* alignment
|
|
43
|
+
* between the two full trees (`deps.fetchAlignment`/`extractNodeDiff`), not a standalone search
|
|
44
|
+
* for the anchor alone — a single-node content search would chase a content lookalike elsewhere
|
|
45
|
+
* on the page (e.g. a rotating carousel slot, or a live counter) rather than the element that
|
|
46
|
+
* actually took its place. When the alignment finds a counterpart, that counterpart becomes the
|
|
47
|
+
* anchor for the next (older) hop, so the walk tracks the same structural slot across a long
|
|
48
|
+
* history one hop at a time. Trackers sharing the same anchor domId are aligned against
|
|
49
|
+
* `historicDom.domId` together, once, via `groupByAnchorDomId` — the alignment itself doesn't
|
|
50
|
+
* depend on which specific node is being asked about, only on the pair of domIds, so paying for
|
|
51
|
+
* it once per distinct pair rather than once per tracker avoids pure waste (and, since
|
|
52
|
+
* `deps.fetchAlignment` is typically itself cached by domId pair, this also means a hop already
|
|
53
|
+
* paid for elsewhere — e.g. by `eyes_inspect_dom_diff`'s own two-sided diff — is never
|
|
54
|
+
* recomputed here either).
|
|
55
|
+
*
|
|
56
|
+
* When a hop's alignment finds no counterpart for the current anchor, that doesn't mean the
|
|
57
|
+
* anchor itself is wrong — it may just mean this one historic candidate is a poor comparison
|
|
58
|
+
* point (an unrelated element elsewhere on the page stealing the alignment's matching capacity,
|
|
59
|
+
* a transient loading state, and so on). Rather than reverting to some other reference node,
|
|
60
|
+
* a miss is simply skipped: nothing is recorded, and the anchor is left untouched, so the very
|
|
61
|
+
* same anchor is retried against the next (older) candidate the next time this is called. The
|
|
62
|
+
* chain resumes forward from wherever it next finds a match.
|
|
63
|
+
*
|
|
64
|
+
* A tracker whose `anchorNodeId` doesn't exist at all in whichever tree just played the
|
|
65
|
+
* checkpoint role (`extractNodeDiff`'s `found: false`) is different from a poor-comparison miss —
|
|
66
|
+
* that's `createHistoricDomNodeTrackers` never having verified the caller's nodeId, surfacing now
|
|
67
|
+
* for the first time. Every such tracker across the whole call is collected and reported in one
|
|
68
|
+
* combined error, rather than silently left to retry forever.
|
|
69
|
+
*/
|
|
70
|
+
export async function applyHistoricDom(trackers, historicDom, targetDiffCount, deps) {
|
|
71
|
+
const active = trackers.filter(t => t.diffs.length < targetDiffCount);
|
|
72
|
+
const notFound = [];
|
|
73
|
+
for (const [anchorDomId, group] of groupByAnchorDomId(active)) {
|
|
74
|
+
const alignment = await deps.fetchAlignment(historicDom.domId, anchorDomId);
|
|
75
|
+
for (const tracker of group) {
|
|
76
|
+
const result = extractNodeDiff(alignment, tracker.anchorNodeId);
|
|
77
|
+
if (!result.found) {
|
|
78
|
+
notFound.push(tracker.nodeId);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (!result.matchedNodeId)
|
|
82
|
+
continue; // poor comparison point — retry this same anchor next time
|
|
83
|
+
if (result.diff.length > 0) {
|
|
84
|
+
tracker.diffs.push({
|
|
85
|
+
checkpointStep: tracker.anchorStep,
|
|
86
|
+
checkpointCapturedAt: tracker.anchorCapturedAt,
|
|
87
|
+
baselineStep: historicDom.step,
|
|
88
|
+
baselineCapturedAt: historicDom.capturedAt,
|
|
89
|
+
diff: result.diff,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
tracker.anchorNodeId = result.matchedNodeId;
|
|
93
|
+
tracker.anchorDomId = historicDom.domId;
|
|
94
|
+
tracker.anchorStep = historicDom.step;
|
|
95
|
+
tracker.anchorCapturedAt = historicDom.capturedAt;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (notFound.length > 0) {
|
|
99
|
+
throw new Error(`Node id(s) not found in DOM snapshot: ${notFound.join(', ')}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/** Strips tracker-only bookkeeping (the chain anchor) down to the plain
|
|
103
|
+
* `{nodeId, diffs: [{checkpointStep, checkpointCapturedAt, baselineStep, baselineCapturedAt, diff}]}`
|
|
104
|
+
* output shape. */
|
|
105
|
+
export function finalizeHistoricDomNodeEvidence(trackers) {
|
|
106
|
+
return trackers.map(({ nodeId, diffs }) => ({ nodeId, diffs }));
|
|
107
|
+
}
|