capybara-lightpanda 0.2.2 → 0.4.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.
@@ -69,748 +69,9 @@
69
69
  _signalTurbo('idle');
70
70
  });
71
71
 
72
- // ====== XPath 1.0 Evaluator ======
73
-
74
- var XPathEval = (function() {
75
-
76
- // --- Tokenizer ---
77
-
78
- var NODE_TYPES = {text:1, node:1, comment:1, 'processing-instruction':1};
79
-
80
- function tokenize(expr) {
81
- var toks = [], i = 0, len = expr.length;
82
- while (i < len) {
83
- // Skip whitespace
84
- while (i < len && ' \t\n\r'.indexOf(expr[i]) >= 0) i++;
85
- if (i >= len) break;
86
- var c = expr[i];
87
-
88
- // String literals
89
- if (c === '"' || c === "'") {
90
- var q = c, s = ++i;
91
- while (i < len && expr[i] !== q) i++;
92
- toks.push({t: 'S', v: expr.substring(s, i)});
93
- i++;
94
- continue;
95
- }
96
-
97
- // Numbers: digits or . followed by digit
98
- if (c >= '0' && c <= '9' || (c === '.' && i + 1 < len && expr[i + 1] >= '0' && expr[i + 1] <= '9')) {
99
- var s = i;
100
- while (i < len && expr[i] >= '0' && expr[i] <= '9') i++;
101
- if (i < len && expr[i] === '.') { i++; while (i < len && expr[i] >= '0' && expr[i] <= '9') i++; }
102
- toks.push({t: 'D', v: parseFloat(expr.substring(s, i))});
103
- continue;
104
- }
105
-
106
- // Double-char operators
107
- if (i + 1 < len) {
108
- var c2 = expr[i + 1];
109
- if (c === '/' && c2 === '/') { toks.push({t: '//'}); i += 2; continue; }
110
- if (c === ':' && c2 === ':') { toks.push({t: '::'}); i += 2; continue; }
111
- if (c === '!' && c2 === '=') { toks.push({t: '!='}); i += 2; continue; }
112
- if (c === '<' && c2 === '=') { toks.push({t: '<='}); i += 2; continue; }
113
- if (c === '>' && c2 === '=') { toks.push({t: '>='}); i += 2; continue; }
114
- if (c === '.' && c2 === '.') { toks.push({t: '..'}); i += 2; continue; }
115
- }
116
-
117
- // Single-char operators
118
- if ('()[],|=<>+-*$/@.'.indexOf(c) >= 0) { toks.push({t: c}); i++; continue; }
119
-
120
- // Names (NCName, possibly with namespace prefix)
121
- if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c === '_') {
122
- var s = i;
123
- while (i < len && /[a-zA-Z0-9_\-.]/.test(expr[i])) i++;
124
- var name = expr.substring(s, i);
125
- // Check for namespace prefix (name:localname but not name::)
126
- if (i < len && expr[i] === ':' && (i + 1 >= len || expr[i + 1] !== ':')) {
127
- i++; // skip :
128
- if (i < len && expr[i] === '*') { name += ':*'; i++; }
129
- else { var ls = i; while (i < len && /[a-zA-Z0-9_\-.]/.test(expr[i])) i++; name += ':' + expr.substring(ls, i); }
130
- }
131
- toks.push({t: 'N', v: name});
132
- continue;
133
- }
134
-
135
- i++; // skip unknown characters
136
- }
137
- toks.push({t: 'E'}); // EOF
138
- return toks;
139
- }
140
-
141
- // --- Parser ---
142
- // Recursive descent parser producing an AST from XPath 1.0 tokens.
143
-
144
- function Parser(tokens) { this.tk = tokens; this.p = 0; }
145
-
146
- Parser.prototype.peek = function() { return this.tk[this.p]; };
147
- Parser.prototype.next = function() { return this.tk[this.p++]; };
148
- Parser.prototype.expect = function(t) {
149
- var tok = this.next();
150
- if (tok.t !== t) throw new Error('XPath parse error: expected ' + t + ', got ' + tok.t);
151
- return tok;
152
- };
153
- Parser.prototype.at = function(t) { return this.peek().t === t; };
154
- Parser.prototype.match = function(t) { if (this.at(t)) { this.p++; return true; } return false; };
155
- Parser.prototype.lookahead = function(offset) { return this.tk[this.p + offset] || {t: 'E'}; };
156
-
157
- Parser.prototype.parseExpr = function() { return this.parseOrExpr(); };
158
-
159
- Parser.prototype.parseOrExpr = function() {
160
- var left = this.parseAndExpr();
161
- while (this.peek().t === 'N' && this.peek().v === 'or') { this.next(); left = {op: 'or', l: left, r: this.parseAndExpr()}; }
162
- return left;
163
- };
164
-
165
- Parser.prototype.parseAndExpr = function() {
166
- var left = this.parseEqualityExpr();
167
- while (this.peek().t === 'N' && this.peek().v === 'and') { this.next(); left = {op: 'and', l: left, r: this.parseEqualityExpr()}; }
168
- return left;
169
- };
170
-
171
- Parser.prototype.parseEqualityExpr = function() {
172
- var left = this.parseRelationalExpr();
173
- while (this.at('=') || this.at('!=')) {
174
- var op = this.next().t === '=' ? 'eq' : 'neq';
175
- left = {op: op, l: left, r: this.parseRelationalExpr()};
176
- }
177
- return left;
178
- };
179
-
180
- Parser.prototype.parseRelationalExpr = function() {
181
- var left = this.parseAdditiveExpr();
182
- while (true) {
183
- var t = this.peek().t, op;
184
- if (t === '<') op = 'lt'; else if (t === '>') op = 'gt'; else if (t === '<=') op = 'lte'; else if (t === '>=') op = 'gte'; else break;
185
- this.next();
186
- left = {op: op, l: left, r: this.parseAdditiveExpr()};
187
- }
188
- return left;
189
- };
190
-
191
- Parser.prototype.parseAdditiveExpr = function() {
192
- var left = this.parseMultExpr();
193
- while (this.at('+') || this.at('-')) {
194
- var op = this.next().t === '+' ? 'add' : 'sub';
195
- left = {op: op, l: left, r: this.parseMultExpr()};
196
- }
197
- return left;
198
- };
199
-
200
- // After a complete unary expression, * is multiply; div/mod are operators
201
- Parser.prototype.parseMultExpr = function() {
202
- var left = this.parseUnaryExpr();
203
- while (true) {
204
- var t = this.peek(), op;
205
- if (t.t === '*') op = 'mul';
206
- else if (t.t === 'N' && t.v === 'div') op = 'div';
207
- else if (t.t === 'N' && t.v === 'mod') op = 'mod';
208
- else break;
209
- this.next();
210
- left = {op: op, l: left, r: this.parseUnaryExpr()};
211
- }
212
- return left;
213
- };
214
-
215
- Parser.prototype.parseUnaryExpr = function() {
216
- if (this.at('-')) { this.next(); return {op: 'neg', a: this.parseUnaryExpr()}; }
217
- return this.parseUnionExpr();
218
- };
219
-
220
- Parser.prototype.parseUnionExpr = function() {
221
- var left = this.parsePathExpr();
222
- while (this.match('|')) { left = {op: 'union', l: left, r: this.parsePathExpr()}; }
223
- return left;
224
- };
225
-
226
- // Distinguishes filter expressions (starting with primary) from location paths
227
- Parser.prototype.parsePathExpr = function() {
228
- var t = this.peek();
229
-
230
- // Absolute path: / or //
231
- if (t.t === '/' || t.t === '//') return this.parseAbsPath();
232
-
233
- // Check if this starts a filter expression (primary expr)
234
- var isFilter = false;
235
- if (t.t === '(' || t.t === 'S' || t.t === 'D' || t.t === '$') isFilter = true;
236
- else if (t.t === 'N' && this.lookahead(1).t === '(' && !NODE_TYPES[t.v]) isFilter = true;
237
-
238
- if (isFilter) {
239
- var primary = this.parsePrimaryExpr();
240
- // Parse predicates on the filter expression
241
- while (this.at('[')) { this.next(); var pred = this.parseExpr(); this.expect(']'); primary = {op: 'filt', e: primary, pred: pred}; }
242
- // Optional / or // after filter
243
- if (this.at('/') || this.at('//')) {
244
- var dsl = this.next().t === '//';
245
- var steps = this.parseRelSteps();
246
- if (dsl) steps.unshift({ax: 'descendant-or-self', test: {ty: 'type', nt: 'node'}, preds: []});
247
- return {op: 'fpath', f: primary, steps: steps};
248
- }
249
- return primary;
250
- }
251
-
252
- // Relative location path
253
- return this.parseRelPath();
254
- };
255
-
256
- Parser.prototype.parseAbsPath = function() {
257
- var steps = [];
258
- if (this.match('//')) {
259
- steps.push({ax: 'descendant-or-self', test: {ty: 'type', nt: 'node'}, preds: []});
260
- steps = steps.concat(this.parseRelSteps());
261
- } else {
262
- this.expect('/');
263
- if (this.canStartStep()) steps = this.parseRelSteps();
264
- }
265
- return {op: 'path', abs: true, steps: steps};
266
- };
267
-
268
- Parser.prototype.parseRelPath = function() {
269
- return {op: 'path', abs: false, steps: this.parseRelSteps()};
270
- };
271
-
272
- Parser.prototype.parseRelSteps = function() {
273
- var steps = [this.parseStep()];
274
- while (this.at('/') || this.at('//')) {
275
- if (this.next().t === '//') steps.push({ax: 'descendant-or-self', test: {ty: 'type', nt: 'node'}, preds: []});
276
- steps.push(this.parseStep());
277
- }
278
- return steps;
279
- };
280
-
281
- Parser.prototype.canStartStep = function() {
282
- var t = this.peek().t;
283
- return t === 'N' || t === '*' || t === '.' || t === '..' || t === '@';
284
- };
285
-
286
- Parser.prototype.parseStep = function() {
287
- // Abbreviated steps
288
- if (this.match('.')) return {ax: 'self', test: {ty: 'type', nt: 'node'}, preds: []};
289
- if (this.match('..')) return {ax: 'parent', test: {ty: 'type', nt: 'node'}, preds: []};
290
-
291
- // Determine axis
292
- var axis = 'child';
293
- if (this.match('@')) {
294
- axis = 'attribute';
295
- } else if (this.peek().t === 'N' && this.lookahead(1).t === '::') {
296
- axis = this.next().v; this.next(); // consume name and ::
297
- }
298
-
299
- // Node test
300
- var test;
301
- if (this.match('*')) {
302
- test = {ty: 'name', n: '*'};
303
- } else if (this.peek().t === 'N') {
304
- var name = this.peek().v;
305
- if (NODE_TYPES[name] && this.lookahead(1).t === '(') {
306
- this.next(); this.next(); // consume name and (
307
- if (name === 'processing-instruction' && this.at('S')) this.next(); // optional literal
308
- this.expect(')');
309
- test = {ty: 'type', nt: name};
310
- } else {
311
- this.next();
312
- test = {ty: 'name', n: name};
313
- }
314
- } else {
315
- throw new Error('XPath parse error: expected node test, got ' + this.peek().t);
316
- }
317
-
318
- // Predicates
319
- var preds = [];
320
- while (this.at('[')) { this.next(); preds.push(this.parseExpr()); this.expect(']'); }
321
-
322
- return {ax: axis, test: test, preds: preds};
323
- };
324
-
325
- Parser.prototype.parsePrimaryExpr = function() {
326
- if (this.at('S')) { var v = this.next().v; return {op: 'lit', v: v}; }
327
- if (this.at('D')) { var v = this.next().v; return {op: 'num', v: v}; }
328
- if (this.match('$')) { return {op: 'var', v: this.expect('N').v}; }
329
- if (this.match('(')) { var e = this.parseExpr(); this.expect(')'); return e; }
330
- if (this.at('N')) {
331
- var name = this.next().v;
332
- this.expect('(');
333
- var args = [];
334
- if (!this.at(')')) {
335
- args.push(this.parseExpr());
336
- while (this.match(',')) args.push(this.parseExpr());
337
- }
338
- this.expect(')');
339
- return {op: 'fn', name: name, args: args};
340
- }
341
- throw new Error('XPath parse error: expected primary expression, got ' + this.peek().t);
342
- };
343
-
344
- // --- Evaluator Utilities ---
345
-
346
- function stringVal(node) {
347
- if (!node) return '';
348
- if (node.nodeType === 1 || node.nodeType === 9) return node.textContent || '';
349
- if (node.nodeType === 2) return node.value || '';
350
- return node.nodeValue || node.textContent || '';
351
- }
352
-
353
- function toStr(val) {
354
- if (Array.isArray(val)) return val.length > 0 ? stringVal(val[0]) : '';
355
- if (typeof val === 'boolean') return val ? 'true' : 'false';
356
- if (typeof val === 'number') return isNaN(val) ? 'NaN' : String(val);
357
- return String(val);
358
- }
359
-
360
- function toNum(val) {
361
- if (typeof val === 'number') return val;
362
- if (typeof val === 'boolean') return val ? 1 : 0;
363
- if (Array.isArray(val)) val = toStr(val);
364
- var s = String(val).trim();
365
- return s === '' ? NaN : Number(s);
366
- }
367
-
368
- function toBool(val) {
369
- if (Array.isArray(val)) return val.length > 0;
370
- if (typeof val === 'string') return val.length > 0;
371
- if (typeof val === 'number') return val !== 0 && !isNaN(val);
372
- return Boolean(val);
373
- }
374
-
375
- // --- Comparison ---
376
-
377
- function cmpOp(a, b, op) {
378
- switch (op) {
379
- case 'eq': return a === b;
380
- case 'neq': return a !== b;
381
- case 'lt': return a < b;
382
- case 'gt': return a > b;
383
- case 'lte': return a <= b;
384
- case 'gte': return a >= b;
385
- }
386
- return false;
387
- }
388
-
389
- // XPath comparison with type coercion rules per spec section 3.4
390
- function xCmp(left, right, op) {
391
- var isEq = (op === 'eq' || op === 'neq');
392
- var lArr = Array.isArray(left), rArr = Array.isArray(right);
393
-
394
- // Both node-sets
395
- if (lArr && rArr) {
396
- for (var i = 0; i < left.length; i++) {
397
- var lv = stringVal(left[i]);
398
- for (var j = 0; j < right.length; j++) {
399
- if (isEq ? cmpOp(lv, stringVal(right[j]), op) : cmpOp(toNum(lv), toNum(stringVal(right[j])), op)) return true;
400
- }
401
- }
402
- return false;
403
- }
404
-
405
- // One node-set, one scalar
406
- if (lArr || rArr) {
407
- var ns = lArr ? left : right, other = lArr ? right : left, nsLeft = lArr;
408
-
409
- // Boolean comparison: convert node-set to boolean
410
- if (typeof other === 'boolean') {
411
- var b = ns.length > 0;
412
- return cmpOp(nsLeft ? b : other, nsLeft ? other : b, op);
413
- }
414
-
415
- for (var i = 0; i < ns.length; i++) {
416
- var sv = stringVal(ns[i]);
417
- var a, b;
418
- if (typeof other === 'number') {
419
- a = nsLeft ? toNum(sv) : other;
420
- b = nsLeft ? other : toNum(sv);
421
- } else if (isEq) {
422
- a = nsLeft ? sv : String(other);
423
- b = nsLeft ? String(other) : sv;
424
- } else {
425
- a = nsLeft ? toNum(sv) : toNum(String(other));
426
- b = nsLeft ? toNum(String(other)) : toNum(sv);
427
- }
428
- if (cmpOp(a, b, op)) return true;
429
- }
430
- return false;
431
- }
432
-
433
- // Neither is a node-set
434
- if (isEq) {
435
- if (typeof left === 'boolean' || typeof right === 'boolean') return cmpOp(toBool(left), toBool(right), op);
436
- if (typeof left === 'number' || typeof right === 'number') return cmpOp(toNum(left), toNum(right), op);
437
- return cmpOp(toStr(left), toStr(right), op);
438
- }
439
- return cmpOp(toNum(left), toNum(right), op);
440
- }
441
-
442
- // --- Axis Traversal ---
443
-
444
- function addDesc(node, out) {
445
- var c = node.firstChild;
446
- while (c) { out.push(c); addDesc(c, out); c = c.nextSibling; }
447
- }
448
-
449
- function addFollowing(node, out) {
450
- var n = node;
451
- while (n) {
452
- var s = n.nextSibling;
453
- while (s) { out.push(s); addDesc(s, out); s = s.nextSibling; }
454
- n = n.parentNode;
455
- }
456
- }
457
-
458
- function addPrecedingSubtree(node, out) {
459
- var c = node.lastChild;
460
- while (c) { addPrecedingSubtree(c, out); c = c.previousSibling; }
461
- out.push(node);
462
- }
463
-
464
- function addPreceding(node, out) {
465
- var n = node;
466
- while (n.parentNode) {
467
- var s = n.previousSibling;
468
- while (s) { addPrecedingSubtree(s, out); s = s.previousSibling; }
469
- n = n.parentNode;
470
- }
471
- }
472
-
473
- function getAxisNodes(node, axis) {
474
- var out = [], c, p;
475
- switch (axis) {
476
- case 'child':
477
- c = node.firstChild; while (c) { out.push(c); c = c.nextSibling; } break;
478
- case 'descendant':
479
- addDesc(node, out); break;
480
- case 'descendant-or-self':
481
- out.push(node); addDesc(node, out); break;
482
- case 'self':
483
- out.push(node); break;
484
- case 'parent':
485
- if (node.parentNode) out.push(node.parentNode); break;
486
- case 'ancestor':
487
- // Reverse axis — emit in PROXIMITY order (nearest first) so positional
488
- // predicates evaluate correctly: ancestor::*[1] picks the parent, not
489
- // the root. The final node-set is sorted into document order at the
490
- // XPathEval.find entry point per the XPath spec.
491
- p = node.parentNode; while (p) { out.push(p); p = p.parentNode; } break;
492
- case 'ancestor-or-self':
493
- out.push(node); p = node.parentNode; while (p) { out.push(p); p = p.parentNode; } break;
494
- case 'following-sibling':
495
- c = node.nextSibling; while (c) { out.push(c); c = c.nextSibling; } break;
496
- case 'preceding-sibling':
497
- // Reverse axis — emit in proximity order (closest first).
498
- c = node.previousSibling; while (c) { out.push(c); c = c.previousSibling; } break;
499
- case 'following':
500
- addFollowing(node, out); break;
501
- case 'preceding':
502
- addPreceding(node, out); break;
503
- case 'attribute':
504
- if (node.attributes) { for (var i = 0; i < node.attributes.length; i++) out.push(node.attributes[i]); } break;
505
- case 'namespace':
506
- break; // stub
507
- }
508
- return out;
509
- }
510
-
511
- // --- Node Test Matching ---
512
-
513
- function matchTest(node, test, axis) {
514
- if (test.ty === 'type') {
515
- switch (test.nt) {
516
- case 'node': return true;
517
- case 'text': return node.nodeType === 3;
518
- case 'comment': return node.nodeType === 8;
519
- case 'processing-instruction': return node.nodeType === 7;
520
- }
521
- return false;
522
- }
523
- // Name test
524
- if (axis === 'attribute') {
525
- return test.n === '*' || (node.name || node.nodeName || '').toLowerCase() === test.n.toLowerCase();
526
- }
527
- if (node.nodeType !== 1) return false;
528
- if (test.n === '*') return true;
529
- return node.nodeName.toLowerCase() === test.n.toLowerCase();
530
- }
531
-
532
- // --- Step Evaluation ---
533
-
534
- function evalStep(ctxNodes, step) {
535
- var result = [];
536
- for (var i = 0; i < ctxNodes.length; i++) {
537
- var axNodes = getAxisNodes(ctxNodes[i], step.ax);
538
- // Filter by node test
539
- var filtered = [];
540
- for (var j = 0; j < axNodes.length; j++) {
541
- if (matchTest(axNodes[j], step.test, step.ax)) filtered.push(axNodes[j]);
542
- }
543
- // Apply predicates
544
- var cur = filtered;
545
- for (var p = 0; p < step.preds.length; p++) {
546
- var newCur = [], sz = cur.length;
547
- for (var k = 0; k < cur.length; k++) {
548
- var val = evaluate(step.preds[p], cur[k], k + 1, sz);
549
- if (typeof val === 'number') { if (val === k + 1) newCur.push(cur[k]); }
550
- else { if (toBool(val)) newCur.push(cur[k]); }
551
- }
552
- cur = newCur;
553
- }
554
- // Add to result, dedup
555
- for (var k = 0; k < cur.length; k++) {
556
- if (result.indexOf(cur[k]) < 0) result.push(cur[k]);
557
- }
558
- }
559
- return result;
560
- }
561
-
562
- // --- Document Order Sort ---
563
-
564
- function sortDocOrder(nodes) {
565
- if (nodes.length <= 1) return nodes;
566
- if (nodes[0] && typeof nodes[0].compareDocumentPosition === 'function') {
567
- return nodes.sort(function(a, b) {
568
- if (a === b) return 0;
569
- var pos = a.compareDocumentPosition(b);
570
- return (pos & 4) ? -1 : (pos & 2) ? 1 : 0;
571
- });
572
- }
573
- return nodes;
574
- }
575
-
576
- // --- AST Evaluation ---
577
-
578
- function evaluate(ast, ctx, pos, size) {
579
- if (!ast || !ast.op) {
580
- // Step node (from path parsing)
581
- if (ast && ast.ax) return evalStep([ctx], ast);
582
- throw new Error('XPath eval error: invalid AST node');
583
- }
584
-
585
- switch (ast.op) {
586
- case 'path': {
587
- var nodes;
588
- if (ast.abs) {
589
- nodes = [ctx.nodeType === 9 ? ctx : (ctx.ownerDocument || ctx)];
590
- } else {
591
- nodes = [ctx];
592
- }
593
- for (var i = 0; i < ast.steps.length; i++) nodes = evalStep(nodes, ast.steps[i]);
594
- return nodes;
595
- }
596
-
597
- case 'fpath': {
598
- var base = evaluate(ast.f, ctx, pos, size);
599
- if (!Array.isArray(base)) return base;
600
- for (var i = 0; i < ast.steps.length; i++) base = evalStep(base, ast.steps[i]);
601
- return base;
602
- }
603
-
604
- case 'filt': {
605
- var base = evaluate(ast.e, ctx, pos, size);
606
- if (!Array.isArray(base)) return base;
607
- var out = [], sz = base.length;
608
- for (var i = 0; i < base.length; i++) {
609
- var val = evaluate(ast.pred, base[i], i + 1, sz);
610
- if (typeof val === 'number') { if (val === i + 1) out.push(base[i]); }
611
- else { if (toBool(val)) out.push(base[i]); }
612
- }
613
- return out;
614
- }
615
-
616
- case 'or': return toBool(evaluate(ast.l, ctx, pos, size)) || toBool(evaluate(ast.r, ctx, pos, size));
617
- case 'and': return toBool(evaluate(ast.l, ctx, pos, size)) && toBool(evaluate(ast.r, ctx, pos, size));
618
-
619
- case 'eq': case 'neq': case 'lt': case 'gt': case 'lte': case 'gte':
620
- return xCmp(evaluate(ast.l, ctx, pos, size), evaluate(ast.r, ctx, pos, size), ast.op);
621
-
622
- case 'add': return toNum(evaluate(ast.l, ctx, pos, size)) + toNum(evaluate(ast.r, ctx, pos, size));
623
- case 'sub': return toNum(evaluate(ast.l, ctx, pos, size)) - toNum(evaluate(ast.r, ctx, pos, size));
624
- case 'mul': return toNum(evaluate(ast.l, ctx, pos, size)) * toNum(evaluate(ast.r, ctx, pos, size));
625
- case 'div': return toNum(evaluate(ast.l, ctx, pos, size)) / toNum(evaluate(ast.r, ctx, pos, size));
626
- case 'mod': return toNum(evaluate(ast.l, ctx, pos, size)) % toNum(evaluate(ast.r, ctx, pos, size));
627
- case 'neg': return -toNum(evaluate(ast.a, ctx, pos, size));
628
-
629
- case 'union': {
630
- var l = evaluate(ast.l, ctx, pos, size), r = evaluate(ast.r, ctx, pos, size);
631
- if (!Array.isArray(l) || !Array.isArray(r)) throw new Error('Union requires node-sets');
632
- var merged = l.slice();
633
- for (var i = 0; i < r.length; i++) { if (merged.indexOf(r[i]) < 0) merged.push(r[i]); }
634
- return sortDocOrder(merged);
635
- }
636
-
637
- case 'lit': return ast.v;
638
- case 'num': return ast.v;
639
- case 'var': return '';
640
- case 'fn': return evalFunc(ast.name, ast.args, ctx, pos, size);
641
- }
642
-
643
- throw new Error('XPath eval error: unknown op ' + ast.op);
644
- }
645
-
646
- // --- XPath Functions ---
647
-
648
- function evalFunc(name, args, ctx, pos, size) {
649
- switch (name) {
650
- // -- Node-set functions --
651
- case 'position': return pos;
652
- case 'last': return size;
653
- case 'count': {
654
- var ns = evaluate(args[0], ctx, pos, size);
655
- return Array.isArray(ns) ? ns.length : 0;
656
- }
657
- case 'id': {
658
- var val = evaluate(args[0], ctx, pos, size);
659
- var idStr;
660
- if (Array.isArray(val)) { idStr = []; for (var i = 0; i < val.length; i++) idStr.push(stringVal(val[i])); idStr = idStr.join(' '); }
661
- else idStr = toStr(val);
662
- var ids = idStr.split(/\s+/), doc = ctx.ownerDocument || ctx, nodes = [];
663
- for (var i = 0; i < ids.length; i++) {
664
- if (ids[i]) { var el = doc.getElementById(ids[i]); if (el && nodes.indexOf(el) < 0) nodes.push(el); }
665
- }
666
- return nodes;
667
- }
668
- case 'local-name': {
669
- var ns = args.length === 0 ? [ctx] : evaluate(args[0], ctx, pos, size);
670
- if (!Array.isArray(ns) || ns.length === 0) return '';
671
- return (ns[0].localName || ns[0].nodeName || '').toLowerCase();
672
- }
673
- case 'name': case 'namespace-uri': {
674
- if (name === 'namespace-uri') return '';
675
- var ns = args.length === 0 ? [ctx] : evaluate(args[0], ctx, pos, size);
676
- if (!Array.isArray(ns) || ns.length === 0) return '';
677
- return (ns[0].nodeName || '').toLowerCase();
678
- }
679
-
680
- // -- String functions --
681
- case 'string':
682
- return args.length === 0 ? stringVal(ctx) : toStr(evaluate(args[0], ctx, pos, size));
683
- case 'concat': {
684
- var r = '';
685
- for (var i = 0; i < args.length; i++) r += toStr(evaluate(args[i], ctx, pos, size));
686
- return r;
687
- }
688
- case 'contains': {
689
- var s1 = toStr(evaluate(args[0], ctx, pos, size));
690
- var s2 = toStr(evaluate(args[1], ctx, pos, size));
691
- return s1.indexOf(s2) >= 0;
692
- }
693
- case 'starts-with': {
694
- var s1 = toStr(evaluate(args[0], ctx, pos, size));
695
- var s2 = toStr(evaluate(args[1], ctx, pos, size));
696
- return s1.indexOf(s2) === 0;
697
- }
698
- case 'substring': {
699
- var s = toStr(evaluate(args[0], ctx, pos, size));
700
- var start = Math.round(toNum(evaluate(args[1], ctx, pos, size)));
701
- if (isNaN(start)) return '';
702
- if (args.length >= 3) {
703
- var len = Math.round(toNum(evaluate(args[2], ctx, pos, size)));
704
- if (isNaN(len)) return '';
705
- var si = Math.max(start - 1, 0), ei = Math.min(start - 1 + len, s.length);
706
- return si >= ei ? '' : s.substring(si, ei);
707
- }
708
- return s.substring(Math.max(start - 1, 0));
709
- }
710
- case 'substring-before': {
711
- var s1 = toStr(evaluate(args[0], ctx, pos, size));
712
- var s2 = toStr(evaluate(args[1], ctx, pos, size));
713
- var idx = s1.indexOf(s2);
714
- return idx >= 0 ? s1.substring(0, idx) : '';
715
- }
716
- case 'substring-after': {
717
- var s1 = toStr(evaluate(args[0], ctx, pos, size));
718
- var s2 = toStr(evaluate(args[1], ctx, pos, size));
719
- var idx = s1.indexOf(s2);
720
- return idx >= 0 ? s1.substring(idx + s2.length) : '';
721
- }
722
- case 'string-length': {
723
- var s = args.length === 0 ? stringVal(ctx) : toStr(evaluate(args[0], ctx, pos, size));
724
- return s.length;
725
- }
726
- case 'normalize-space': {
727
- var s = args.length === 0 ? stringVal(ctx) : toStr(evaluate(args[0], ctx, pos, size));
728
- return s.replace(/^\s+|\s+$/g, '').replace(/\s+/g, ' ');
729
- }
730
- case 'translate': {
731
- var s = toStr(evaluate(args[0], ctx, pos, size));
732
- var from = toStr(evaluate(args[1], ctx, pos, size));
733
- var to = toStr(evaluate(args[2], ctx, pos, size));
734
- var r = '';
735
- for (var i = 0; i < s.length; i++) {
736
- var idx = from.indexOf(s[i]);
737
- if (idx < 0) r += s[i];
738
- else if (idx < to.length) r += to[idx];
739
- // else: character removed
740
- }
741
- return r;
742
- }
743
-
744
- // -- Boolean functions --
745
- case 'boolean': return toBool(evaluate(args[0], ctx, pos, size));
746
- case 'not': return !toBool(evaluate(args[0], ctx, pos, size));
747
- case 'true': return true;
748
- case 'false': return false;
749
- case 'lang': return false; // stub
750
-
751
- // -- Number functions --
752
- case 'number':
753
- return args.length === 0 ? toNum(stringVal(ctx)) : toNum(evaluate(args[0], ctx, pos, size));
754
- case 'sum': {
755
- var ns = evaluate(args[0], ctx, pos, size);
756
- if (!Array.isArray(ns)) return NaN;
757
- var total = 0;
758
- for (var i = 0; i < ns.length; i++) total += toNum(stringVal(ns[i]));
759
- return total;
760
- }
761
- case 'floor': return Math.floor(toNum(evaluate(args[0], ctx, pos, size)));
762
- case 'ceiling': return Math.ceil(toNum(evaluate(args[0], ctx, pos, size)));
763
- case 'round': {
764
- var n = toNum(evaluate(args[0], ctx, pos, size));
765
- if (isNaN(n) || !isFinite(n)) return n;
766
- return Math.round(n);
767
- }
768
- }
769
- throw new Error('XPath error: unknown function ' + name + '()');
770
- }
771
-
772
- // --- Public API ---
773
- return {
774
- find: function(expression, contextNode) {
775
- var tokens = tokenize(expression);
776
- var parser = new Parser(tokens);
777
- var ast = parser.parseExpr();
778
- var result = evaluate(ast, contextNode, 1, 1);
779
- // XPath spec: deliver the final node-set in document order. Internal
780
- // step results are kept in axis order for predicate semantics; we sort
781
- // here, at the public entry, so consumers see ordered results.
782
- return Array.isArray(result) ? sortDocOrder(result) : [];
783
- }
784
- };
785
- })();
786
-
787
72
  // --- Main API ---
