@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.
Files changed (72) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/LICENSE +26 -0
  3. package/dist/api/api.js +17 -0
  4. package/dist/api/compare-step.js +20 -0
  5. package/dist/api/diff-fetch.js +13 -0
  6. package/dist/api/dom-diff.js +139 -0
  7. package/dist/api/dom-fetch.js +39 -0
  8. package/dist/api/find-regions.js +24 -0
  9. package/dist/api/fs-cache.js +19 -0
  10. package/dist/api/gzip.js +12 -0
  11. package/dist/api/image-fetch.js +31 -0
  12. package/dist/api/polling.js +117 -0
  13. package/dist/api/req-results.js +77 -0
  14. package/dist/api/same-diff-as.js +25 -0
  15. package/dist/api/selector-mapping.js +15 -0
  16. package/dist/api/session-fetch.js +35 -0
  17. package/dist/api/session-history-fetch.js +73 -0
  18. package/dist/api/session-updates.js +37 -0
  19. package/dist/api/tasks.js +48 -0
  20. package/dist/api/url-parsing.js +33 -0
  21. package/dist/common/common.js +2 -0
  22. package/dist/common/diff-rects.js +64 -0
  23. package/dist/common/region-mapping.js +50 -0
  24. package/dist/geometry/geometry.js +72 -0
  25. package/dist/index.js +14 -0
  26. package/dist/lib/api-key-provider.js +26 -0
  27. package/dist/lib/env.js +26 -0
  28. package/dist/lib/phase-tracker.js +223 -0
  29. package/dist/sessions/batch.js +98 -0
  30. package/dist/sessions/diffs.js +210 -0
  31. package/dist/sessions/dom-history.js +107 -0
  32. package/dist/sessions/dom-search.js +1150 -0
  33. package/dist/sessions/dom.js +513 -0
  34. package/dist/sessions/match.js +56 -0
  35. package/dist/sessions/regions.js +280 -0
  36. package/dist/sessions/session.js +115 -0
  37. package/llm-docs/dom-search.md +164 -0
  38. package/package.json +69 -0
  39. package/types/api/api.d.ts +17 -0
  40. package/types/api/compare-step.d.ts +52 -0
  41. package/types/api/diff-fetch.d.ts +21 -0
  42. package/types/api/dom-diff.d.ts +53 -0
  43. package/types/api/dom-fetch.d.ts +29 -0
  44. package/types/api/find-regions.d.ts +26 -0
  45. package/types/api/fs-cache.d.ts +12 -0
  46. package/types/api/gzip.d.ts +5 -0
  47. package/types/api/image-fetch.d.ts +29 -0
  48. package/types/api/polling.d.ts +41 -0
  49. package/types/api/req-results.d.ts +37 -0
  50. package/types/api/same-diff-as.d.ts +38 -0
  51. package/types/api/selector-mapping.d.ts +28 -0
  52. package/types/api/session-fetch.d.ts +34 -0
  53. package/types/api/session-history-fetch.d.ts +56 -0
  54. package/types/api/session-updates.d.ts +26 -0
  55. package/types/api/tasks.d.ts +33 -0
  56. package/types/api/url-parsing.d.ts +20 -0
  57. package/types/common/common.d.ts +2 -0
  58. package/types/common/diff-rects.d.ts +39 -0
  59. package/types/common/region-mapping.d.ts +26 -0
  60. package/types/geometry/geometry.d.ts +24 -0
  61. package/types/index.d.ts +14 -0
  62. package/types/lib/api-key-provider.d.ts +31 -0
  63. package/types/lib/env.d.ts +14 -0
  64. package/types/lib/phase-tracker.d.ts +146 -0
  65. package/types/sessions/batch.d.ts +67 -0
  66. package/types/sessions/diffs.d.ts +26 -0
  67. package/types/sessions/dom-history.d.ts +119 -0
  68. package/types/sessions/dom-search.d.ts +113 -0
  69. package/types/sessions/dom.d.ts +263 -0
  70. package/types/sessions/match.d.ts +72 -0
  71. package/types/sessions/regions.d.ts +171 -0
  72. package/types/sessions/session.d.ts +97 -0
