@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,513 @@
|
|
|
1
|
+
import { rectsIntersect, rectContains } from '../geometry/geometry.js';
|
|
2
|
+
/**
|
|
3
|
+
* Extracts the bounding rect from an Applitools DOM snapshot node.
|
|
4
|
+
* Returns null if the node has no rect or its values are not numbers.
|
|
5
|
+
*/
|
|
6
|
+
export function nodeRect(node) {
|
|
7
|
+
const r = node?.rect;
|
|
8
|
+
if (!r)
|
|
9
|
+
return null;
|
|
10
|
+
const { left, top, width, height } = r;
|
|
11
|
+
if (typeof left !== 'number' || typeof top !== 'number' || typeof width !== 'number' || typeof height !== 'number')
|
|
12
|
+
return null;
|
|
13
|
+
return { left, top, width, height };
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* How many times larger than the tightest container an ancestor's area may be
|
|
17
|
+
* before it is reduced to a summary node (tagName + attributes only).
|
|
18
|
+
* Ancestors within this factor are kept in full because they are likely part of
|
|
19
|
+
* the direct component structure immediately surrounding the diff.
|
|
20
|
+
*/
|
|
21
|
+
const ANCESTOR_SIMILARITY_FACTOR = 5;
|
|
22
|
+
/**
|
|
23
|
+
* Internal recursive implementation. Returns the pruned node alongside the pixel area
|
|
24
|
+
* of the tightest container found in the subtree, so ancestors can decide whether they
|
|
25
|
+
* are "similar in size" and should be kept in full, or are distant and should be summarised.
|
|
26
|
+
*/
|
|
27
|
+
function getDomAreaInternal(node, area) {
|
|
28
|
+
if (!node)
|
|
29
|
+
return { nodes: [], tightestContainerArea: 0 };
|
|
30
|
+
// Non-element nodes (text "#text", comment "#comment", etc.) have no rect.
|
|
31
|
+
// Pass them through — they survive only if their parent element is retained.
|
|
32
|
+
if (!node.tagName || node.tagName.startsWith('#'))
|
|
33
|
+
return { nodes: [node], tightestContainerArea: 0 };
|
|
34
|
+
// Process children. Only retained element children vote on retention; text nodes
|
|
35
|
+
// ride along. Track the largest tightestContainerArea reported by any child.
|
|
36
|
+
let retainedElementCount = 0;
|
|
37
|
+
const prunedChildren = [];
|
|
38
|
+
let childTightestArea = 0;
|
|
39
|
+
for (const child of node.childNodes ?? []) {
|
|
40
|
+
if (child?.tagName && !child.tagName.startsWith('#')) {
|
|
41
|
+
const result = getDomAreaInternal(child, area);
|
|
42
|
+
if (result.nodes.length > 0) {
|
|
43
|
+
prunedChildren.push(...result.nodes);
|
|
44
|
+
retainedElementCount++;
|
|
45
|
+
if (result.tightestContainerArea > childTightestArea) {
|
|
46
|
+
childTightestArea = result.tightestContainerArea;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
if (child != null)
|
|
52
|
+
prunedChildren.push(child);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const r = nodeRect(node);
|
|
56
|
+
const selfIntersects = r !== null && rectsIntersect(r, area);
|
|
57
|
+
if (!selfIntersects && retainedElementCount === 0)
|
|
58
|
+
return { nodes: [], tightestContainerArea: 0 };
|
|
59
|
+
const nodeArea = r !== null ? r.width * r.height : 0;
|
|
60
|
+
const diffArea = area.width * area.height;
|
|
61
|
+
// A "container" strictly contains the area and is larger (equal-size nodes are
|
|
62
|
+
// treated as directly intersecting via selfIntersects above).
|
|
63
|
+
const isContainer = r !== null && rectContains(r, area) && nodeArea > diffArea;
|
|
64
|
+
// The "tightest container" is the innermost one: it contains the area but no
|
|
65
|
+
// retained child does. This is the most useful ancestor for source code correlation.
|
|
66
|
+
const isTightestContainer = isContainer && childTightestArea === 0;
|
|
67
|
+
const tightestContainerArea = isTightestContainer ? nodeArea : childTightestArea;
|
|
68
|
+
// Nodes that overlap the diff area without fully containing it are treated as direct
|
|
69
|
+
// neighbours of the change and always kept in full (rule 1).
|
|
70
|
+
// Nodes that contain the diff area may still be summarised if they are large enough.
|
|
71
|
+
const overlapsWithoutContaining = selfIntersects && !isContainer;
|
|
72
|
+
// A node is a distant ancestor when:
|
|
73
|
+
// - not a direct neighbour (overlaps without containing)
|
|
74
|
+
// - not the tightest container
|
|
75
|
+
// - either has no meaningful pixel area, or is much larger than the tightest container
|
|
76
|
+
// Distant ancestors are skipped entirely; their retained children are promoted up.
|
|
77
|
+
const isDistantAncestor = !overlapsWithoutContaining &&
|
|
78
|
+
!isTightestContainer &&
|
|
79
|
+
(nodeArea === 0 || (tightestContainerArea > 0 && nodeArea > ANCESTOR_SIMILARITY_FACTOR * tightestContainerArea));
|
|
80
|
+
if (isDistantAncestor) {
|
|
81
|
+
return { nodes: prunedChildren, tightestContainerArea };
|
|
82
|
+
}
|
|
83
|
+
return { nodes: [{ ...node, childNodes: prunedChildren }], tightestContainerArea };
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Recursively crops a PrunedDom tree to only the branches relevant to the given area.
|
|
87
|
+
*
|
|
88
|
+
* Retention rules:
|
|
89
|
+
* 1. Nodes that overlap the area without fully containing it get full detail — they are
|
|
90
|
+
* direct neighbours of the change.
|
|
91
|
+
* 2. The tightest container (innermost ancestor whose rect strictly contains the area,
|
|
92
|
+
* with no retained child also containing it) gets full detail.
|
|
93
|
+
* 3. Ancestors within ANCESTOR_SIMILARITY_FACTOR× the tightest container's pixel area
|
|
94
|
+
* get full detail — they are likely part of the immediate component structure.
|
|
95
|
+
* 4. All other (distant) ancestors are skipped entirely; their retained children are
|
|
96
|
+
* promoted up, so the output starts at the innermost relevant node.
|
|
97
|
+
* - Text/comment nodes ("#text", "#comment") are passed through with their parent.
|
|
98
|
+
* - Returns null if no node in the tree intersects the area.
|
|
99
|
+
*/
|
|
100
|
+
export function getDomArea(node, area) {
|
|
101
|
+
const { nodes } = getDomAreaInternal(node, area);
|
|
102
|
+
if (nodes.length === 0)
|
|
103
|
+
return null;
|
|
104
|
+
if (nodes.length === 1)
|
|
105
|
+
return nodes[0];
|
|
106
|
+
return nodes;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Validates that a domId is usable. Returns an error string if not, null if valid.
|
|
110
|
+
* Guards against passing the literal string "null" copied from `info` JSON output.
|
|
111
|
+
*/
|
|
112
|
+
export function validateDomId(domId) {
|
|
113
|
+
if (!domId || domId === 'null') {
|
|
114
|
+
return 'This step has no DOM capture (domId is null). DOM snapshots are only available for steps captured with the Applitools SDK DOM capture feature enabled.';
|
|
115
|
+
}
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
// Node ids
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
//
|
|
122
|
+
// A short, LLM-safe id embedded on every element node's top-level `id` field (a sibling of
|
|
123
|
+
// `tagName`/`rect`/`attributes`) so the model can reference a specific node unambiguously
|
|
124
|
+
// (e.g. "node 14") instead of guessing a locator. Assignment happens once, upstream of this
|
|
125
|
+
// package (server-side, see api's fetchPrunedDom), against the full unpruned tree — dom itself only
|
|
126
|
+
// provides the assignment function and the lookup. See atoms/README.md's "Node ids" section for the full rationale.
|
|
127
|
+
/**
|
|
128
|
+
* Assigns a sequential, 1-based id to every element node as a top-level `id` field, via a
|
|
129
|
+
* pre-order DFS over `root`. Mutates nodes in place.
|
|
130
|
+
*
|
|
131
|
+
* Must be called against the full, unpruned tree so a node's id is stable no matter
|
|
132
|
+
* what area a later `dom`/`dom-diff` call happens to crop to.
|
|
133
|
+
*/
|
|
134
|
+
export function assignNodeIds(root) {
|
|
135
|
+
let counter = 0;
|
|
136
|
+
function visit(node) {
|
|
137
|
+
if (!node)
|
|
138
|
+
return;
|
|
139
|
+
if (Array.isArray(node)) {
|
|
140
|
+
for (const n of node)
|
|
141
|
+
visit(n);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (!node.tagName || node.tagName.startsWith('#'))
|
|
145
|
+
return;
|
|
146
|
+
counter++;
|
|
147
|
+
node.id = String(counter);
|
|
148
|
+
for (const child of node.childNodes ?? [])
|
|
149
|
+
visit(child);
|
|
150
|
+
}
|
|
151
|
+
visit(root);
|
|
152
|
+
}
|
|
153
|
+
// The DOM API uppercases `tagName` for HTML-namespace elements regardless of source
|
|
154
|
+
// casing, but preserves the as-authored case for everything else (SVG, MathML, custom
|
|
155
|
+
// elements) — so an all-uppercase `tagName` is always an HTML-namespace normalization,
|
|
156
|
+
// never a real name, and should be lowercased back to what's actually in the markup.
|
|
157
|
+
// Live-verified: a `domSelector` built with the raw (uppercased) HTML tag name produced a
|
|
158
|
+
// region that was visually positioned correctly (rect-only match) but showed as "unlinked"
|
|
159
|
+
// to any DOM node until manually relinked, at which point the server rewrote it to
|
|
160
|
+
// lowercase — confirming the server's own matching is case-sensitive against source case.
|
|
161
|
+
const ALL_UPPERCASE_TAGNAME = /^[A-Z][A-Z0-9]*$/;
|
|
162
|
+
function toDomSelectorTagName(tagName) {
|
|
163
|
+
return ALL_UPPERCASE_TAGNAME.test(tagName) ? tagName.toLowerCase() : tagName;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* The shared id-range-skipping walk behind both `findNodeById` and `findNodeObjectById`:
|
|
167
|
+
* resolves a single node id against the full, unpruned, already-id-assigned tree, returning
|
|
168
|
+
* both the node itself and its `domSelector` (a `/tagName[i]` path from the root, `i` being
|
|
169
|
+
* the 0-based index among siblings sharing that tag name). Returns `null` if the id isn't
|
|
170
|
+
* present in the tree.
|
|
171
|
+
*
|
|
172
|
+
* Because `assignNodeIds` numbers nodes in pre-order with a single counter, every node's
|
|
173
|
+
* subtree occupies a contiguous, gapless id range starting at its own id, and sibling
|
|
174
|
+
* subtrees' ranges are strictly ordered left-to-right with no overlap — a sibling's own
|
|
175
|
+
* id is exactly the exclusive upper bound of the *previous* sibling's subtree range. This
|
|
176
|
+
* lets the search skip an entire sibling subtree without visiting a single node in it,
|
|
177
|
+
* whenever the target id clearly falls outside that subtree's range, rather than only
|
|
178
|
+
* stopping once it happens to reach the target by a plain linear walk.
|
|
179
|
+
*/
|
|
180
|
+
function searchNodeById(root, domId) {
|
|
181
|
+
const target = Number(domId);
|
|
182
|
+
function search(node, path, upperBoundExclusive) {
|
|
183
|
+
if (!node || !node.tagName || node.tagName.startsWith('#'))
|
|
184
|
+
return null;
|
|
185
|
+
const nodeId = Number(node.id);
|
|
186
|
+
if (!Number.isFinite(nodeId) || target < nodeId || target >= upperBoundExclusive)
|
|
187
|
+
return null;
|
|
188
|
+
if (target === nodeId)
|
|
189
|
+
return { node, path };
|
|
190
|
+
const elementChildren = (node.childNodes ?? []).filter((c) => c?.tagName && !c.tagName.startsWith('#'));
|
|
191
|
+
const tagCounts = new Map();
|
|
192
|
+
for (let i = 0; i < elementChildren.length; i++) {
|
|
193
|
+
const child = elementChildren[i];
|
|
194
|
+
// Siblings' id ranges are contiguous and strictly ascending (see the class doc above), so
|
|
195
|
+
// once target is smaller than this child's own id, it can't be in this or any later
|
|
196
|
+
// sibling either — nothing left to find. Safe to stop here without finishing tagCounts:
|
|
197
|
+
// nothing downstream will read it once we're about to return null from this node.
|
|
198
|
+
if (target < Number(child.id))
|
|
199
|
+
break;
|
|
200
|
+
// tagCounts must be updated for every sibling in order, even ones we skip below without
|
|
201
|
+
// recursing into — it's this child's index among same-tag siblings, needed to number any
|
|
202
|
+
// later sibling sharing its tag correctly, independent of whether target turns out to be
|
|
203
|
+
// in this particular child's subtree.
|
|
204
|
+
const index = tagCounts.get(child.tagName) ?? 0;
|
|
205
|
+
tagCounts.set(child.tagName, index + 1);
|
|
206
|
+
// This child's subtree ends where the next sibling's own id begins; the last
|
|
207
|
+
// child inherits the bound passed down from this node's own caller.
|
|
208
|
+
const next = elementChildren[i + 1];
|
|
209
|
+
const childUpperBound = next ? Number(next.id) : upperBoundExclusive;
|
|
210
|
+
// target lies past this child's entire subtree — skip recursing into it, but its
|
|
211
|
+
// tagCounts increment above must still happen (see comment there).
|
|
212
|
+
if (target >= childUpperBound)
|
|
213
|
+
continue;
|
|
214
|
+
const found = search(child, `${path}/${toDomSelectorTagName(child.tagName)}[${index}]`, childUpperBound);
|
|
215
|
+
if (found)
|
|
216
|
+
return found;
|
|
217
|
+
}
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
return search(root, `/${toDomSelectorTagName(root?.tagName ?? '')}`, Infinity);
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Resolves a single node id to its `domSelector` and `rect`. Returns `null` if the id isn't
|
|
224
|
+
* present in the tree, or if the node found has no usable rect.
|
|
225
|
+
*/
|
|
226
|
+
export function findNodeById(root, domId) {
|
|
227
|
+
const found = searchNodeById(root, domId);
|
|
228
|
+
if (!found)
|
|
229
|
+
return null;
|
|
230
|
+
const rect = nodeRect(found.node);
|
|
231
|
+
return rect ? { domSelector: found.path, rect: roundRect(rect) } : null;
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Resolves a single node id to the node object itself — unlike `findNodeById`, which only
|
|
235
|
+
* returns `{domSelector, rect}`. Needed wherever a caller must inspect or serialize the node
|
|
236
|
+
* itself, not just locate it. Returns `null` if the id isn't present in the tree.
|
|
237
|
+
*/
|
|
238
|
+
export function findNodeObjectById(root, domId) {
|
|
239
|
+
return searchNodeById(root, domId)?.node ?? null;
|
|
240
|
+
}
|
|
241
|
+
const DOM_SELECTOR_SEGMENT_RE = /^([^[]+)\[(\d+)]$/;
|
|
242
|
+
/**
|
|
243
|
+
* The inverse of `findNodeById`: walks `root` following a `domSelector` path (e.g.
|
|
244
|
+
* `/html/body[0]/div[2]`, as produced by `findNodeById`/`findNodeByRect`) and returns the
|
|
245
|
+
* rect of whatever element it still resolves to — or `null` if it doesn't (the element that
|
|
246
|
+
* path was built against has been removed, or a sibling's tag/order changed enough to shift
|
|
247
|
+
* indices). Returns the same `{domSelector, rect}` shape as `findNodeById`/`findNodeByRect`,
|
|
248
|
+
* so callers can use whichever of the three applies interchangeably.
|
|
249
|
+
*
|
|
250
|
+
* The first path segment is the root's own bare tag name with no `[index]` (see
|
|
251
|
+
* `findNodeById`) — there's only one root, so it's skipped rather than checked. Every
|
|
252
|
+
* segment after that must match `tagName[index]`, `index` being 0-based among `node`'s
|
|
253
|
+
* children sharing that tag.
|
|
254
|
+
*/
|
|
255
|
+
export function findNodeBySelector(root, path) {
|
|
256
|
+
const segments = path.split('/').filter(Boolean);
|
|
257
|
+
if (segments.length === 0)
|
|
258
|
+
return null;
|
|
259
|
+
let node = root;
|
|
260
|
+
for (let i = 1; i < segments.length; i++) {
|
|
261
|
+
const match = DOM_SELECTOR_SEGMENT_RE.exec(segments[i]);
|
|
262
|
+
if (!match)
|
|
263
|
+
return null;
|
|
264
|
+
const [, tagName, indexStr] = match;
|
|
265
|
+
const children = (node?.childNodes ?? []).filter((c) => c?.tagName && !c.tagName.startsWith('#') && toDomSelectorTagName(c.tagName) === tagName);
|
|
266
|
+
node = children[Number(indexStr)];
|
|
267
|
+
if (!node)
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
const rect = nodeRect(node);
|
|
271
|
+
return rect ? { domSelector: path, rect: roundRect(rect) } : null;
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Resolves a batch of node ids against the same already-fetched, already-id-assigned
|
|
275
|
+
* tree, one `findNodeById` walk per id. Pure — takes the tree as-is, does no fetching of
|
|
276
|
+
* its own (callers fetch the DOM snapshot once and reuse it here).
|
|
277
|
+
*
|
|
278
|
+
* Throws if any id can't be resolved, naming all of them, rather than silently omitting
|
|
279
|
+
* an entry from the returned map — a caller indexing the result by id (e.g.
|
|
280
|
+
* `result.get(id)!`) would otherwise get `undefined` for a bad id with no signal why.
|
|
281
|
+
*/
|
|
282
|
+
export function resolveNodeIdsToNodes(root, nodeIds) {
|
|
283
|
+
const result = new Map();
|
|
284
|
+
const missing = [];
|
|
285
|
+
for (const id of nodeIds) {
|
|
286
|
+
const resolved = findNodeById(root, id);
|
|
287
|
+
if (resolved)
|
|
288
|
+
result.set(id, resolved);
|
|
289
|
+
else
|
|
290
|
+
missing.push(id);
|
|
291
|
+
}
|
|
292
|
+
if (missing.length > 0) {
|
|
293
|
+
throw new Error(`Node id(s) not found in DOM snapshot: ${missing.join(', ')}`);
|
|
294
|
+
}
|
|
295
|
+
return result;
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Finds the element node in `root` whose rect most closely matches `targetRect` — within
|
|
299
|
+
* `tolerancePx` on all four of left/top/width/height — returning the same {domSelector,
|
|
300
|
+
* rect} shape as `findNodeById`. Lets a caller upgrade a raw pixel rect (e.g. one eyeballed off
|
|
301
|
+
* a screenshot) into a domSelector-backed region when a matching element actually exists,
|
|
302
|
+
* so the region tracks that element rather than a fixed set of pixels. Returns `null` if no
|
|
303
|
+
* node is within tolerance. When multiple nodes qualify (e.g. a wrapper and its one child
|
|
304
|
+
* share nearly the same rect), the one with the smallest total deviation wins.
|
|
305
|
+
*/
|
|
306
|
+
export function findNodeByRect(root, targetRect, tolerancePx = 5) {
|
|
307
|
+
let best = null;
|
|
308
|
+
let bestDelta = Infinity;
|
|
309
|
+
for (const { node } of flattenTree(root)) {
|
|
310
|
+
const r = nodeRect(node);
|
|
311
|
+
if (!r)
|
|
312
|
+
continue;
|
|
313
|
+
const dLeft = Math.abs(r.left - targetRect.left);
|
|
314
|
+
const dTop = Math.abs(r.top - targetRect.top);
|
|
315
|
+
const dWidth = Math.abs(r.width - targetRect.width);
|
|
316
|
+
const dHeight = Math.abs(r.height - targetRect.height);
|
|
317
|
+
if (dLeft > tolerancePx || dTop > tolerancePx || dWidth > tolerancePx || dHeight > tolerancePx)
|
|
318
|
+
continue;
|
|
319
|
+
const delta = dLeft + dTop + dWidth + dHeight;
|
|
320
|
+
if (delta < bestDelta) {
|
|
321
|
+
bestDelta = delta;
|
|
322
|
+
best = node;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
return best ? findNodeById(root, best.id) : null;
|
|
326
|
+
}
|
|
327
|
+
// ---------------------------------------------------------------------------
|
|
328
|
+
// Reading-order sort
|
|
329
|
+
// ---------------------------------------------------------------------------
|
|
330
|
+
function readingOrderCompare(a, b) {
|
|
331
|
+
const aR = a.rect, bR = b.rect;
|
|
332
|
+
if (!aR && !bR)
|
|
333
|
+
return 0;
|
|
334
|
+
if (!aR)
|
|
335
|
+
return 1;
|
|
336
|
+
if (!bR)
|
|
337
|
+
return -1;
|
|
338
|
+
const aBottom = aR.top + aR.height;
|
|
339
|
+
const bBottom = bR.top + bR.height;
|
|
340
|
+
const overlapTop = Math.max(aR.top, bR.top);
|
|
341
|
+
const overlapBottom = Math.min(aBottom, bBottom);
|
|
342
|
+
const overlap = overlapBottom - overlapTop;
|
|
343
|
+
const minHeight = Math.min(aR.height, bR.height);
|
|
344
|
+
// "Same line" when vertical overlap > 50% of the shorter element's height
|
|
345
|
+
if (minHeight > 0 && overlap / minHeight > 0.5) {
|
|
346
|
+
return aR.left - bR.left;
|
|
347
|
+
}
|
|
348
|
+
return aR.top - bR.top;
|
|
349
|
+
}
|
|
350
|
+
// ---------------------------------------------------------------------------
|
|
351
|
+
// flattenTree
|
|
352
|
+
// ---------------------------------------------------------------------------
|
|
353
|
+
/** Kept past the DOM-diff backend cutover: `findNodeByRect` walks the tree through it. */
|
|
354
|
+
export function flattenTree(root) {
|
|
355
|
+
const result = [];
|
|
356
|
+
function traverse(node, depth, parent) {
|
|
357
|
+
if (!node)
|
|
358
|
+
return;
|
|
359
|
+
if (Array.isArray(node)) {
|
|
360
|
+
for (const n of node)
|
|
361
|
+
traverse(n, depth, parent);
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
// Skip text/comment nodes and elements without a rect
|
|
365
|
+
if (!node.tagName || node.tagName.startsWith('#'))
|
|
366
|
+
return;
|
|
367
|
+
if (!node.rect)
|
|
368
|
+
return;
|
|
369
|
+
const flat = { node, depth, parent };
|
|
370
|
+
result.push(flat);
|
|
371
|
+
const elementChildren = (node.childNodes ?? []).filter((c) => c?.tagName && !c.tagName.startsWith('#') && c.rect);
|
|
372
|
+
const sorted = [...elementChildren].sort(readingOrderCompare);
|
|
373
|
+
for (const child of sorted) {
|
|
374
|
+
traverse(child, depth + 1, flat);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
traverse(root, 0, null);
|
|
378
|
+
return result;
|
|
379
|
+
}
|
|
380
|
+
// ---------------------------------------------------------------------------
|
|
381
|
+
// Property helpers
|
|
382
|
+
// ---------------------------------------------------------------------------
|
|
383
|
+
function roundRect(r) {
|
|
384
|
+
if (!r)
|
|
385
|
+
return r;
|
|
386
|
+
return {
|
|
387
|
+
left: Math.round(r.left),
|
|
388
|
+
top: Math.round(r.top),
|
|
389
|
+
width: Math.round(r.width),
|
|
390
|
+
height: Math.round(r.height),
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* Returns whether an Applitools DOM capture node could plausibly paint a pixel: it has a
|
|
395
|
+
* non-zero-size rect and isn't hidden via `display:none`, `visibility:hidden`,
|
|
396
|
+
* `opacity:0`, or the `hidden` HTML attribute.
|
|
397
|
+
*/
|
|
398
|
+
export function isVisible(node) {
|
|
399
|
+
const r = node.rect;
|
|
400
|
+
if (!r || r.width <= 0 || r.height <= 0)
|
|
401
|
+
return false;
|
|
402
|
+
const style = node.style ?? {};
|
|
403
|
+
if ((style.display ?? '').toString().toLowerCase() === 'none')
|
|
404
|
+
return false;
|
|
405
|
+
if ((style.visibility ?? '').toString().toLowerCase() === 'hidden')
|
|
406
|
+
return false;
|
|
407
|
+
const opacity = parseFloat(style.opacity);
|
|
408
|
+
if (!Number.isNaN(opacity) && opacity === 0)
|
|
409
|
+
return false;
|
|
410
|
+
if (Object.prototype.hasOwnProperty.call(node.attributes ?? {}, 'hidden'))
|
|
411
|
+
return false;
|
|
412
|
+
return true;
|
|
413
|
+
}
|
|
414
|
+
// ---------------------------------------------------------------------------
|
|
415
|
+
// filterDiffToArea (final pass — cropping)
|
|
416
|
+
// ---------------------------------------------------------------------------
|
|
417
|
+
//
|
|
418
|
+
// Restricts an already-computed diff to entries relevant to a given area. This must run
|
|
419
|
+
// LAST, after matching/diffing has completed over the full, unrestricted trees. Cropping
|
|
420
|
+
// the input trees before matching (e.g. pruning both sides to `area` before flattening)
|
|
421
|
+
// loses cross-subtree context and can cause the LCS matcher to align the wrong nodes, or
|
|
422
|
+
// fail to recognize an unpruned ancestor as the same persisting element — see
|
|
423
|
+
// the node-ids/cropping caveat in atoms/README.md's "DOM diffing" section.
|
|
424
|
+
function entryIntersectsArea(item, area) {
|
|
425
|
+
if (item.status === 'displaced') {
|
|
426
|
+
return rectsIntersect(item.rect, area) || rectsIntersect(item.baseline.rect, area);
|
|
427
|
+
}
|
|
428
|
+
if (item.status === 'changed') {
|
|
429
|
+
return rectsIntersect(item.rect, area) || rectsIntersect(item.baseline.rect, area);
|
|
430
|
+
}
|
|
431
|
+
// 'new' (checkpoint-side only) or 'missing', singleton or collapsed (baseline-side only) —
|
|
432
|
+
// either way, item.rect is the one side that exists for this entry.
|
|
433
|
+
return rectsIntersect(item.rect, area);
|
|
434
|
+
}
|
|
435
|
+
export function filterDiffToArea(diffs, area) {
|
|
436
|
+
return diffs.map(item => (item !== null && entryIntersectsArea(item, area) ? item : null));
|
|
437
|
+
}
|
|
438
|
+
// ---------------------------------------------------------------------------
|
|
439
|
+
// excludeNonVisualNodeDiffs (final pass — visual-diff-rect filtering)
|
|
440
|
+
// ---------------------------------------------------------------------------
|
|
441
|
+
//
|
|
442
|
+
// Restricts an already-computed diff to entries that correspond to an actual visual diff —
|
|
443
|
+
// e.g. a match region (ignore/dynamic/layout/etc.) suppresses a pixel diff but not necessarily
|
|
444
|
+
// a DOM-level structural difference underneath it. Like `filterDiffToArea`, this must run
|
|
445
|
+
// LAST, after matching/diffing has completed over the full, unrestricted trees — pruning the
|
|
446
|
+
// input trees themselves before matching risks the same alignment-quality loss described above.
|
|
447
|
+
// Unlike `filterDiffToArea`, baseline-side and checkpoint-side rects are checked against their
|
|
448
|
+
// own, independent rect list (a step's visual diff is itself two-way — see `compareStep`/the
|
|
449
|
+
// `GET .../diff` endpoint's own `image1`/`image2` split), not a single shared area.
|
|
450
|
+
function entryIntersectsVisualDiff(item, baselineDiffs, checkpointDiffs) {
|
|
451
|
+
const intersectsAny = (r, rects) => !!r && rects.some(rect => rectsIntersect(r, rect));
|
|
452
|
+
if (item.status === 'displaced') {
|
|
453
|
+
return intersectsAny(item.rect, checkpointDiffs) || intersectsAny(item.baseline.rect, baselineDiffs);
|
|
454
|
+
}
|
|
455
|
+
if (item.status === 'changed') {
|
|
456
|
+
return intersectsAny(item.rect, checkpointDiffs) || intersectsAny(item.baseline.rect, baselineDiffs);
|
|
457
|
+
}
|
|
458
|
+
if (item.status === 'new') {
|
|
459
|
+
return intersectsAny(item.rect, checkpointDiffs);
|
|
460
|
+
}
|
|
461
|
+
// 'missing', singleton or collapsed — baseline-only subtree
|
|
462
|
+
return intersectsAny(item.rect, baselineDiffs);
|
|
463
|
+
}
|
|
464
|
+
export function excludeNonVisualNodeDiffs(diffs, baselineDiffs, checkpointDiffs) {
|
|
465
|
+
return diffs.map(item => item !== null && entryIntersectsVisualDiff(item, baselineDiffs, checkpointDiffs) ? item : null);
|
|
466
|
+
}
|
|
467
|
+
// ---------------------------------------------------------------------------
|
|
468
|
+
// serializeDiff
|
|
469
|
+
// ---------------------------------------------------------------------------
|
|
470
|
+
export function serializeDiff(diffs) {
|
|
471
|
+
const lines = [];
|
|
472
|
+
for (const diff of diffs) {
|
|
473
|
+
if (diff === null)
|
|
474
|
+
continue;
|
|
475
|
+
const { depth, ...obj } = diff;
|
|
476
|
+
lines.push(' '.repeat(depth) + JSON.stringify(obj));
|
|
477
|
+
}
|
|
478
|
+
return lines.join('\n');
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* Extracts one target node's aligned counterpart and rect-filtered diff from an
|
|
482
|
+
* already-computed alignment (the backend's `fetchDomDiff` result, or an equivalent
|
|
483
|
+
* `DomAlignment` obtained some other way) — the cheap part once the
|
|
484
|
+
* whole-tree alignment is done, so a caller with several target nodes against the same pair of
|
|
485
|
+
* trees runs this once per node without paying for the alignment itself more than once.
|
|
486
|
+
* `targetNodeId` must be a node id assigned (see `assignNodeIds`) on whichever tree was aligned
|
|
487
|
+
* as the checkpoint side — its matched baseline id and its own checkpoint rect are read straight
|
|
488
|
+
* off `alignment.matches` by that id, so a caller never needs to hold onto the actual node object,
|
|
489
|
+
* only its id, and this works just as well against an alignment that was serialized (e.g. read
|
|
490
|
+
* back from a cache or a network response) as against one just computed in-process.
|
|
491
|
+
*
|
|
492
|
+
* Reading a target's own match off a *global*, whole-tree alignment — rather than a standalone
|
|
493
|
+
* best-match search for that one node — is deliberate: an element that occupies the same
|
|
494
|
+
* structural position across captures but now shows entirely different content (e.g. a rotating
|
|
495
|
+
* carousel slot, or a live counter) would otherwise get matched to wherever its *old* content
|
|
496
|
+
* happens to reappear elsewhere on the page, rather than to whatever now occupies its own slot.
|
|
497
|
+
* Aligning the whole page at once keeps a node's in-place replacement — not a content lookalike
|
|
498
|
+
* somewhere else — as its counterpart, because the aligner is scored on overall sequence
|
|
499
|
+
* correspondence, not one node in isolation.
|
|
500
|
+
*/
|
|
501
|
+
export function extractNodeDiff(alignment, targetNodeId) {
|
|
502
|
+
if (!(targetNodeId in alignment.matches))
|
|
503
|
+
return { found: false, matchedNodeId: null, diff: '' };
|
|
504
|
+
const match = alignment.matches[targetNodeId];
|
|
505
|
+
if (!match)
|
|
506
|
+
return { found: true, matchedNodeId: null, diff: '' };
|
|
507
|
+
const result = filterDiffToArea(alignment.diff, match.checkpointRect);
|
|
508
|
+
const diff = serializeDiff(result)
|
|
509
|
+
.split('\n')
|
|
510
|
+
.map(line => line.trimStart())
|
|
511
|
+
.join('\n');
|
|
512
|
+
return { found: true, matchedNodeId: match.baselineId, diff };
|
|
513
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The subset of `AnnotationCategory` (`sessions/regions.ts`) Applitools Eyes' own
|
|
3
|
+
* `ImageMatchSettings` schema actually has a field for — a raw session's own
|
|
4
|
+
* `imageMatchSettings` object never has a `mismatching` or `remarks` key, unlike
|
|
5
|
+
* `annotations`/`identifiedRegions`, which do (those two are a dashboard/annotations-only
|
|
6
|
+
* concept, not a real match-settings one).
|
|
7
|
+
*/
|
|
8
|
+
export const MATCH_SETTINGS_CATEGORIES = [
|
|
9
|
+
'content',
|
|
10
|
+
'dynamic',
|
|
11
|
+
'floating',
|
|
12
|
+
'ignore',
|
|
13
|
+
'layout',
|
|
14
|
+
'strict',
|
|
15
|
+
'accessibility',
|
|
16
|
+
];
|
|
17
|
+
function pickCategories(bucket) {
|
|
18
|
+
const result = {};
|
|
19
|
+
for (const category of MATCH_SETTINGS_CATEGORIES) {
|
|
20
|
+
result[category] = bucket?.[category] ?? [];
|
|
21
|
+
}
|
|
22
|
+
return result;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Builds the full `ImageMatchSettings` an `images/compare` request body expects for a step —
|
|
26
|
+
* ready to use as-is. The rest of the request (`image1`/`image2`/`sessionId`/`batchId`/
|
|
27
|
+
* `runningSessionId`/`stepIndex`) is the caller's own job, not this function's — none of those
|
|
28
|
+
* are match-settings concerns.
|
|
29
|
+
*
|
|
30
|
+
* - Scalars (`matchLevel`, `scale`, `ignoreCaret`, `ignoreDisplacements`, `useDom`, etc.) come
|
|
31
|
+
* straight from `actualAppOutput[stepIndex].imageMatchSettings` — the match settings actually
|
|
32
|
+
* in effect for this run.
|
|
33
|
+
* - `identifiedRegions` (this run's own coded regions) also comes from that same
|
|
34
|
+
* `imageMatchSettings` object's own per-category arrays — never falling back to the
|
|
35
|
+
* baseline's `identifiedRegions` snapshot: if a category has no entry there, there is no coded
|
|
36
|
+
* region of that kind in the current match, full stop.
|
|
37
|
+
* - The plain, domSelector-anchored region categories are overwritten from
|
|
38
|
+
* `appOutputResolution[stepIndex].annotations` if a resolution exists, else
|
|
39
|
+
* `expectedAppOutput[stepIndex].annotations` (the baseline's own regions) — since
|
|
40
|
+
* `imageMatchSettings`'s own arrays only reflect what was true when the test last ran, not any
|
|
41
|
+
* region a reviewer added since.
|
|
42
|
+
*
|
|
43
|
+
* No baseline/checkpoint side-picking or healing happens here: the raw session already resolves
|
|
44
|
+
* which side a region describes (see `regions.ts`'s `getEffectiveRegions`, used instead for a
|
|
45
|
+
* diagnostic display that needs both sides shown) — this only ever needs the one side already in
|
|
46
|
+
* effect, relayed as-is, by reference.
|
|
47
|
+
*/
|
|
48
|
+
export function getEffectiveMatchSettings(rawSession, stepIndex) {
|
|
49
|
+
const imageMatchSettings = rawSession?.actualAppOutput?.[stepIndex]?.imageMatchSettings ?? {};
|
|
50
|
+
const annotations = rawSession?.appOutputResolution?.[stepIndex]?.annotations ?? rawSession?.expectedAppOutput?.[stepIndex]?.annotations;
|
|
51
|
+
return {
|
|
52
|
+
...imageMatchSettings,
|
|
53
|
+
...pickCategories(annotations),
|
|
54
|
+
identifiedRegions: pickCategories(imageMatchSettings),
|
|
55
|
+
};
|
|
56
|
+
}
|