788
73
 
789
74
  window._lightpanda = {
790
- xpathFind: function(expression, contextNode) {
791
- // Use native XPath if available (non-polyfilled)
792
- if (typeof contextNode.evaluate === 'function' && typeof XPathResult !== 'undefined' &&
793
- !XPathResult._polyfilled) {
794
- try {
795
- var result = contextNode.evaluate(expression, contextNode, null,
796
- XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
797
- var nodes = [];
798
- for (var i = 0; i < result.snapshotLength; i++) {
799
- nodes.push(result.snapshotItem(i));
800
- }
801
- return nodes;
802
- } catch(e) {
803
- // Fall through to polyfill
804
- }
805
- }
806
-
807
- try {
808
- return XPathEval.find(expression, contextNode);
809
- } catch(e) {
810
- return [];
811
- }
812
- },
813
-
814
75
  turbo: {
815
76
  pending: function() { return _pendingTurboOps; },
816
77
  idle: function() { return _pendingTurboOps <= 0; }
@@ -823,56 +84,44 @@
823
84
  // Page.addScriptToEvaluateOnNewDocument.
824
85
 
825
86
  // Returns true if `el` is visible per Capybara's semantics. Lightpanda's
826
- // UA stylesheet (PR #2294, nightly ≥5918) puts `display:none` on
827
- // HEAD/SCRIPT/STYLE/NOSCRIPT/TEMPLATE/TITLE and `[type=hidden]`, and
828
- // `[hidden]` / closed-<details> children also resolve to `display:none`
829
- // through the cascade — so a single display:none walk catches the
830
- // unrendered-element cases without an explicit tag list. `visibility`
831
- // and offsetParent stay explicit because they aren't covered by display.
832
- // `checkVisibility()` does the parent walk for us when available.
87
+ // `checkVisibility()` walks ancestors and rejects `display:none` from
88
+ // inline styles, stylesheets, and the UA sheet (PR #2294 — covers
89
+ // HEAD/SCRIPT/STYLE/NOSCRIPT/TEMPLATE/TITLE, `[hidden]`, `[type=hidden]`,
90
+ // closed-<details> children). `visibility:hidden|collapse` is handled
91
+ // separately because checkVisibility's defaults (per spec) ignore it.
833
92
  //
834
- // `opts.checkOffsetParent` (default true): when true, also rejects
835
- // elements with `offsetParent === null` as a fallback for ancestor
836
- // `display:none`. The visible_text walker passes false because it
837
- // already short-circuits when descending into hidden subtrees.
838
- isVisible: function(el, opts) {
93
+ // Intentional upstream out-of-scope (upstream-wishlist.md C10):
94
+ // Lightpanda is a headless agentic browser and deliberately doesn't
95
+ // load external `<link rel=stylesheet>` or apply `@media` rules to
96
+ // the cascade, and `matchMedia()` returns false for every query.
97
+ // Responsive patterns that hide one of two mobile/desktop CTA
98
+ // duplicates via `@media (min-width: …) { display: none }` leak
99
+ // both variants past this check — Capybara then raises
100
+ // `Ambiguous: found 2 elements`. There is no in-gem fix: rebuilding
101
+ // the CSS cascade in JS would need a CSS parser, sync access to
102
+ // remote stylesheets, and a real media-query evaluator. Run
103
+ // cuprite for responsive-UI assertions.
104
+ isVisible: function(el) {
839
105
  if (!el || el.nodeType !== 1) return false;
840
- var checkOffsetParent = !opts || opts.checkOffsetParent !== false;
841
- var TAG = (el.tagName || '').toUpperCase();
842
-
843
106
  var win = el.ownerDocument.defaultView || window;
844
107
  var style = win.getComputedStyle(el);
845
108
  if (style.visibility === 'hidden' || style.visibility === 'collapse') return false;
846
- if (typeof el.checkVisibility === 'function') {
847
- if (!el.checkVisibility()) return false;
848
- } else {
849
- var node = el;
850
- while (node && node.nodeType === 1) {
851
- if (win.getComputedStyle(node).display === 'none') return false;
852
- node = node.parentElement;
853
- }
854
- }
855
- if (checkOffsetParent && el.offsetParent === null && style.position !== 'fixed' &&
856
- TAG !== 'BODY' && TAG !== 'HTML') return false;
857
- return true;
109
+ return el.checkVisibility();
858
110
  },
859
111
 
860
112
  // Returns true if the element is obscured at its center point — i.e.
861
113
  // hit-testing elementFromPoint at the center returns something that is
862
- // not the element or its descendant. Display:none / visibility:hidden /
863
- // [hidden] short-circuit to true because Lightpanda returns a fake
864
- // bounding rect for display:none elements.
114
+ // not the element or its descendant. `visibility:hidden|collapse` is
115
+ // short-circuited explicitly because those elements still produce a
116
+ // layout box (so the rect-zero check below can't catch them); every
117
+ // other "not rendered" case (display:none, [hidden], descendants of
118
+ // either) falls out naturally because getBoundingClientRect returns
119
+ // DOMRect{0,0,0,0} for elements with no layout box.
865
120
  isObscured: function(el) {
866
121
  var doc = el.ownerDocument;
867
122
  var win = doc.defaultView || window;
868
123
  var style = win.getComputedStyle(el);
869
- if (style.display === 'none') return true;
870
124
  if (style.visibility === 'hidden' || style.visibility === 'collapse') return true;
871
- var anc = el;
872
- while (anc && anc.nodeType === 1) {
873
- if (anc.hasAttribute && anc.hasAttribute('hidden')) return true;
874
- anc = anc.parentNode;
875
- }
876
125
  var r = el.getBoundingClientRect();
877
126
  if (r.width === 0 || r.height === 0) return true;
878
127
  var cx = r.left + (r.width / 2);
@@ -902,13 +151,12 @@
902
151
  return false;
903
152
  },
904
153
 
905
- // True if the element is the host of a contenteditable region either
906
- // its own isContentEditable is true, or any ancestor has a non-"false"
907
- // `contenteditable` attribute. Lightpanda doesn't expose
908
- // `isContentEditable` on every element, so we walk ancestors as a
909
- // fallback.
154
+ // True if the element is the host of a contenteditable region: it (or any
155
+ // ancestor) has a non-"false" `contenteditable` attribute. Lightpanda's
156
+ // native `HTMLElement.isContentEditable` (PR #2310) is hardwired to return
157
+ // `false` for every element it has no caret/keyboard editing pipeline —
158
+ // so the IDL property is useless here and we walk ancestors ourselves.
910
159
  isContentEditable: function(el) {
911
- if (el.isContentEditable) return true;
912
160
  var n = el;
913
161
  while (n && n.nodeType === 1) {
914
162
  if (n.hasAttribute && n.hasAttribute('contenteditable')) {
@@ -951,7 +199,7 @@
951
199
  return fout;
952
200
  }
953
201
  if (node.nodeType !== 1) return '';
954
- if (!self.isVisible(node, { checkOffsetParent: false })) return '';
202
+ if (!self.isVisible(node)) return '';
955
203
  var tag = (node.tagName || '').toUpperCase();
956
204
  if (tag === 'TEXTAREA') return node.value || '';
957
205
  if (tag === 'BR') return '\n';
@@ -975,24 +223,4 @@
975
223
  return walk(el);
976
224
  }
977
225
  };
978
-
979
- // --- XPathResult polyfill ---
980
-
981
- if (typeof XPathResult === 'undefined') {
982
- window.XPathResult = {
983
- ORDERED_NODE_SNAPSHOT_TYPE: 7,
984
- FIRST_ORDERED_NODE_TYPE: 9,
985
- _polyfilled: true
986
- };
987
- }
988
- if (!document.evaluate) {
989
- document.evaluate = function(expression, contextNode) {
990
- var nodes = window._lightpanda.xpathFind(expression, contextNode);
991
- return {
992
- snapshotLength: nodes.length,
993
- snapshotItem: function(i) { return nodes[i] || null; },
994
- singleNodeValue: nodes[0] || null
995
- };
996
- };
997
- }
998
226
  })();