@@ -0,0 +1,1150 @@
1
+ // ---------------------------------------------------------------------------
2
+ // dom-search: a small predicate language for querying DOM capture files
3
+ // ---------------------------------------------------------------------------
4
+ //
5
+ // Mostly self-contained — duplicates a handful of tiny dom.ts helpers (node ids, text
6
+ // aggregation, rect extraction) rather than import them, since they're small and stable
7
+ // enough that keeping this file independently readable outweighs the duplication. Visibility
8
+ // is the one exception: `isVisible` is imported for real from dom.ts, where it belongs as a
9
+ // general DOM-node concept — this file has no reason to own its own copy of that logic, and
10
+ // nothing here flows back into dom.ts, so there's no circularity to worry about.
11
+ import { isVisible } from './dom.js';
12
+ const WORD_RE = /^[A-Za-z_][A-Za-z0-9_]*(?:-[A-Za-z0-9_]+)*/;
13
+ const ORDINAL_SUFFIX_RE = /^(\d+)(st|nd|rd|th)\b/i;
14
+ // Escapes recognized inside a single-quoted value. `"` is deliberately absent — there is no
15
+ // double-quoted form, so a literal double quote never needs escaping and `\"` passes through
16
+ // as its own two literal characters rather than being treated as an escape.
17
+ const STRING_ESCAPES = {
18
+ "'": "'",
19
+ '\\': '\\',
20
+ n: '\n',
21
+ t: '\t',
22
+ r: '\r',
23
+ };
24
+ function scanString(src, start) {
25
+ let i = start + 1;
26
+ let value = '';
27
+ while (i < src.length && src[i] !== "'") {
28
+ const escaped = src[i] === '\\' ? STRING_ESCAPES[src[i + 1]] : undefined;
29
+ if (escaped !== undefined) {
30
+ value += escaped;
31
+ i += 2;
32
+ }
33
+ else {
34
+ value += src[i];
35
+ i++;
36
+ }
37
+ }
38
+ if (i >= src.length) {
39
+ throw new Error(`Unterminated string literal starting at position ${start} — a single-quoted value must be closed with '.`);
40
+ }
41
+ return { value, end: i + 1 };
42
+ }
43
+ function scanRegex(src, start) {
44
+ let i = start + 1;
45
+ let pattern = '';
46
+ while (i < src.length && src[i] !== '/') {
47
+ if (src[i] === '\\' && i + 1 < src.length) {
48
+ pattern += src[i] + src[i + 1];
49
+ i += 2;
50
+ continue;
51
+ }
52
+ pattern += src[i];
53
+ i++;
54
+ }
55
+ if (i >= src.length) {
56
+ throw new Error(`Unterminated regex literal starting at position ${start} — a /pattern/ must be closed with /.`);
57
+ }
58
+ i++; // consume closing '/'
59
+ let flags = '';
60
+ while (i < src.length && /[a-zA-Z]/.test(src[i])) {
61
+ flags += src[i];
62
+ i++;
63
+ }
64
+ return { pattern, flags, end: i };
65
+ }
66
+ function tokenize(src) {
67
+ const tokens = [];
68
+ let i = 0;
69
+ while (i < src.length) {
70
+ const c = src[i];
71
+ if (/\s/.test(c)) {
72
+ i++;
73
+ continue;
74
+ }
75
+ const start = i;
76
+ if (c === '(' || c === ')' || c === ',' || c === '.') {
77
+ tokens.push({ type: 'punct', value: c, pos: start });
78
+ i++;
79
+ continue;
80
+ }
81
+ if (c === '&' && src[i + 1] === '&') {
82
+ tokens.push({ type: 'punct', value: '&&', pos: start });
83
+ i += 2;
84
+ continue;
85
+ }
86
+ if (c === '|' && src[i + 1] === '|') {
87
+ tokens.push({ type: 'punct', value: '||', pos: start });
88
+ i += 2;
89
+ continue;
90
+ }
91
+ if (c === '!' && src[i + 1] === '=') {
92
+ tokens.push({ type: 'punct', value: '!=', pos: start });
93
+ i += 2;
94
+ continue;
95
+ }
96
+ if (c === '<' && src[i + 1] === '=') {
97
+ tokens.push({ type: 'punct', value: '<=', pos: start });
98
+ i += 2;
99
+ continue;
100
+ }
101
+ if (c === '>' && src[i + 1] === '=') {
102
+ tokens.push({ type: 'punct', value: '>=', pos: start });
103
+ i += 2;
104
+ continue;
105
+ }
106
+ if (c === '=' && src[i + 1] === '~') {
107
+ tokens.push({ type: 'punct', value: '=~', pos: start });
108
+ i += 2;
109
+ continue;
110
+ }
111
+ if (c === '!') {
112
+ tokens.push({ type: 'punct', value: '!', pos: start });
113
+ i++;
114
+ continue;
115
+ }
116
+ if (c === '<') {
117
+ tokens.push({ type: 'punct', value: '<', pos: start });
118
+ i++;
119
+ continue;
120
+ }
121
+ if (c === '>') {
122
+ tokens.push({ type: 'punct', value: '>', pos: start });
123
+ i++;
124
+ continue;
125
+ }
126
+ if (c === '=') {
127
+ tokens.push({ type: 'punct', value: '=', pos: start });
128
+ i++;
129
+ continue;
130
+ }
131
+ if (c === "'") {
132
+ const { value, end } = scanString(src, i);
133
+ tokens.push({ type: 'string', value, pos: start });
134
+ i = end;
135
+ continue;
136
+ }
137
+ if (c === '/') {
138
+ const { pattern, flags, end } = scanRegex(src, i);
139
+ tokens.push({ type: 'regex', value: pattern, flags, pos: start });
140
+ i = end;
141
+ continue;
142
+ }
143
+ const isDigitStart = /[0-9]/.test(c);
144
+ const isNegativeStart = c === '-' && /[0-9]/.test(src[i + 1] ?? '');
145
+ if (isDigitStart || isNegativeStart) {
146
+ let j = isNegativeStart ? i + 1 : i;
147
+ while (/[0-9]/.test(src[j] ?? ''))
148
+ j++;
149
+ const digitsEnd = j;
150
+ if (!isNegativeStart) {
151
+ const ordinalMatch = ORDINAL_SUFFIX_RE.exec(src.slice(i));
152
+ if (ordinalMatch) {
153
+ const word = ordinalMatch[0];
154
+ tokens.push({ type: 'word', value: word, pos: start });
155
+ i += word.length;
156
+ continue;
157
+ }
158
+ }
159
+ let k = digitsEnd;
160
+ if (src[k] === '.' && /[0-9]/.test(src[k + 1] ?? '')) {
161
+ k++;
162
+ while (/[0-9]/.test(src[k] ?? ''))
163
+ k++;
164
+ }
165
+ tokens.push({ type: 'number', value: src.slice(i, k), pos: start });
166
+ i = k;
167
+ continue;
168
+ }
169
+ if (/[A-Za-z_]/.test(c)) {
170
+ const match = WORD_RE.exec(src.slice(i));
171
+ const word = match[0];
172
+ tokens.push({ type: 'word', value: word, pos: start });
173
+ i += word.length;
174
+ continue;
175
+ }
176
+ throw new Error(`Unexpected character '${c}' at position ${start} in predicate.`);
177
+ }
178
+ tokens.push({ type: 'eof', value: '', pos: src.length });
179
+ return tokens;
180
+ }
181
+ // ---------------------------------------------------------------------------
182
+ // Keyword tables
183
+ // ---------------------------------------------------------------------------
184
+ const BOUNDS_OPS = new Set(['contains', 'intersects', 'contained-by']);
185
+ const BOUNDS_OP_CANON = {
186
+ contains: 'contains',
187
+ intersects: 'intersects',
188
+ 'contained-by': 'containedBy',
189
+ };
190
+ const RELATION_KIND = {
191
+ 'descendant-of': 'descendant',
192
+ 'descendent-of': 'descendant',
193
+ 'ancestor-of': 'ancestor',
194
+ 'child-of': 'child',
195
+ 'parent-of': 'parent',
196
+ 'sibling-of': 'sibling',
197
+ };
198
+ const NULLARY_WORDS = new Set([
199
+ 'text',
200
+ 'image',
201
+ 'gif',
202
+ 'logo',
203
+ 'link',
204
+ 'button',
205
+ 'heading',
206
+ 'hidden',
207
+ 'visible',
208
+ 'empty',
209
+ 'true',
210
+ 'false',
211
+ ]);
212
+ const SIZE_OP_KIND = {
213
+ 'larger-than': 'larger',
214
+ 'smaller-than': 'smaller',
215
+ 'wider-than': 'wider',
216
+ 'narrower-than': 'narrower',
217
+ 'taller-than': 'taller',
218
+ 'shorter-than': 'shorter',
219
+ };
220
+ const DIR_OP_KIND = {
221
+ above: 'above',
222
+ below: 'below',
223
+ 'left-of': 'leftOf',
224
+ 'right-of': 'rightOf',
225
+ };
226
+ const _VALID_SIZE_OPS_LIST = 'larger-than, smaller-than, wider-than, narrower-than, taller-than, shorter-than';
227
+ const VALID_DIR_OPS_LIST = 'above, below, left-of, right-of';
228
+ const COMPARE_OP_MAP = {
229
+ '=': 'eq',
230
+ equals: 'eq',
231
+ '!=': 'neq',
232
+ 'not-equals': 'neq',
233
+ '<': 'lt',
234
+ 'less-than': 'lt',
235
+ '<=': 'lte',
236
+ 'less-than-or-equal': 'lte',
237
+ 'less-than-or-equal-to': 'lte',
238
+ '>': 'gt',
239
+ 'greater-than': 'gt',
240
+ '>=': 'gte',
241
+ 'greater-than-or-equal': 'gte',
242
+ 'greater-than-or-equal-to': 'gte',
243
+ 'starts-with': 'startsWith',
244
+ 'ends-with': 'endsWith',
245
+ contains: 'contains',
246
+ '=~': 'regex',
247
+ matches: 'regex',
248
+ regex: 'regex',
249
+ in: 'in',
250
+ };
251
+ const VALID_COMPARE_OPS_LIST = '=, equals, !=, not-equals, <, less-than, <=, less-than-or-equal, less-than-or-equal-to, ' +
252
+ '>, greater-than, >=, greater-than-or-equal, greater-than-or-equal-to, starts-with, ends-with, ' +
253
+ 'contains, =~, matches, regex, in';
254
+ const VALID_BOUNDS_OPS_LIST = 'contains, intersects, contained-by';
255
+ const VALID_NULLARY_LIST = 'text, image, gif, logo, link, button, heading, hidden, visible, empty, true, false';
256
+ const VALID_RELATION_LIST = "descendant-of, descendent-of, ancestor-of, child-of, parent-of, sibling-of (optionally preceded by an ordinal like '2nd' or 'first' for child-of)";
257
+ const ORDINAL_WORDS = {
258
+ first: 1,
259
+ second: 2,
260
+ third: 3,
261
+ fourth: 4,
262
+ fifth: 5,
263
+ sixth: 6,
264
+ seventh: 7,
265
+ eighth: 8,
266
+ ninth: 9,
267
+ tenth: 10,
268
+ eleventh: 11,
269
+ twelfth: 12,
270
+ thirteenth: 13,
271
+ fourteenth: 14,
272
+ fifteenth: 15,
273
+ sixteenth: 16,
274
+ seventeenth: 17,
275
+ eighteenth: 18,
276
+ nineteenth: 19,
277
+ twentieth: 20,
278
+ };
279
+ function isOrdinalToken(text) {
280
+ const lower = text.toLowerCase();
281
+ if (lower in ORDINAL_WORDS)
282
+ return true;
283
+ return /^\d+(st|nd|rd|th)$/i.test(lower);
284
+ }
285
+ function parseOrdinalValue(text) {
286
+ const lower = text.toLowerCase();
287
+ if (lower in ORDINAL_WORDS)
288
+ return ORDINAL_WORDS[lower];
289
+ const m = /^(\d+)(st|nd|rd|th)$/i.exec(lower);
290
+ if (m) {
291
+ const n = Number(m[1]);
292
+ if (n >= 1 && n <= 20)
293
+ return n;
294
+ }
295
+ throw new Error(`Ordinal '${text}' is out of range. Only 1st through 20th (or 'first' through 'twentieth') are supported.`);
296
+ }
297
+ function tokenOpKey(tok) {
298
+ if (tok.type === 'punct')
299
+ return COMPARE_OP_MAP[tok.value] ?? null;
300
+ if (tok.type === 'word')
301
+ return COMPARE_OP_MAP[tok.value.toLowerCase()] ?? null;
302
+ return null;
303
+ }
304
+ // ---------------------------------------------------------------------------
305
+ // Parser
306
+ // ---------------------------------------------------------------------------
307
+ class Parser {
308
+ tokens;
309
+ pos = 0;
310
+ constructor(tokens) {
311
+ this.tokens = tokens;
312
+ }
313
+ peek(offset = 0) {
314
+ return this.tokens[Math.min(this.pos + offset, this.tokens.length - 1)];
315
+ }
316
+ next() {
317
+ const tok = this.tokens[this.pos];
318
+ if (this.pos < this.tokens.length - 1)
319
+ this.pos++;
320
+ return tok;
321
+ }
322
+ isWord(tok, text) {
323
+ return tok.type === 'word' && tok.value.toLowerCase() === text;
324
+ }
325
+ isPunct(tok, text) {
326
+ return tok.type === 'punct' && tok.value === text;
327
+ }
328
+ expectPunct(sym) {
329
+ const tok = this.peek();
330
+ if (!this.isPunct(tok, sym)) {
331
+ const shown = tok.type === 'eof' ? 'end of predicate' : `'${tok.value}'`;
332
+ throw new Error(`Unexpected token ${shown} at position ${tok.pos} — expected '${sym}'.`);
333
+ }
334
+ this.next();
335
+ }
336
+ parseTopLevel() {
337
+ const ast = this.parseExpr();
338
+ const rest = this.peek();
339
+ if (rest.type !== 'eof') {
340
+ throw new Error(`Unexpected trailing input starting at '${rest.value}' (position ${rest.pos}) — the predicate finished parsing before the end of the string.`);
341
+ }
342
+ return ast;
343
+ }
344
+ parseExpr() {
345
+ return this.parseOr();
346
+ }
347
+ parseOr() {
348
+ let left = this.parseAnd();
349
+ while (this.isWord(this.peek(), 'or') || this.isPunct(this.peek(), '||')) {
350
+ this.next();
351
+ const right = this.parseAnd();
352
+ left = { type: 'or', left, right };
353
+ }
354
+ return left;
355
+ }
356
+ parseAnd() {
357
+ let left = this.parseNot();
358
+ while (this.isWord(this.peek(), 'and') || this.isPunct(this.peek(), '&&')) {
359
+ this.next();
360
+ const right = this.parseNot();
361
+ left = { type: 'and', left, right };
362
+ }
363
+ return left;
364
+ }
365
+ parseNot() {
366
+ if (this.isWord(this.peek(), 'not') || this.isPunct(this.peek(), '!')) {
367
+ this.next();
368
+ const inner = this.parseNot();
369
+ return { type: 'not', inner };
370
+ }
371
+ return this.parseAtom();
372
+ }
373
+ isValidCompareOpStart(tok) {
374
+ return tokenOpKey(tok) !== null;
375
+ }
376
+ parseAtom() {
377
+ const tok = this.peek();
378
+ if (this.isPunct(tok, '(')) {
379
+ this.next();
380
+ const expr = this.parseExpr();
381
+ this.expectPunct(')');
382
+ return expr;
383
+ }
384
+ if (tok.type !== 'word') {
385
+ throw new Error(`Expected a predicate (property comparison, bounds check, relation, or shortcut keyword) but found '${tok.value || 'end of predicate'}' at position ${tok.pos}.`);
386
+ }
387
+ const lower = tok.value.toLowerCase();
388
+ if (lower === 'bounds' || lower === 'rect') {
389
+ const nextTok = this.peek(1);
390
+ if (!this.isPunct(nextTok, '.')) {
391
+ const nextLower = nextTok.type === 'word' ? nextTok.value.toLowerCase() : '';
392
+ if (BOUNDS_OPS.has(nextLower)) {
393
+ return this.parseBoundsPred();
394
+ }
395
+ if (!this.isValidCompareOpStart(nextTok)) {
396
+ throw new Error(`Unknown operator '${nextTok.value}' after '${tok.value}'. If testing bounds, valid operators are: ${VALID_BOUNDS_OPS_LIST}. ` +
397
+ `If comparing a property, use '.' to continue the path (e.g. ${tok.value}.width) followed by a comparison operator.`);
398
+ }
399
+ // else: falls through to PropPred below (bare rect/bounds compared as a whole value)
400
+ }
401
+ }
402
+ if (RELATION_KIND[lower]) {
403
+ return this.parseRelPred();
404
+ }
405
+ if (SIZE_OP_KIND[lower]) {
406
+ return this.parseSizePred();
407
+ }
408
+ if (DIR_OP_KIND[lower]) {
409
+ return this.parseDirPred();
410
+ }
411
+ if (lower === 'has-attribute') {
412
+ return this.parseHasAttrPred();
413
+ }
414
+ if (/-of$/i.test(lower)) {
415
+ throw new Error(`Unknown relation or direction check '${tok.value}'. Valid relations: ${VALID_RELATION_LIST}. Valid direction checks: ${VALID_DIR_OPS_LIST}.`);
416
+ }
417
+ if (isOrdinalToken(tok.value)) {
418
+ const nextTok = this.peek(1);
419
+ if (!(nextTok.type === 'word' && nextTok.value.toLowerCase() === 'child-of')) {
420
+ throw new Error(`Ordinal '${tok.value}' must be followed by 'child-of <target>', e.g. '${tok.value} child-of 5'.`);
421
+ }
422
+ return this.parseOrdinalChildOf();
423
+ }
424
+ if (NULLARY_WORDS.has(lower)) {
425
+ if (lower === 'text') {
426
+ const nextTok = this.peek(1);
427
+ if (this.isValidCompareOpStart(nextTok)) {
428
+ return this.parsePropPred();
429
+ }
430
+ }
431
+ this.next();
432
+ return { type: 'nullary', name: lower };
433
+ }
434
+ const nextTok = this.peek(1);
435
+ const isBoundaryToken = nextTok.type === 'eof' ||
436
+ this.isWord(nextTok, 'and') ||
437
+ this.isWord(nextTok, 'or') ||
438
+ this.isPunct(nextTok, '&&') ||
439
+ this.isPunct(nextTok, '||') ||
440
+ this.isPunct(nextTok, ')');
441
+ if (isBoundaryToken) {
442
+ throw new Error(`Unknown predicate '${tok.value}'. If you meant a shortcut predicate, valid ones are: ${VALID_NULLARY_LIST}. ` +
443
+ `If you meant a property comparison, it must be followed by an operator and value, e.g. '${tok.value} = ...'.`);
444
+ }
445
+ return this.parsePropPred();
446
+ }
447
+ parsePropertyPath() {
448
+ const first = this.next();
449
+ if (first.type !== 'word') {
450
+ throw new Error(`Expected a property name but found '${first.value}' at position ${first.pos}.`);
451
+ }
452
+ const path = [first.value];
453
+ while (this.isPunct(this.peek(), '.')) {
454
+ this.next();
455
+ const seg = this.next();
456
+ if (seg.type !== 'word') {
457
+ throw new Error(`Expected a property name after '.' but found '${seg.value}' at position ${seg.pos}.`);
458
+ }
459
+ path.push(seg.value);
460
+ }
461
+ return path;
462
+ }
463
+ parsePropPred() {
464
+ const path = this.parsePropertyPath();
465
+ const opTok = this.peek();
466
+ const opKey = tokenOpKey(opTok);
467
+ if (!opKey) {
468
+ throw new Error(`Unknown comparison operator '${opTok.value}'. Valid operators: ${VALID_COMPARE_OPS_LIST}.`);
469
+ }
470
+ this.next();
471
+ if (opKey === 'in') {
472
+ const value = this.parseValueList();
473
+ return { type: 'prop', path, op: 'in', value };
474
+ }
475
+ if (opKey === 'regex') {
476
+ const value = this.parseRegexValue();
477
+ return { type: 'prop', path, op: 'regex', value };
478
+ }
479
+ const value = this.parseSingleValue();
480
+ return { type: 'prop', path, op: opKey, value };
481
+ }
482
+ parseBareValue() {
483
+ let text = '';
484
+ let expectedPos = null;
485
+ while (true) {
486
+ const tok = this.peek();
487
+ if ((tok.type === 'word' || tok.type === 'number') && (expectedPos === null || tok.pos === expectedPos)) {
488
+ text += tok.value;
489
+ expectedPos = tok.pos + tok.value.length;
490
+ this.next();
491
+ }
492
+ else {
493
+ break;
494
+ }
495
+ }
496
+ if (text === '') {
497
+ const tok = this.peek();
498
+ throw new Error(`Expected a value but found '${tok.value || 'end of predicate'}' at position ${tok.pos}.`);
499
+ }
500
+ return text;
501
+ }
502
+ parseSingleValue() {
503
+ const tok = this.peek();
504
+ if (tok.type === 'string') {
505
+ this.next();
506
+ return tok.value;
507
+ }
508
+ if (tok.type === 'regex') {
509
+ throw new Error(`A /regex/ literal is only valid with the matches/regex/=~ operator, not here (position ${tok.pos}).`);
510
+ }
511
+ return this.parseBareValue();
512
+ }
513
+ parseValueList() {
514
+ this.expectPunct('(');
515
+ const values = [this.parseSingleValue()];
516
+ while (this.isPunct(this.peek(), ',')) {
517
+ this.next();
518
+ values.push(this.parseSingleValue());
519
+ }
520
+ this.expectPunct(')');
521
+ return values;
522
+ }
523
+ parseRegexValue() {
524
+ const tok = this.peek();
525
+ if (tok.type !== 'regex') {
526
+ throw new Error(`Expected a /pattern/flags regex literal after 'matches'/'regex'/'=~' but found '${tok.value}' at position ${tok.pos}.`);
527
+ }
528
+ this.next();
529
+ const flags = tok.flags && tok.flags.length > 0 ? tok.flags : 'i';
530
+ try {
531
+ return new RegExp(tok.value, flags);
532
+ }
533
+ catch (e) {
534
+ throw new Error(`Invalid regex literal /${tok.value}/${tok.flags ?? ''}: ${e instanceof Error ? e.message : String(e)}`);
535
+ }
536
+ }
537
+ parseSignedNumber() {
538
+ const tok = this.next();
539
+ if (tok.type !== 'number') {
540
+ throw new Error(`Expected a number but found '${tok.value}' at position ${tok.pos}.`);
541
+ }
542
+ return Number(tok.value);
543
+ }
544
+ isNodeKeyword(tok) {
545
+ return tok.type === 'word' && tok.value.toLowerCase() === 'node';
546
+ }
547
+ // Assumes the caller already confirmed the current token is the 'node' keyword (via
548
+ // isNodeKeyword) — this is the one and only way to reference another element by id anywhere
549
+ // in the grammar (bounds/relation/size/direction targets), freeing up a bare number to mean a
550
+ // literal value in the targets that accept one (e.g. size targets).
551
+ parseNodeId() {
552
+ this.next(); // 'node'
553
+ const idTok = this.next();
554
+ if (idTok.type !== 'number') {
555
+ throw new Error(`Expected a node id (a non-negative integer) after 'node' but found '${idTok.value || 'end of predicate'}' at position ${idTok.pos}.`);
556
+ }
557
+ const n = Number(idTok.value);
558
+ if (n < 0 || !Number.isInteger(n)) {
559
+ throw new Error(`A node id must be a non-negative integer, got '${idTok.value}' at position ${idTok.pos}.`);
560
+ }
561
+ return String(n);
562
+ }
563
+ parseBoundsPred() {
564
+ this.next(); // 'bounds' | 'rect'
565
+ const opTok = this.next();
566
+ const opLower = opTok.value.toLowerCase();
567
+ if (!BOUNDS_OPS.has(opLower)) {
568
+ throw new Error(`Unknown bounds operator '${opTok.value}'. Valid operators: ${VALID_BOUNDS_OPS_LIST}.`);
569
+ }
570
+ const target = this.parseBoundsTarget();
571
+ return { type: 'bounds', op: BOUNDS_OP_CANON[opLower], target };
572
+ }
573
+ parseBoundsTarget() {
574
+ if (this.isNodeKeyword(this.peek())) {
575
+ return { type: 'id', id: this.parseNodeId() };
576
+ }
577
+ const numbers = [this.parseSignedNumber()];
578
+ while (this.isPunct(this.peek(), ',')) {
579
+ this.next();
580
+ numbers.push(this.parseSignedNumber());
581
+ }
582
+ if (numbers.length === 2) {
583
+ return { type: 'point', x: numbers[0], y: numbers[1] };
584
+ }
585
+ if (numbers.length === 4) {
586
+ const [left, top, width, height] = numbers;
587
+ return { type: 'rect', rect: { left, top, width, height } };
588
+ }
589
+ const hint = numbers.length === 1 ? ` Did you mean 'node ${numbers[0]}'?` : '';
590
+ throw new Error(`A bounds target must be 'node <id>' (another element), 2 numbers (point 'x,y'), or 4 numbers (rect 'left,top,width,height') — got ${numbers.length} number(s).${hint}`);
591
+ }
592
+ parseRelPred() {
593
+ const kwTok = this.next();
594
+ const kind = RELATION_KIND[kwTok.value.toLowerCase()];
595
+ const target = this.parseRelTarget();
596
+ return { type: 'rel', kind, target };
597
+ }
598
+ parseOrdinalChildOf() {
599
+ const ordTok = this.next();
600
+ const ordinal = parseOrdinalValue(ordTok.value);
601
+ this.next(); // 'child-of'
602
+ const target = this.parseRelTarget();
603
+ return { type: 'rel', kind: 'nthChild', ordinal, target };
604
+ }
605
+ parseRelTarget() {
606
+ const tok = this.peek();
607
+ if (this.isNodeKeyword(tok)) {
608
+ return { type: 'id', id: this.parseNodeId() };
609
+ }
610
+ if (tok.type === 'number') {
611
+ throw new Error(`A relation target must be 'node <id>' to reference another element, or a sub-predicate — found a bare number '${tok.value}' at position ${tok.pos}. Did you mean 'node ${tok.value}'?`);
612
+ }
613
+ if (tok.type === 'eof') {
614
+ throw new Error(`Expected 'node <id>' or a sub-predicate after the relation, but found end of predicate at position ${tok.pos}.`);
615
+ }
616
+ // Any other target is a single Atom — this already covers a fully parenthesized
617
+ // sub-Expr (parseAtom's own "(" Expr ")" case) as well as a bare single-token target
618
+ // like a nullary predicate, e.g. "ancestor-of text" inside "child-of (ancestor-of text and tagName = BUTTON)".
619
+ const atom = this.parseAtom();
620
+ return { type: 'expr', ast: atom };
621
+ }
622
+ parseSizePred() {
623
+ const kwTok = this.next();
624
+ const kind = SIZE_OP_KIND[kwTok.value.toLowerCase()];
625
+ const target = this.parseSizeTarget();
626
+ return { type: 'size', kind, target };
627
+ }
628
+ parseSizeTarget() {
629
+ if (this.isNodeKeyword(this.peek())) {
630
+ return { type: 'id', id: this.parseNodeId() };
631
+ }
632
+ const numbers = [this.parseSignedNumber()];
633
+ while (this.isPunct(this.peek(), ',')) {
634
+ this.next();
635
+ numbers.push(this.parseSignedNumber());
636
+ }
637
+ if (numbers.length === 1) {
638
+ return { type: 'value', value: numbers[0] };
639
+ }
640
+ if (numbers.length === 2) {
641
+ return { type: 'size', width: numbers[0], height: numbers[1] };
642
+ }
643
+ if (numbers.length === 4) {
644
+ const [left, top, width, height] = numbers;
645
+ return { type: 'rect', rect: { left, top, width, height } };
646
+ }
647
+ throw new Error(`A size target must be a single number (a literal value), 2 numbers (size 'width,height'), 4 numbers (rect 'left,top,width,height'), or 'node <id>' (another element) — got ${numbers.length} numbers.`);
648
+ }
649
+ parseHasAttrPred() {
650
+ this.next(); // 'has-attribute'
651
+ const name = this.parseSingleValue();
652
+ return { type: 'hasAttr', name };
653
+ }
654
+ parseDirPred() {
655
+ const kwTok = this.next();
656
+ const kind = DIR_OP_KIND[kwTok.value.toLowerCase()];
657
+ const target = this.parseDirTarget();
658
+ return { type: 'dir', kind, target };
659
+ }
660
+ parseDirTarget() {
661
+ if (this.isNodeKeyword(this.peek())) {
662
+ return { type: 'id', id: this.parseNodeId() };
663
+ }
664
+ const numbers = [this.parseSignedNumber()];
665
+ while (this.isPunct(this.peek(), ',')) {
666
+ this.next();
667
+ numbers.push(this.parseSignedNumber());
668
+ }
669
+ if (numbers.length === 4) {
670
+ const [left, top, width, height] = numbers;
671
+ return { type: 'rect', rect: { left, top, width, height } };
672
+ }
673
+ const hint = numbers.length === 1 ? ` Did you mean 'node ${numbers[0]}'?` : '';
674
+ throw new Error(`A direction target must be 'node <id>' (another element) or 4 numbers (rect 'left,top,width,height') — got ${numbers.length} number(s).${hint}`);
675
+ }
676
+ }
677
+ export function parsePredicate(source) {
678
+ if (!source || !source.trim()) {
679
+ throw new Error('Predicate is empty. A dom-search predicate cannot be blank — see the dom-search predicate grammar documentation for the grammar and examples.');
680
+ }
681
+ const tokens = tokenize(source);
682
+ const parser = new Parser(tokens);
683
+ return parser.parseTopLevel();
684
+ }
685
+ // ---------------------------------------------------------------------------
686
+ // astNeedsIndex
687
+ // ---------------------------------------------------------------------------
688
+ export function astNeedsIndex(ast) {
689
+ switch (ast.type) {
690
+ case 'or':
691
+ case 'and':
692
+ return astNeedsIndex(ast.left) || astNeedsIndex(ast.right);
693
+ case 'not':
694
+ return astNeedsIndex(ast.inner);
695
+ case 'rel':
696
+ return true;
697
+ case 'bounds':
698
+ case 'size':
699
+ case 'dir':
700
+ return ast.target.type === 'id';
701
+ case 'prop':
702
+ case 'nullary':
703
+ case 'hasAttr':
704
+ return false;
705
+ }
706
+ }
707
+ function collectReferencedIds(ast, out) {
708
+ switch (ast.type) {
709
+ case 'or':
710
+ case 'and':
711
+ collectReferencedIds(ast.left, out);
712
+ collectReferencedIds(ast.right, out);
713
+ return;
714
+ case 'not':
715
+ collectReferencedIds(ast.inner, out);
716
+ return;
717
+ case 'bounds':
718
+ case 'size':
719
+ case 'dir':
720
+ if (ast.target.type === 'id')
721
+ out.add(ast.target.id);
722
+ return;
723
+ case 'rel':
724
+ if (ast.target.type === 'id')
725
+ out.add(ast.target.id);
726
+ else
727
+ collectReferencedIds(ast.target.ast, out);
728
+ return;
729
+ case 'prop':
730
+ case 'nullary':
731
+ case 'hasAttr':
732
+ return;
733
+ }
734
+ }
735
+ /**
736
+ * Validates every literal node id referenced anywhere in the predicate (bounds targets and
737
+ * relation targets, including inside nested chained-relation sub-predicates) against the index,
738
+ * up front — so a missing id is always caught deterministically, rather than only when tree
739
+ * traversal happens to reach the specific node/predicate combination that would look it up.
740
+ */
741
+ function validateReferencedIds(ast, index) {
742
+ const referenced = new Set();
743
+ collectReferencedIds(ast, referenced);
744
+ const missing = [...referenced].filter(id => !index.nodeById.has(id));
745
+ if (missing.length > 0) {
746
+ const idList = missing.map(id => `'${id}'`).join(', ');
747
+ const label = missing.length === 1 ? 'Node id' : 'Node ids';
748
+ const verb = missing.length === 1 ? 'was' : 'were';
749
+ throw new Error(`${label} ${idList} referenced in the predicate ${verb} not found in this capture.`);
750
+ }
751
+ }
752
+ function buildIndex(root) {
753
+ const nodeById = new Map();
754
+ const parentById = new Map();
755
+ const ancestorIds = new Map();
756
+ function visit(node, parent, ancestors) {
757
+ if (!node)
758
+ return;
759
+ if (Array.isArray(node)) {
760
+ for (const n of node)
761
+ visit(n, parent, ancestors);
762
+ return;
763
+ }
764
+ if (!node.tagName || node.tagName.startsWith('#'))
765
+ return;
766
+ const id = node.id;
767
+ if (id != null) {
768
+ nodeById.set(id, node);
769
+ parentById.set(id, parent);
770
+ ancestorIds.set(id, ancestors);
771
+ }
772
+ const childAncestors = id != null ? [...ancestors, id] : ancestors;
773
+ for (const child of node.childNodes ?? []) {
774
+ visit(child, node, childAncestors);
775
+ }
776
+ }
777
+ visit(root, null, []);
778
+ return { nodeById, parentById, ancestorIds };
779
+ }
780
+ // ---------------------------------------------------------------------------
781
+ // Node-level helpers (local reimplementations — see file header)
782
+ // ---------------------------------------------------------------------------
783
+ function nodeTextLocal(node) {
784
+ return (node.childNodes ?? [])
785
+ .filter((c) => c && c.tagName === '#text')
786
+ .map((c) => c.text ?? '')
787
+ .join('');
788
+ }
789
+ function nodeRectLocal(node) {
790
+ const r = node?.rect;
791
+ if (!r)
792
+ return null;
793
+ const { left, top, width, height } = r;
794
+ if (typeof left !== 'number' || typeof top !== 'number' || typeof width !== 'number' || typeof height !== 'number')
795
+ return null;
796
+ return { left, top, width, height };
797
+ }
798
+ function elementChildrenOf(node) {
799
+ return (node.childNodes ?? []).filter((c) => c && c.tagName && !c.tagName.startsWith('#'));
800
+ }
801
+ function rectContainsLocal(outer, inner) {
802
+ return (outer.left <= inner.left &&
803
+ outer.top <= inner.top &&
804
+ outer.left + outer.width >= inner.left + inner.width &&
805
+ outer.top + outer.height >= inner.top + inner.height);
806
+ }
807
+ function rectsIntersectLocal(a, b) {
808
+ return a.left < b.left + b.width && a.left + a.width > b.left && a.top < b.top + b.height && a.top + a.height > b.top;
809
+ }
810
+ function toStringLoose(v) {
811
+ if (v == null)
812
+ return '';
813
+ if (typeof v === 'object')
814
+ return JSON.stringify(v);
815
+ return String(v);
816
+ }
817
+ function toNumberLoose(v) {
818
+ if (typeof v === 'number')
819
+ return v;
820
+ if (typeof v !== 'string')
821
+ return null;
822
+ const n = parseFloat(v);
823
+ return Number.isNaN(n) ? null : n;
824
+ }
825
+ function getPropertyValue(node, path) {
826
+ if (path.length === 1 && path[0] === 'text')
827
+ return nodeTextLocal(node);
828
+ let cur = node;
829
+ for (const seg of path) {
830
+ if (cur == null || typeof cur !== 'object')
831
+ return undefined;
832
+ cur = cur[seg];
833
+ }
834
+ return cur;
835
+ }
836
+ // ---------------------------------------------------------------------------
837
+ // Evaluator
838
+ // ---------------------------------------------------------------------------
839
+ function evaluateProp(node, ast) {
840
+ const raw = getPropertyValue(node, ast.path);
841
+ if (ast.op === 'regex') {
842
+ return ast.value.test(toStringLoose(raw));
843
+ }
844
+ if (ast.op === 'in') {
845
+ const rawStr = toStringLoose(raw).toLowerCase();
846
+ return ast.value.some(v => rawStr === v.toLowerCase());
847
+ }
848
+ if (ast.op === 'lt' || ast.op === 'lte' || ast.op === 'gt' || ast.op === 'gte') {
849
+ const a = toNumberLoose(raw);
850
+ const b = toNumberLoose(ast.value);
851
+ if (a === null || b === null)
852
+ return false;
853
+ if (ast.op === 'lt')
854
+ return a < b;
855
+ if (ast.op === 'lte')
856
+ return a <= b;
857
+ if (ast.op === 'gt')
858
+ return a > b;
859
+ return a >= b;
860
+ }
861
+ const rawStr = toStringLoose(raw).toLowerCase();
862
+ const value = ast.value.toLowerCase();
863
+ switch (ast.op) {
864
+ case 'eq':
865
+ return rawStr === value;
866
+ case 'neq':
867
+ return rawStr !== value;
868
+ case 'startsWith':
869
+ return rawStr.startsWith(value);
870
+ case 'endsWith':
871
+ return rawStr.endsWith(value);
872
+ case 'contains':
873
+ return rawStr.includes(value);
874
+ default:
875
+ return false;
876
+ }
877
+ }
878
+ function evaluateNullary(name, node) {
879
+ const tagName = (node.tagName ?? '').toUpperCase();
880
+ const attrs = node.attributes ?? {};
881
+ const style = node.style ?? {};
882
+ switch (name) {
883
+ case 'text':
884
+ return nodeTextLocal(node).trim().length > 0;
885
+ case 'image': {
886
+ if (tagName === 'IMG' || tagName === 'SVG' || tagName === 'PICTURE')
887
+ return true;
888
+ const bg = style['background-image'];
889
+ return !!bg && bg !== 'none';
890
+ }
891
+ case 'gif':
892
+ return tagName === 'IMG' && /\.gif$/i.test(attrs.src ?? '');
893
+ case 'logo': {
894
+ const hay = [attrs.class, attrs.id, attrs.alt].filter(Boolean).join(' ').toLowerCase();
895
+ return hay.includes('logo');
896
+ }
897
+ case 'link':
898
+ return tagName === 'A';
899
+ case 'button': {
900
+ if (tagName === 'BUTTON')
901
+ return true;
902
+ if (tagName === 'INPUT') {
903
+ const type = (attrs.type ?? '').toLowerCase();
904
+ if (type === 'button' || type === 'submit' || type === 'reset')
905
+ return true;
906
+ }
907
+ return (attrs.role ?? '').toLowerCase() === 'button';
908
+ }
909
+ case 'heading':
910
+ return /^H[1-6]$/.test(tagName);
911
+ case 'hidden':
912
+ return !isVisible(node);
913
+ case 'visible':
914
+ return isVisible(node);
915
+ case 'empty':
916
+ return nodeTextLocal(node).trim().length === 0 && elementChildrenOf(node).length === 0;
917
+ case 'true':
918
+ return true;
919
+ case 'false':
920
+ return false;
921
+ default:
922
+ return false;
923
+ }
924
+ }
925
+ function evaluateHasAttr(node, name) {
926
+ const attrs = node.attributes ?? {};
927
+ const target = name.toLowerCase();
928
+ return Object.keys(attrs).some(k => k.toLowerCase() === target);
929
+ }
930
+ function resolveBoundsTarget(target, index) {
931
+ if (target.type === 'point')
932
+ return { left: target.x, top: target.y, width: 0, height: 0 };
933
+ if (target.type === 'rect')
934
+ return target.rect;
935
+ // Referenced ids are validated up front in searchDom, so this lookup is guaranteed to succeed.
936
+ return nodeRectLocal(index.nodeById.get(target.id));
937
+ }
938
+ function evaluateBounds(node, ast, index) {
939
+ const r = nodeRectLocal(node);
940
+ if (!r)
941
+ return false;
942
+ const targetRect = resolveBoundsTarget(ast.target, index);
943
+ if (!targetRect)
944
+ return false;
945
+ switch (ast.op) {
946
+ case 'contains':
947
+ return rectContainsLocal(r, targetRect);
948
+ case 'intersects':
949
+ return rectsIntersectLocal(r, targetRect);
950
+ case 'containedBy':
951
+ return rectContainsLocal(targetRect, r);
952
+ }
953
+ }
954
+ function scalarFromWH(width, height, kind) {
955
+ switch (kind) {
956
+ case 'larger':
957
+ case 'smaller':
958
+ return width * height;
959
+ case 'wider':
960
+ case 'narrower':
961
+ return width;
962
+ case 'taller':
963
+ case 'shorter':
964
+ return height;
965
+ }
966
+ }
967
+ function resolveSizeTargetScalar(target, kind, index) {
968
+ if (target.type === 'value')
969
+ return target.value;
970
+ if (target.type === 'size')
971
+ return scalarFromWH(target.width, target.height, kind);
972
+ if (target.type === 'rect')
973
+ return scalarFromWH(target.rect.width, target.rect.height, kind);
974
+ // Referenced ids are validated up front in searchDom.
975
+ const r = nodeRectLocal(index.nodeById.get(target.id));
976
+ return r ? scalarFromWH(r.width, r.height, kind) : null;
977
+ }
978
+ function evaluateSizePred(node, ast, index) {
979
+ const r = nodeRectLocal(node);
980
+ if (!r)
981
+ return false;
982
+ const selfScalar = scalarFromWH(r.width, r.height, ast.kind);
983
+ const targetScalar = resolveSizeTargetScalar(ast.target, ast.kind, index);
984
+ if (targetScalar === null)
985
+ return false;
986
+ switch (ast.kind) {
987
+ case 'larger':
988
+ case 'wider':
989
+ case 'taller':
990
+ return selfScalar > targetScalar;
991
+ case 'smaller':
992
+ case 'narrower':
993
+ case 'shorter':
994
+ return selfScalar < targetScalar;
995
+ }
996
+ }
997
+ function resolveDirTargetRect(target, index) {
998
+ if (target.type === 'rect')
999
+ return target.rect;
1000
+ // Referenced ids are validated up front in searchDom.
1001
+ return nodeRectLocal(index.nodeById.get(target.id));
1002
+ }
1003
+ function evaluateDirPred(node, ast, index) {
1004
+ const r = nodeRectLocal(node);
1005
+ if (!r)
1006
+ return false;
1007
+ const t = resolveDirTargetRect(ast.target, index);
1008
+ if (!t)
1009
+ return false;
1010
+ switch (ast.kind) {
1011
+ case 'above':
1012
+ return r.top + r.height <= t.top;
1013
+ case 'below':
1014
+ return r.top >= t.top + t.height;
1015
+ case 'leftOf':
1016
+ return r.left + r.width <= t.left;
1017
+ case 'rightOf':
1018
+ return r.left >= t.left + t.width;
1019
+ }
1020
+ }
1021
+ function resolveRelTargetMatch(candidateNode, target, index) {
1022
+ if (target.type === 'id') {
1023
+ // Referenced ids are validated up front in searchDom, so no existence check needed here.
1024
+ return candidateNode.id === target.id;
1025
+ }
1026
+ return evaluateNode(candidateNode, target.ast, index);
1027
+ }
1028
+ function someDescendant(node, predicate) {
1029
+ for (const child of elementChildrenOf(node)) {
1030
+ if (predicate(child))
1031
+ return true;
1032
+ if (someDescendant(child, predicate))
1033
+ return true;
1034
+ }
1035
+ return false;
1036
+ }
1037
+ function evaluateRel(node, ast, index) {
1038
+ const nodeId = node.id;
1039
+ if (ast.kind === 'child' || ast.kind === 'nthChild') {
1040
+ const parent = index.parentById.get(nodeId);
1041
+ if (!parent)
1042
+ return false;
1043
+ if (ast.kind === 'nthChild') {
1044
+ const siblings = elementChildrenOf(parent);
1045
+ const idx = ast.ordinal - 1;
1046
+ if (idx < 0 || idx >= siblings.length)
1047
+ return false;
1048
+ if (siblings[idx].id !== nodeId)
1049
+ return false;
1050
+ }
1051
+ return resolveRelTargetMatch(parent, ast.target, index);
1052
+ }
1053
+ if (ast.kind === 'parent') {
1054
+ return elementChildrenOf(node).some(child => resolveRelTargetMatch(child, ast.target, index));
1055
+ }
1056
+ if (ast.kind === 'sibling') {
1057
+ const parent = index.parentById.get(nodeId);
1058
+ if (!parent)
1059
+ return false;
1060
+ return elementChildrenOf(parent).some(sib => {
1061
+ const sibId = sib.id;
1062
+ if (sibId === nodeId)
1063
+ return false;
1064
+ return resolveRelTargetMatch(sib, ast.target, index);
1065
+ });
1066
+ }
1067
+ if (ast.kind === 'ancestor') {
1068
+ return someDescendant(node, d => resolveRelTargetMatch(d, ast.target, index));
1069
+ }
1070
+ if (ast.kind === 'descendant') {
1071
+ const chain = index.ancestorIds.get(nodeId) ?? [];
1072
+ return chain.some(aid => {
1073
+ const a = index.nodeById.get(aid);
1074
+ return a ? resolveRelTargetMatch(a, ast.target, index) : false;
1075
+ });
1076
+ }
1077
+ return false;
1078
+ }
1079
+ function evaluateNode(node, ast, index) {
1080
+ switch (ast.type) {
1081
+ case 'or':
1082
+ return evaluateNode(node, ast.left, index) || evaluateNode(node, ast.right, index);
1083
+ case 'and':
1084
+ return evaluateNode(node, ast.left, index) && evaluateNode(node, ast.right, index);
1085
+ case 'not':
1086
+ return !evaluateNode(node, ast.inner, index);
1087
+ case 'bounds':
1088
+ return evaluateBounds(node, ast, index);
1089
+ case 'rel':
1090
+ return evaluateRel(node, ast, index);
1091
+ case 'size':
1092
+ return evaluateSizePred(node, ast, index);
1093
+ case 'dir':
1094
+ return evaluateDirPred(node, ast, index);
1095
+ case 'prop':
1096
+ return evaluateProp(node, ast);
1097
+ case 'nullary':
1098
+ return evaluateNullary(ast.name, node);
1099
+ case 'hasAttr':
1100
+ return evaluateHasAttr(node, ast.name);
1101
+ }
1102
+ }
1103
+ // ---------------------------------------------------------------------------
1104
+ // searchDom
1105
+ // ---------------------------------------------------------------------------
1106
+ /**
1107
+ * Searches a DOM capture for elements matching a boolean predicate. See `llm-docs/dom-search.md`
1108
+ * in this package (shipped in the tarball) for the full predicate language documentation (grammar,
1109
+ * operators, examples) — that file is the canonical, copy-paste-friendly source; don't re-describe
1110
+ * the grammar here or anywhere else, keep it in sync by editing it and copying it where it's
1111
+ * needed instead.
1112
+ *
1113
+ * @param domCaptureJson Raw JSON text of a DOM capture file (as written by eyes-inspect's `dom` command) — a single node, or an array of nodes.
1114
+ * @param predicateSource The predicate string described in `llm-docs/dom-search.md`.
1115
+ * @returns One line per matching element, most indented lines the deepest — each line is that element's own properties (minus `childNodes`) as JSON, with a trailing `# <text>` comment when it has non-empty text.
1116
+ */
1117
+ export function searchDom(domCaptureJson, predicateSource) {
1118
+ const ast = parsePredicate(predicateSource);
1119
+ const root = JSON.parse(domCaptureJson);
1120
+ const index = astNeedsIndex(ast) ? buildIndex(root) : undefined;
1121
+ if (index)
1122
+ validateReferencedIds(ast, index);
1123
+ const lines = [];
1124
+ function walk(node, depth) {
1125
+ if (!node)
1126
+ return;
1127
+ if (Array.isArray(node)) {
1128
+ for (const n of node)
1129
+ walk(n, depth);
1130
+ return;
1131
+ }
1132
+ if (!node.tagName || node.tagName.startsWith('#'))
1133
+ return;
1134
+ if (evaluateNode(node, ast, index)) {
1135
+ const { childNodes: _childNodes, ...rest } = node;
1136
+ // Collapse whitespace (including any literal newlines from the raw text) so a match with
1137
+ // multi-line text still renders as exactly one output line, matching the documented
1138
+ // one-line-per-match format. Only the trailing annotation is collapsed — the underlying
1139
+ // `text` property used for predicate matching keeps the raw, uncollapsed content.
1140
+ const displayText = nodeTextLocal(node).replace(/\s+/g, ' ').trim();
1141
+ const suffix = displayText.length > 0 ? ` # ${displayText}` : '';
1142
+ lines.push(' '.repeat(depth) + JSON.stringify(rest) + suffix);
1143
+ }
1144
+ for (const child of node.childNodes ?? []) {
1145
+ walk(child, depth + 1);
1146
+ }
1147
+ }
1148
+ walk(root, 0);
1149
+ return lines.join('\n');
1150
+ }