@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,280 @@
|
|
|
1
|
+
import { findNodeByRect, findNodeBySelector } from './dom.js';
|
|
2
|
+
export const ANNOTATION_CATEGORIES = [
|
|
3
|
+
'content',
|
|
4
|
+
'dynamic',
|
|
5
|
+
'floating',
|
|
6
|
+
'ignore',
|
|
7
|
+
'mismatching',
|
|
8
|
+
'layout',
|
|
9
|
+
'remarks',
|
|
10
|
+
'strict',
|
|
11
|
+
'accessibility',
|
|
12
|
+
];
|
|
13
|
+
export function emptyAnnotations() {
|
|
14
|
+
return {
|
|
15
|
+
content: [],
|
|
16
|
+
dynamic: [],
|
|
17
|
+
floating: [],
|
|
18
|
+
ignore: [],
|
|
19
|
+
mismatching: [],
|
|
20
|
+
layout: [],
|
|
21
|
+
remarks: [],
|
|
22
|
+
strict: [],
|
|
23
|
+
accessibility: [],
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Determines which side's DOM a step's regions should reference. `accept`/`reject` always
|
|
28
|
+
* resolve against the side they're about to make the (new) baseline going forward —
|
|
29
|
+
* checkpoint or baseline respectively. `unchanged` means an action that doesn't itself
|
|
30
|
+
* change the step's resolution (e.g. `baseline-regions`/`checkpoint-regions`, or just
|
|
31
|
+
* inspecting current state), so it must defer to whatever the step's *existing* resolution
|
|
32
|
+
* already is: a step accepted (but not yet saved) by an earlier call already has the
|
|
33
|
+
* checkpoint as its baseline-to-be, so a region added now should reference that, not the
|
|
34
|
+
* original baseline. Only an explicit prior reject, or no resolution at all, means the
|
|
35
|
+
* original baseline is still the one to reference. `expectedAppOutput`/`actualAppOutput`
|
|
36
|
+
* never change to reflect a pending resolution — only
|
|
37
|
+
* `appOutputResolution[stepIndex].replaceExpected` does.
|
|
38
|
+
*/
|
|
39
|
+
export function resolveDomIdSide(action, rawSession, stepIndex) {
|
|
40
|
+
if (action === 'accept')
|
|
41
|
+
return 'checkpoint';
|
|
42
|
+
if (action === 'reject')
|
|
43
|
+
return 'baseline';
|
|
44
|
+
const resolutionEntry = rawSession?.appOutputResolution?.[stepIndex];
|
|
45
|
+
return resolutionEntry?.replaceExpected === true ? 'checkpoint' : 'baseline';
|
|
46
|
+
}
|
|
47
|
+
/** The raw session's own image record (`{ id, domId, size, hasDom, location, rectangle }`) for a step's baseline or checkpoint image. */
|
|
48
|
+
export function imageForSide(rawSession, stepIndex, side) {
|
|
49
|
+
return side === 'baseline'
|
|
50
|
+
? rawSession?.expectedAppOutput?.[stepIndex]?.image
|
|
51
|
+
: rawSession?.actualAppOutput?.[stepIndex]?.image;
|
|
52
|
+
}
|
|
53
|
+
function flattenCategoryBucket(source) {
|
|
54
|
+
const result = [];
|
|
55
|
+
if (!source)
|
|
56
|
+
return result;
|
|
57
|
+
for (const category of ANNOTATION_CATEGORIES) {
|
|
58
|
+
for (const region of source[category] ?? []) {
|
|
59
|
+
result.push({ category, region });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return result;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Pairs up two flattened coded-region lists — one per image side — into `RegionPair`s by
|
|
66
|
+
* matching `regionId` within each category (a coded region's `regionId` is the same code-level
|
|
67
|
+
* locator/name on both sides, so it's a stable join key). Only ever produces one pair per
|
|
68
|
+
* `checkpoint` entry — a `baseline` entry with no matching `checkpoint` regionId is dropped
|
|
69
|
+
* entirely, not surfaced with `checkpoint` absent: `imageMatchSettings` reflects what the test
|
|
70
|
+
* run just asked for, so a coded region no longer requested by the checkpoint isn't part of
|
|
71
|
+
* the effective set, even if a stale snapshot of it still lingers in `identifiedRegions`. Every
|
|
72
|
+
* returned pair's `checkpoint` is therefore always populated, by construction, even though
|
|
73
|
+
* `RegionPair`'s own type doesn't require it.
|
|
74
|
+
*/
|
|
75
|
+
function pairCodedRegions(baseline, checkpoint) {
|
|
76
|
+
return checkpoint.map(c => {
|
|
77
|
+
const match = baseline.find(b => b.category === c.category && b.region.regionId === c.region.regionId);
|
|
78
|
+
return match
|
|
79
|
+
? { category: c.category, baseline: match.region, checkpoint: c.region }
|
|
80
|
+
: { category: c.category, checkpoint: c.region };
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Extracts every region-shaped structure the raw session encodes for a step, as
|
|
85
|
+
* baseline/checkpoint pairs — either side of a pair may be `undefined`, for a caller to fill
|
|
86
|
+
* in later (e.g. via `checkRegions` and the other functions below):
|
|
87
|
+
*
|
|
88
|
+
* - `regions` — domSelector-backed `Region[]`, from `annotations`. Only one side is ever
|
|
89
|
+
* populated here, since the raw session never encodes an `annotations`-shaped structure for
|
|
90
|
+
* both sides at once — `resolveDomIdSide` determines which side these domSelectors actually
|
|
91
|
+
* describe. `appOutputResolution[stepIndex].annotations`, when present, overrides
|
|
92
|
+
* `expectedAppOutput[stepIndex].annotations` entirely rather than being combined with it —
|
|
93
|
+
* the same way an empty category there means "resolved to have none" even when the
|
|
94
|
+
* baseline has one.
|
|
95
|
+
* - `codedRegions` — `regionId`-backed `Region[]`, paired up from `identifiedRegions` (on
|
|
96
|
+
* `expectedAppOutput`/`appOutputResolution`, same override rule as `annotations` — always the
|
|
97
|
+
* baseline side) and `imageMatchSettings` (on `actualAppOutput` — always the checkpoint side).
|
|
98
|
+
* Only ever includes an entry that's defined on the checkpoint side — a baseline
|
|
99
|
+
* `identifiedRegions` entry with no matching checkpoint `regionId` is dropped entirely (see
|
|
100
|
+
* `pairCodedRegions`), so `checkpoint` is never `undefined` here, only `baseline`.
|
|
101
|
+
*
|
|
102
|
+
* Both `annotations` and `identifiedRegions` deliberately exclude `removedAnnotations` — a
|
|
103
|
+
* historical record of past removals, not part of the current, effective region set.
|
|
104
|
+
*/
|
|
105
|
+
export function getEffectiveRegions(rawSession, stepIndex) {
|
|
106
|
+
const flatRegions = flattenCategoryBucket(rawSession?.appOutputResolution?.[stepIndex]?.annotations ??
|
|
107
|
+
rawSession?.expectedAppOutput?.[stepIndex]?.annotations);
|
|
108
|
+
const regionsOnBaseline = resolveDomIdSide('unchanged', rawSession, stepIndex) === 'baseline';
|
|
109
|
+
const regions = flatRegions.map(({ category, region }) => regionsOnBaseline ? { category, baseline: region } : { category, checkpoint: region });
|
|
110
|
+
const baselineCoded = flattenCategoryBucket(rawSession?.appOutputResolution?.[stepIndex]?.identifiedRegions ??
|
|
111
|
+
rawSession?.expectedAppOutput?.[stepIndex]?.identifiedRegions);
|
|
112
|
+
const checkpointCoded = flattenCategoryBucket(rawSession?.actualAppOutput?.[stepIndex]?.imageMatchSettings);
|
|
113
|
+
return { regions, codedRegions: pairCodedRegions(baselineCoded, checkpointCoded) };
|
|
114
|
+
}
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
// Healing — filling in whichever side of a RegionPair the raw session doesn't encode
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
// findNodeBySelector/findNodeByRect return a nested {domSelector, rect} shape (DomNode);
|
|
119
|
+
// the Region shape needed elsewhere is flat (domSelector alongside left/top/width/height).
|
|
120
|
+
function toRegion(resolved) {
|
|
121
|
+
return { domSelector: resolved.domSelector, ...resolved.rect };
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Splits every incomplete `RegionPair` (missing one side — see `getEffectiveRegions`) into
|
|
125
|
+
* anchored/non-anchored, verifying each anchored candidate's `domSelector` against `domSource`
|
|
126
|
+
* (the DOM of whichever side is already populated) along the way. Purely a classification —
|
|
127
|
+
* nothing is mutated, so a caller that just wants to inspect a step's regions as currently
|
|
128
|
+
* encoded (e.g. `eyes-inspect`'s `regions` command) can call this without side effects: a
|
|
129
|
+
* stale `domSelector` (one that no longer resolves) lands in `nonAnchored` exactly as encoded,
|
|
130
|
+
* not cleared — only a caller that intends to *heal* it (see `clearStaleSelectors`, below)
|
|
131
|
+
* should clear it. `anchored` means "has a `domSelector` that actually resolved" — if
|
|
132
|
+
* `domSource` is `null` (couldn't be fetched), nothing can be verified, so every pair lands in
|
|
133
|
+
* `nonAnchored`, domSelector or not.
|
|
134
|
+
*/
|
|
135
|
+
export function checkRegions(regions, domSource) {
|
|
136
|
+
const anchored = [];
|
|
137
|
+
const nonAnchored = [];
|
|
138
|
+
for (const pair of regions) {
|
|
139
|
+
if (pair.baseline && pair.checkpoint)
|
|
140
|
+
continue; // already complete — nothing to check
|
|
141
|
+
const region = (pair.baseline ?? pair.checkpoint);
|
|
142
|
+
if (domSource && region.domSelector && findNodeBySelector(domSource, region.domSelector)) {
|
|
143
|
+
anchored.push(pair);
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
nonAnchored.push(pair); // no domSelector, a stale one, or nothing to verify against at all
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return { anchored, nonAnchored };
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Clears the `domSelector` (mutating in place) of every pair in `nonAnchored` that still has
|
|
153
|
+
* one — i.e. a stale one `checkRegions` left untouched — so it can be retried by
|
|
154
|
+
* `healNonAnchoredRegionsBySourceRect`. A stale selector is never sent to
|
|
155
|
+
* get-compatible-selectors as-is: live-confirmed against the real API, a single bad input
|
|
156
|
+
* selector fails the *entire* batched call, not just itself. Returns whether anything was
|
|
157
|
+
* actually cleared. Only a caller that intends to re-anchor a region calls this (e.g.
|
|
158
|
+
* `eyes-resolve`'s `resolveStepFlow`) — a caller that just wants to inspect regions as encoded
|
|
159
|
+
* (`checkRegions`'s other caller) should leave a stale selector alone for diagnosis rather
|
|
160
|
+
* than heal past it.
|
|
161
|
+
*/
|
|
162
|
+
export function clearStaleSelectors(nonAnchored) {
|
|
163
|
+
let changed = false;
|
|
164
|
+
for (const pair of nonAnchored) {
|
|
165
|
+
const region = pair.baseline ?? pair.checkpoint;
|
|
166
|
+
if (region?.domSelector) {
|
|
167
|
+
region.domSelector = '';
|
|
168
|
+
changed = true;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return changed;
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Attempts to anchor each `nonAnchored` pair by finding its region's rect on `domSource` — the
|
|
175
|
+
* same mechanism a brand-new rect-form region uses to upgrade into a domSelector-backed one.
|
|
176
|
+
* Mutates a healed pair's existing (source-side) region in place with the found `domSelector`
|
|
177
|
+
* and accurate rect; a pair with no match is left as a plain rect, unchanged. Returns whether
|
|
178
|
+
* anything was healed. Pure and synchronous — `domSource` must already be fetched.
|
|
179
|
+
*/
|
|
180
|
+
export function healNonAnchoredRegionsBySourceRect(nonAnchored, domSource) {
|
|
181
|
+
if (!domSource)
|
|
182
|
+
return false;
|
|
183
|
+
let changed = false;
|
|
184
|
+
for (const pair of nonAnchored) {
|
|
185
|
+
const sourceSide = pair.baseline ? 'baseline' : 'checkpoint';
|
|
186
|
+
const found = findNodeByRect(domSource, pair[sourceSide]);
|
|
187
|
+
if (found) {
|
|
188
|
+
pair[sourceSide] = toRegion(found);
|
|
189
|
+
changed = true;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return changed;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Fills in whichever side of each `RegionPair` is still missing with a plain, unanchored
|
|
196
|
+
* copy of its populated side — same rect, no `domSelector`. This doesn't try to find or heal
|
|
197
|
+
* anything (unlike every other function above); it just guarantees every pair comes out with
|
|
198
|
+
* both sides present, for a caller (`eyes-inspect`'s `regions` command) that wants a
|
|
199
|
+
* consistently-shaped, fully "mapped" output to inspect rather than an occasionally-partial
|
|
200
|
+
* one. Once this runs, a `domSelector` present on one side but empty on the other is itself
|
|
201
|
+
* diagnostically meaningful — it means that side's `domSelector` is stale, or the anchoring
|
|
202
|
+
* element genuinely doesn't exist there (get-compatible-selectors mapped it to `null`) —
|
|
203
|
+
* rather than being an accident of the pair simply never having been looked at.
|
|
204
|
+
*/
|
|
205
|
+
export function fillMissingSideWithPlainRect(regions) {
|
|
206
|
+
for (const pair of regions) {
|
|
207
|
+
if (pair.baseline && pair.checkpoint)
|
|
208
|
+
continue;
|
|
209
|
+
const region = (pair.baseline ?? pair.checkpoint);
|
|
210
|
+
const missingSide = pair.baseline ? 'checkpoint' : 'baseline';
|
|
211
|
+
pair[missingSide] = { ...region, domSelector: '' };
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* The coded-region counterpart to `fillMissingSideWithPlainRect` — but one-directional, since
|
|
216
|
+
* `checkpoint` is never missing to begin with on a coded `RegionPair` (see
|
|
217
|
+
* `pairCodedRegions`/`getEffectiveRegions`): only `baseline` ever needs filling. Fills it with a
|
|
218
|
+
* plain copy of `checkpoint` at the same rect, with `regionId` cleared to `''`, rather than
|
|
219
|
+
* leaving it `undefined`.
|
|
220
|
+
*/
|
|
221
|
+
export function fillMissingBaselineForCodedRegions(codedRegions) {
|
|
222
|
+
for (const pair of codedRegions) {
|
|
223
|
+
if (pair.baseline)
|
|
224
|
+
continue;
|
|
225
|
+
pair.baseline = { ...pair.checkpoint, regionId: '' };
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Finds an existing region "the same" as `candidate`: an exact `domSelector` match
|
|
230
|
+
* (checked across all candidates first — the stronger signal, survives rect drift), or
|
|
231
|
+
* else a rect match within `tolerancePx` on all four of left/top/width/height. Searches
|
|
232
|
+
* across every category, not just the one `candidate` belongs to, so a region that's been
|
|
233
|
+
* reclassified (e.g. layout -> ignore) is still found and replaced rather than duplicated.
|
|
234
|
+
*/
|
|
235
|
+
export function findSimilarExistingRegion(existing, candidate, tolerancePx = 5) {
|
|
236
|
+
for (const entry of existing) {
|
|
237
|
+
if (candidate.domSelector && entry.region.domSelector && candidate.domSelector === entry.region.domSelector) {
|
|
238
|
+
return entry;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
for (const entry of existing) {
|
|
242
|
+
const r = entry.region;
|
|
243
|
+
if (Math.abs(r.left - candidate.left) <= tolerancePx &&
|
|
244
|
+
Math.abs(r.top - candidate.top) <= tolerancePx &&
|
|
245
|
+
Math.abs(r.width - candidate.width) <= tolerancePx &&
|
|
246
|
+
Math.abs(r.height - candidate.height) <= tolerancePx) {
|
|
247
|
+
return entry;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Removes (in place) the entry in `entries` "similar" to `candidate` (see
|
|
254
|
+
* `findSimilarExistingRegion`), if any, returning the removed entry — or `null` if nothing
|
|
255
|
+
* matched, so a caller can report exactly what was actually removed (e.g. for a `-<matchLevel>-
|
|
256
|
+
* <target>` regionOp) rather than just whether something was.
|
|
257
|
+
*
|
|
258
|
+
* Live-confirmed against the real API: the `stepUpdates[].annotations` field is a full
|
|
259
|
+
* replacement of the resolution's active regions, not a delta merged against whatever's
|
|
260
|
+
* already stored — and `removedAnnotations` isn't actually used for removal at all (the
|
|
261
|
+
* real Test Manager UI sends it as an empty skeleton even when deleting a region). So the
|
|
262
|
+
* only correct way to add/remove a region is to fetch the current full set, mutate it
|
|
263
|
+
* in memory (this function, plus a plain `.push` for adds), and send the *entire* result
|
|
264
|
+
* back as `annotations` — see eyes-resolve's `resolveStepFlow`.
|
|
265
|
+
*/
|
|
266
|
+
export function removeSimilarRegion(entries, candidate, tolerancePx = 5) {
|
|
267
|
+
const match = findSimilarExistingRegion(entries, candidate, tolerancePx);
|
|
268
|
+
if (!match)
|
|
269
|
+
return null;
|
|
270
|
+
entries.splice(entries.indexOf(match), 1);
|
|
271
|
+
return match;
|
|
272
|
+
}
|
|
273
|
+
/** Buckets a flat, category-tagged region list back into the category-keyed shape the API expects. */
|
|
274
|
+
export function bucketRegions(entries) {
|
|
275
|
+
const result = emptyAnnotations();
|
|
276
|
+
for (const entry of entries) {
|
|
277
|
+
result[entry.category].push(entry.region);
|
|
278
|
+
}
|
|
279
|
+
return result;
|
|
280
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a raw session currently has any pending, unsaved change — an accept/reject decision
|
|
3
|
+
* or a region edit not yet committed via `save`. Checks every step's own
|
|
4
|
+
* `appOutputResolution[i].isChanged` flag (the same signal each step's own `isUnsaved` below is
|
|
5
|
+
* derived from), not the raw session's own top-level `isModified` field — confirmed live:
|
|
6
|
+
* `isModified` is sticky, staying `true` forever once a session was ever changed, even long
|
|
7
|
+
* after that change was fully cleared (e.g. via a batch-wide reset), so it can't signal "has a
|
|
8
|
+
* pending change right now." Independent of whether `simplifySession` was asked to compute
|
|
9
|
+
* `steps` — this reads `appOutputResolution` directly, so it's correct either way.
|
|
10
|
+
*/
|
|
11
|
+
export function hasUnsavedChanges(rawData) {
|
|
12
|
+
const appOutputResolution = rawData.appOutputResolution ?? [];
|
|
13
|
+
return appOutputResolution.some((entry) => entry?.isChanged === true);
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Whether `stepIndex`'s checkpoint already carries a human accept/reject decision — the same
|
|
17
|
+
* `resolution` a step gets in `simplifySession` below, pulled out standalone so a caller only
|
|
18
|
+
* needs one step's status without shaping (or paying for) the whole session's `steps` array.
|
|
19
|
+
* A resolution entry can exist purely from a region edit, with no accept/reject decision behind
|
|
20
|
+
* it (no `replaceExpected` field) — only a real boolean there counts as resolved, however each
|
|
21
|
+
* side originally read.
|
|
22
|
+
*/
|
|
23
|
+
export function getStepResolution(rawData, stepIndex) {
|
|
24
|
+
const resolutionEntry = rawData?.appOutputResolution?.[stepIndex];
|
|
25
|
+
return typeof resolutionEntry?.replaceExpected === 'boolean'
|
|
26
|
+
? resolutionEntry.replaceExpected
|
|
27
|
+
? 'accepted'
|
|
28
|
+
: 'rejected'
|
|
29
|
+
: 'none';
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Extracts a raw session's scenario id — the join key for the session-history query (see
|
|
33
|
+
* `api`'s `fetchSessionHistory`) — confirmed live as a top-level `scenarioId` field,
|
|
34
|
+
* distinct from `scenarioName` (already mapped to `SimplifiedSession.sessionName`). Throws
|
|
35
|
+
* rather than returning `undefined` so a caller building a history query fails loudly instead
|
|
36
|
+
* of silently querying with a bad/missing id.
|
|
37
|
+
*/
|
|
38
|
+
export function extractScenarioId(rawData) {
|
|
39
|
+
const scenarioId = rawData?.scenarioId;
|
|
40
|
+
if (typeof scenarioId !== 'string' || !scenarioId) {
|
|
41
|
+
throw new Error('Raw session has no scenarioId field — cannot look up its session history.');
|
|
42
|
+
}
|
|
43
|
+
return scenarioId;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Throws a clear "step index N is out of range" error if `stepIndex` doesn't correspond to an
|
|
47
|
+
* actual step in the raw session. Callers that work from the raw, unshaped session directly
|
|
48
|
+
* (unlike this module's own `simplifySession`, which already has a bounds-checked `steps`
|
|
49
|
+
* array) would otherwise silently index past the end of `expectedAppOutput`/`actualAppOutput`
|
|
50
|
+
* and read `undefined` — which `validateDomId` then reports as "this step has no DOM capture",
|
|
51
|
+
* indistinguishable from a real step that simply wasn't DOM-captured.
|
|
52
|
+
*/
|
|
53
|
+
export function validateStepIndex(rawSession, stepIndex) {
|
|
54
|
+
const stepCount = getStepCount(rawSession);
|
|
55
|
+
if (stepIndex < 0 || stepIndex >= stepCount) {
|
|
56
|
+
throw new Error(`Step index ${stepIndex} is out of range (session has ${stepCount} steps)`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/** Total step count for a raw session — the longer of its baseline/checkpoint image arrays. */
|
|
60
|
+
export function getStepCount(rawSession) {
|
|
61
|
+
return Math.max(rawSession?.expectedAppOutput?.length ?? 0, rawSession?.actualAppOutput?.length ?? 0);
|
|
62
|
+
}
|
|
63
|
+
export function simplifySession(rawData, options) {
|
|
64
|
+
let steps = null;
|
|
65
|
+
if (options?.includeSteps ?? true) {
|
|
66
|
+
steps = [];
|
|
67
|
+
const expectedOutputs = rawData.expectedAppOutput || [];
|
|
68
|
+
const actualOutputs = rawData.actualAppOutput || [];
|
|
69
|
+
const appOutputResolution = rawData.appOutputResolution || [];
|
|
70
|
+
for (let i = 0; i < Math.max(expectedOutputs.length, actualOutputs.length); i++) {
|
|
71
|
+
const expected = expectedOutputs[i];
|
|
72
|
+
const actual = actualOutputs[i];
|
|
73
|
+
if (!expected && !actual)
|
|
74
|
+
continue;
|
|
75
|
+
const tag = actual?.tag;
|
|
76
|
+
const isMatching = actual?.isMatching ?? false;
|
|
77
|
+
const resolution = getStepResolution(rawData, i);
|
|
78
|
+
const resolutionEntry = appOutputResolution[i];
|
|
79
|
+
steps.push({
|
|
80
|
+
tag,
|
|
81
|
+
baseline: {
|
|
82
|
+
id: expected?.image?.id ?? null,
|
|
83
|
+
domId: expected?.image?.domId ?? null,
|
|
84
|
+
size: expected?.image?.size ?? null,
|
|
85
|
+
},
|
|
86
|
+
checkpoint: {
|
|
87
|
+
id: actual?.image?.id ?? null,
|
|
88
|
+
domId: actual?.image?.domId ?? null,
|
|
89
|
+
size: actual?.image?.size ?? null,
|
|
90
|
+
},
|
|
91
|
+
isMatching,
|
|
92
|
+
resolution,
|
|
93
|
+
isUnsaved: resolutionEntry?.isChanged === true,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const env = rawData.env ?? {};
|
|
98
|
+
return {
|
|
99
|
+
sessionId: rawData.id,
|
|
100
|
+
sessionName: rawData.scenarioName,
|
|
101
|
+
appName: rawData.appName,
|
|
102
|
+
status: rawData.status,
|
|
103
|
+
isUnsaved: hasUnsavedChanges(rawData),
|
|
104
|
+
isAborted: rawData.isAborted === true,
|
|
105
|
+
environment: {
|
|
106
|
+
browser: env.hostingApp ?? null,
|
|
107
|
+
device: env.deviceInfo ?? null,
|
|
108
|
+
os: env.os ?? null,
|
|
109
|
+
width: env.displaySize?.width ?? null,
|
|
110
|
+
height: env.displaySize?.height ?? null,
|
|
111
|
+
},
|
|
112
|
+
startedAt: rawData.startedAt,
|
|
113
|
+
steps,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# dom-search predicate language
|
|
2
|
+
|
|
3
|
+
`dom-search` searches a DOM capture (the JSON produced by eyes-inspect's `dom` command) for elements matching a predicate, and prints one line per match — indented to show its depth in the tree.
|
|
4
|
+
|
|
5
|
+
Only real tag elements are matched; text and comment nodes are never matched directly. Every element also gets a virtual `text` property: the concatenation of its own direct text content. A matching element with non-empty text has that text appended to its output line after a `#`.
|
|
6
|
+
|
|
7
|
+
A predicate is one of seven kinds of check — a **property comparison**, a **bounds (geometry) check**, a **size comparison**, a **direction check**, a **relationship check**, an **attribute-presence check**, or a **shortcut keyword** — combined with `and`, `or`, `not`, and parentheses.
|
|
8
|
+
|
|
9
|
+
Several of these checks need to reference *another* element rather than the one being tested. Do that with `node <id>`, using the element's `id` field — e.g. `node 12`; it shows up in bounds checks, size comparisons, direction checks, and relationship checks below. Relationship checks additionally accept a sub-predicate in place of `node <id>`, describing the other element instead of naming its id — see "Checking relationships" below.
|
|
10
|
+
|
|
11
|
+
## Boolean combinations
|
|
12
|
+
|
|
13
|
+
Combine any predicates with `and`, `or`, `not` (or the symbols `&&`, `||`, `!`) and parentheses. `not` binds tighter than `and`, which binds tighter than `or` — same as most programming languages. Use parentheses to override that.
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
image and gif
|
|
17
|
+
tagName = 'DIV' or tagName = 'SPAN'
|
|
18
|
+
not (tagName = 'SPAN')
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Comparing a property
|
|
22
|
+
|
|
23
|
+
Reference a property by its path — `tagName`, `attributes.class`, `attributes.data-testid`, `style.display`, `rect.width`, `rect.left`, or the virtual `text` property — and compare it to a value:
|
|
24
|
+
|
|
25
|
+
- **Equality**: `=` or `equals`, and `!=` or `not-equals`.
|
|
26
|
+
- **Ordering** (numeric — also handles CSS lengths like `"16px"`): `<`/`less-than`, `<=`/`less-than-or-equal`, `>`/`greater-than`, `>=`/`greater-than-or-equal`.
|
|
27
|
+
- **String matching**: `starts-with`, `ends-with`, `contains`.
|
|
28
|
+
- **Regex**: `matches`, `regex`, or `=~`, with the value written as `/pattern/flags`.
|
|
29
|
+
- **Membership**: `in`, with a parenthesized, comma-separated list of values.
|
|
30
|
+
|
|
31
|
+
String comparisons are case-insensitive. A property that doesn't exist on an element just fails to match — it never causes an error. Examples:
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
tagName = 'DIV'
|
|
35
|
+
attributes.class contains 'btn'
|
|
36
|
+
rect.width > 200
|
|
37
|
+
style.display != 'none'
|
|
38
|
+
attributes.href starts-with 'https://'
|
|
39
|
+
tagName =~ /^H[1-6]$/
|
|
40
|
+
attributes.type in ('button', 'submit', 'reset')
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Values with no spaces can be written bare (`tagName = DIV`). A value containing spaces or special characters must be single-quoted (`attributes.class = 'nav item'`) — there is no double-quoted form, so a literal `"` never needs escaping and isn't itself an escape sequence. Inside a quoted value, `\'`, `\\`, `\n`, `\t`, and `\r` are recognized escapes — so `text = 'line one\nline two'` matches an element whose text spans two lines. A regex literal (`/pattern/flags`) doesn't need this — its own `\n`/`\t`/etc. are interpreted by the regex engine directly.
|
|
44
|
+
|
|
45
|
+
## Checking geometry
|
|
46
|
+
|
|
47
|
+
Test an element's bounding rectangle against a point, a rectangle, or another element:
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
bounds contains 400,300 (a point, "x,y")
|
|
51
|
+
bounds contains 0,0,500,200 (a rectangle, "left,top,width,height")
|
|
52
|
+
bounds contains node 12 (the element with id 12)
|
|
53
|
+
bounds intersects node 12
|
|
54
|
+
bounds contained-by node 12
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
`bounds` and `rect` are interchangeable here. An element with no rectangle never matches a geometry check. (Writing `rect.width` or another dotted path still works as an ordinary property comparison — only a bare `rect`/`bounds` followed directly by `contains`/`intersects`/`contained-by` is a geometry check.)
|
|
58
|
+
|
|
59
|
+
## Comparing size
|
|
60
|
+
|
|
61
|
+
Compare an element's area, width, or height against a literal number, another element's dimensions, or a plain size/rectangle:
|
|
62
|
+
|
|
63
|
+
```
|
|
64
|
+
larger-than node 12 (area bigger than element 12's area)
|
|
65
|
+
smaller-than 5000 (area smaller than 5000 square px)
|
|
66
|
+
wider-than node 12 (width greater than element 12's width)
|
|
67
|
+
narrower-than 300 (width less than 300px)
|
|
68
|
+
taller-than 0,0,200,80 (height greater than 80 — a rect's own height, position ignored)
|
|
69
|
+
shorter-than 200,80 (height less than 80 — a plain "width,height" size)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
The target can be `node <id>` (use that element's dimensions), a rectangle (4 numbers — only its width/height matter, not its position), a plain size (2 numbers, `width,height`), or a single bare number — interpreted as area for `larger-than`/`smaller-than`, width for `wider-than`/`narrower-than`, height for `taller-than`/`shorter-than`. An element with no rectangle (as either the subject or the `node` target) never matches.
|
|
73
|
+
|
|
74
|
+
## Checking direction
|
|
75
|
+
|
|
76
|
+
Test whether an element is entirely above, below, left of, or right of another element or rectangle — not just overlapping it, but fully past the relevant edge:
|
|
77
|
+
|
|
78
|
+
```
|
|
79
|
+
above node 12
|
|
80
|
+
below node 12
|
|
81
|
+
left-of node 12
|
|
82
|
+
right-of 0,0,500,200
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`right-of T` means entirely to the right of T's right edge; `left-of T` entirely to the left of T's left edge; `above`/`below` work the same way against T's top/bottom edge. An element that merely overlaps the target matches none of the four.
|
|
86
|
+
|
|
87
|
+
## Checking relationships
|
|
88
|
+
|
|
89
|
+
Test an element's position in the tree relative to another element:
|
|
90
|
+
|
|
91
|
+
```
|
|
92
|
+
descendant-of node 12
|
|
93
|
+
ancestor-of node 12
|
|
94
|
+
child-of node 12
|
|
95
|
+
parent-of node 12
|
|
96
|
+
sibling-of node 12
|
|
97
|
+
2nd child-of node 12
|
|
98
|
+
first child-of node 12
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Ordinal forms (`1st`, `2nd`, ... `20th`, or `first`, `second`, ... `twentieth`) count only element children, ignoring text.
|
|
102
|
+
|
|
103
|
+
Instead of `node <id>`, you can give a whole sub-predicate in parentheses, describing the other element instead of naming it — this lets you say "some ancestor/descendant/sibling that looks like X" without knowing its id:
|
|
104
|
+
|
|
105
|
+
```
|
|
106
|
+
child-of (tagName = 'FORM')
|
|
107
|
+
2nd child-of (ancestor-of text and tagName = 'BUTTON')
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
The second example reads as: "the 2nd child of some element that is itself a BUTTON with a descendant that has text."
|
|
111
|
+
|
|
112
|
+
## Checking attribute presence
|
|
113
|
+
|
|
114
|
+
Test whether an element has a given attribute at all, regardless of its value:
|
|
115
|
+
|
|
116
|
+
```
|
|
117
|
+
has-attribute src
|
|
118
|
+
has-attribute 'data-testid'
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
The name is matched case-insensitively and can be written bare (no spaces) or single-quoted. This differs from `attributes.<name> != ''` in one case: an attribute present with an empty value (e.g. `alt=""`) counts as present for `has-attribute`, but would fail a `!= ''` check.
|
|
122
|
+
|
|
123
|
+
## Shortcut keywords
|
|
124
|
+
|
|
125
|
+
A handful of common checks are a single word, with no operator or value:
|
|
126
|
+
|
|
127
|
+
| Keyword | Matches |
|
|
128
|
+
|---|---|
|
|
129
|
+
| `text` | Has non-empty text |
|
|
130
|
+
| `image` | Looks like a visual image (broad heuristic — see below) |
|
|
131
|
+
| `gif` | Is an `IMG` (specifically, not the broader `image` set) whose `src` ends with `.gif` |
|
|
132
|
+
| `logo` | `class`/`id`/`alt` contains "logo" |
|
|
133
|
+
| `link` | Is an `A` |
|
|
134
|
+
| `button` | Is a `BUTTON`, a `button`/`submit`/`reset` `INPUT`, or has `role="button"` |
|
|
135
|
+
| `heading` | Is `H1` through `H6` |
|
|
136
|
+
| `hidden` | `display:none`, `visibility:hidden`, `opacity:0`, zero width/height (or no rect), or has a `hidden` attribute |
|
|
137
|
+
| `visible` | The exact inverse of `hidden` |
|
|
138
|
+
| `empty` | Has no text and no element children |
|
|
139
|
+
| `true` | Always matches — every element |
|
|
140
|
+
| `false` | Never matches |
|
|
141
|
+
|
|
142
|
+
`image` is a broad heuristic, not a single structural check — it's meant to answer "does this look like an image to a person looking at the page", not "is this literally an `<img>` tag". It matches any of:
|
|
143
|
+
|
|
144
|
+
- an `IMG`, `SVG`, or `PICTURE` element (by tag, regardless of attributes)
|
|
145
|
+
- any element with a CSS `background-image` other than `none`
|
|
146
|
+
|
|
147
|
+
Because it's a heuristic, expect both false positives and false negatives: a `DIV` styled with a decorative `background-image` counts as an "image" even though it's not meaningfully one, while an icon rendered via an icon font (a glyph character styled with a special font, no image-related attribute or style at all) is invisible to this check and will never match. When you need the strict "literally an `<img>` tag" meaning instead, write `tagName = 'IMG'` directly.
|
|
148
|
+
|
|
149
|
+
**Results include hidden elements by default** — dom-search doesn't filter anything out unless the predicate says so, since silently hiding a match is worse than an unwanted one (you might be searching precisely because an element is unexpectedly hidden). **To skip hidden elements, start the predicate with `visible and ...`**, e.g. `visible and tagName = 'BUTTON'`.
|
|
150
|
+
|
|
151
|
+
## More examples
|
|
152
|
+
|
|
153
|
+
```
|
|
154
|
+
tagName = 'DIV' and attributes.class contains 'btn'
|
|
155
|
+
image and gif
|
|
156
|
+
heading and text
|
|
157
|
+
rect.width > 200 and not (tagName = 'SPAN')
|
|
158
|
+
attributes.type in ('button', 'submit')
|
|
159
|
+
bounds contains 400,300
|
|
160
|
+
descendant-of node 12
|
|
161
|
+
larger-than node 12
|
|
162
|
+
right-of node 12
|
|
163
|
+
has-attribute 'aria-label'
|
|
164
|
+
```
|
package/package.json
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@applitools/eyes-results",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Applitools Eyes REST plumbing, DOM diffing, and session/batch shaping",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"applitools",
|
|
7
|
+
"eyes",
|
|
8
|
+
"visual-testing",
|
|
9
|
+
"dom-diff",
|
|
10
|
+
"sessions"
|
|
11
|
+
],
|
|
12
|
+
"homepage": "https://applitools.com",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/applitools/eyes.sdk.javascript1/issues"
|
|
15
|
+
},
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git://github.com/applitools/eyes.sdk.javascript1.git",
|
|
19
|
+
"directory": "js/packages/eyes-results"
|
|
20
|
+
},
|
|
21
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
22
|
+
"author": {
|
|
23
|
+
"name": "Applitools Team",
|
|
24
|
+
"email": "team@applitools.com"
|
|
25
|
+
},
|
|
26
|
+
"type": "module",
|
|
27
|
+
"main": "./dist/index.js",
|
|
28
|
+
"types": "./types/index.d.ts",
|
|
29
|
+
"exports": {
|
|
30
|
+
".": {
|
|
31
|
+
"types": "./types/index.d.ts",
|
|
32
|
+
"default": "./dist/index.js"
|
|
33
|
+
},
|
|
34
|
+
"./package.json": "./package.json"
|
|
35
|
+
},
|
|
36
|
+
"files": [
|
|
37
|
+
"dist",
|
|
38
|
+
"types",
|
|
39
|
+
"llm-docs/dom-search.md"
|
|
40
|
+
],
|
|
41
|
+
"scripts": {
|
|
42
|
+
"lint": "run --top-level eslint '**/*.ts'",
|
|
43
|
+
"build": "run --top-level tsc -p tsconfig.build.json",
|
|
44
|
+
"test": "vitest run",
|
|
45
|
+
"type-check": "run --top-level tsc --noEmit"
|
|
46
|
+
},
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"@applitools/req": "1.11.3",
|
|
49
|
+
"dotenv": "^16.6.1"
|
|
50
|
+
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"@types/node": "^22",
|
|
53
|
+
"typescript": "~5.9.3",
|
|
54
|
+
"vitest": "^4.1.9"
|
|
55
|
+
},
|
|
56
|
+
"engines": {
|
|
57
|
+
"node": ">=24.0.0"
|
|
58
|
+
},
|
|
59
|
+
"publishConfig": {
|
|
60
|
+
"access": "public"
|
|
61
|
+
},
|
|
62
|
+
"lavamoat": {
|
|
63
|
+
"allowScripts": {
|
|
64
|
+
"vitest>jsdom>ws>bufferutil": false,
|
|
65
|
+
"vitest>jsdom>ws>utf-8-validate": false,
|
|
66
|
+
"vitest>vite>esbuild": false
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export * from './url-parsing.js';
|
|
2
|
+
export * from './polling.js';
|
|
3
|
+
export * from './req-results.js';
|
|
4
|
+
export * from './tasks.js';
|
|
5
|
+
export * from './fs-cache.js';
|
|
6
|
+
export * from './gzip.js';
|
|
7
|
+
export * from './dom-fetch.js';
|
|
8
|
+
export * from './dom-diff.js';
|
|
9
|
+
export * from './image-fetch.js';
|
|
10
|
+
export * from './session-fetch.js';
|
|
11
|
+
export * from './selector-mapping.js';
|
|
12
|
+
export * from './diff-fetch.js';
|
|
13
|
+
export * from './session-updates.js';
|
|
14
|
+
export * from './find-regions.js';
|
|
15
|
+
export * from './session-history-fetch.js';
|
|
16
|
+
export * from './compare-step.js';
|
|
17
|
+
export * from './same-diff-as.js';
|