capybara-lightpanda 0.1.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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +50 -0
- data/LICENSE.txt +27 -0
- data/NOTICE.md +101 -0
- data/README.md +215 -0
- data/lib/capybara/lightpanda/binary.rb +190 -0
- data/lib/capybara/lightpanda/browser.rb +963 -0
- data/lib/capybara/lightpanda/client/subscriber.rb +44 -0
- data/lib/capybara/lightpanda/client/web_socket.rb +160 -0
- data/lib/capybara/lightpanda/client.rb +124 -0
- data/lib/capybara/lightpanda/cookies.rb +181 -0
- data/lib/capybara/lightpanda/driver.rb +252 -0
- data/lib/capybara/lightpanda/errors.rb +76 -0
- data/lib/capybara/lightpanda/frame.rb +33 -0
- data/lib/capybara/lightpanda/javascripts/index.js +1108 -0
- data/lib/capybara/lightpanda/keyboard.rb +142 -0
- data/lib/capybara/lightpanda/logger.rb +37 -0
- data/lib/capybara/lightpanda/network.rb +92 -0
- data/lib/capybara/lightpanda/node.rb +726 -0
- data/lib/capybara/lightpanda/options.rb +63 -0
- data/lib/capybara/lightpanda/process.rb +252 -0
- data/lib/capybara/lightpanda/utils/event.rb +37 -0
- data/lib/capybara/lightpanda/version.rb +7 -0
- data/lib/capybara/lightpanda/xpath_polyfill.rb +10 -0
- data/lib/capybara-lightpanda.rb +42 -0
- metadata +119 -0
|
@@ -0,0 +1,1108 @@
|
|
|
1
|
+
(function() {
|
|
2
|
+
if (window._lightpanda) return;
|
|
3
|
+
|
|
4
|
+
// --- Turbo activity tracking ---
|
|
5
|
+
// Tracks pending Turbo operations so the driver can wait for Turbo to settle.
|
|
6
|
+
// Inspired by the CapybaraLockstep approach for stabilizing Turbo integration tests.
|
|
7
|
+
// Events are not perfectly symmetrical in Turbo, so we track multiple pairs
|
|
8
|
+
// and use a counter to handle overlapping operations.
|
|
9
|
+
//
|
|
10
|
+
// Transitions across 0 emit `__lightpanda_turbo_busy` / `__lightpanda_turbo_idle`
|
|
11
|
+
// sentinels via console.debug. Browser#wait_for_turbo subscribes to those
|
|
12
|
+
// sentinels (Runtime.consoleAPICalled) and toggles a Concurrent::Event so the
|
|
13
|
+
// Ruby side can wait event-driven instead of polling.
|
|
14
|
+
//
|
|
15
|
+
// Pages without Turbo never trigger _turboStart, so no sentinels fire and the
|
|
16
|
+
// Ruby Event stays set (idle by default) — wait_for_turbo returns immediately.
|
|
17
|
+
var _pendingTurboOps = 0;
|
|
18
|
+
function _signalTurbo(state) {
|
|
19
|
+
try { console.debug('__lightpanda_turbo_' + state); } catch (e) {}
|
|
20
|
+
}
|
|
21
|
+
function _turboStart() {
|
|
22
|
+
_pendingTurboOps++;
|
|
23
|
+
if (_pendingTurboOps === 1) _signalTurbo('busy');
|
|
24
|
+
}
|
|
25
|
+
function _turboEnd() {
|
|
26
|
+
if (_pendingTurboOps > 0) {
|
|
27
|
+
_pendingTurboOps--;
|
|
28
|
+
if (_pendingTurboOps === 0) _signalTurbo('idle');
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Fetch requests (covers Drive, Frames, and Form submission fetches)
|
|
33
|
+
document.addEventListener('turbo:before-fetch-request', _turboStart);
|
|
34
|
+
document.addEventListener('turbo:before-fetch-response', _turboEnd);
|
|
35
|
+
document.addEventListener('turbo:fetch-request-error', _turboEnd);
|
|
36
|
+
|
|
37
|
+
// Form submissions (can outlast their underlying fetch)
|
|
38
|
+
document.addEventListener('turbo:submit-start', _turboStart);
|
|
39
|
+
document.addEventListener('turbo:submit-end', _turboEnd);
|
|
40
|
+
|
|
41
|
+
// Frame rendering (can outlast the fetch that triggered it)
|
|
42
|
+
document.addEventListener('turbo:before-frame-render', _turboStart);
|
|
43
|
+
document.addEventListener('turbo:frame-render', _turboEnd);
|
|
44
|
+
|
|
45
|
+
// Stream rendering (no symmetric end event — wrap the render function)
|
|
46
|
+
document.addEventListener('turbo:before-stream-render', function(event) {
|
|
47
|
+
_turboStart();
|
|
48
|
+
if (event.detail && event.detail.render) {
|
|
49
|
+
var originalRender = event.detail.render;
|
|
50
|
+
event.detail.render = function(streamElement) {
|
|
51
|
+
var result = originalRender(streamElement);
|
|
52
|
+
if (result && typeof result.then === 'function') {
|
|
53
|
+
return result.finally(_turboEnd);
|
|
54
|
+
}
|
|
55
|
+
_turboEnd();
|
|
56
|
+
return result;
|
|
57
|
+
};
|
|
58
|
+
} else {
|
|
59
|
+
_turboEnd();
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// Drive page visits: turbo:load fires after the page is fully rendered.
|
|
64
|
+
// Also serves as a safety reset — clears any counter leaks from aborted fetches.
|
|
65
|
+
// Always re-signal idle so the Ruby Event re-arms even if some `_turboEnd`
|
|
66
|
+
// call dropped on the floor mid-navigation.
|
|
67
|
+
document.addEventListener('turbo:load', function() {
|
|
68
|
+
_pendingTurboOps = 0;
|
|
69
|
+
_signalTurbo('idle');
|
|
70
|
+
});
|
|
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
|
+
// --- Main API ---
|
|
788
|
+
|
|
789
|
+
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
|
+
turbo: {
|
|
815
|
+
pending: function() { return _pendingTurboOps; },
|
|
816
|
+
idle: function() { return _pendingTurboOps <= 0; }
|
|
817
|
+
},
|
|
818
|
+
|
|
819
|
+
// --- DOM visibility / state predicates ---
|
|
820
|
+
// Centralized so node.rb's predicate methods (visible?, obscured?, disabled?)
|
|
821
|
+
// and the visible_text walker share one implementation of the ancestor
|
|
822
|
+
// cascade. Available in iframes too because index.js is registered via
|
|
823
|
+
// Page.addScriptToEvaluateOnNewDocument.
|
|
824
|
+
|
|
825
|
+
// Returns true if `el` is visible per Capybara's semantics: not in an
|
|
826
|
+
// unrendered tag (HEAD/SCRIPT/TEMPLATE/etc), no `hidden` attribute on
|
|
827
|
+
// self/ancestor, no closed-<details> ancestor, not visibility:hidden, and
|
|
828
|
+
// (when checkVisibility() is unavailable) not display:none.
|
|
829
|
+
//
|
|
830
|
+
// `opts.checkOffsetParent` (default true): when true, also rejects
|
|
831
|
+
// elements with offsetParent === null as a fallback for ancestor display:none.
|
|
832
|
+
// The visible_text walker passes false because it already short-circuits
|
|
833
|
+
// when descending into hidden subtrees.
|
|
834
|
+
isVisible: function(el, opts) {
|
|
835
|
+
if (!el || el.nodeType !== 1) return false;
|
|
836
|
+
var checkOffsetParent = !opts || opts.checkOffsetParent !== false;
|
|
837
|
+
var TAG = (el.tagName || '').toUpperCase();
|
|
838
|
+
if (TAG === 'HEAD' || TAG === 'TEMPLATE' || TAG === 'NOSCRIPT' ||
|
|
839
|
+
TAG === 'SCRIPT' || TAG === 'STYLE' || TAG === 'TITLE') return false;
|
|
840
|
+
if (TAG === 'INPUT' && (el.type || '').toLowerCase() === 'hidden') return false;
|
|
841
|
+
|
|
842
|
+
var node = el;
|
|
843
|
+
while (node && node.nodeType === 1) {
|
|
844
|
+
if (node.hasAttribute && node.hasAttribute('hidden')) return false;
|
|
845
|
+
var parent = node.parentNode;
|
|
846
|
+
if (parent && parent.nodeType === 1) {
|
|
847
|
+
var ptag = (parent.tagName || '').toUpperCase();
|
|
848
|
+
if (ptag === 'HEAD' || ptag === 'TEMPLATE' || ptag === 'NOSCRIPT') return false;
|
|
849
|
+
if (ptag === 'DETAILS' && !parent.open) {
|
|
850
|
+
var ntag = (node.tagName || '').toUpperCase();
|
|
851
|
+
if (ntag !== 'SUMMARY') return false;
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
node = parent;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
var win = el.ownerDocument.defaultView || window;
|
|
858
|
+
var style = win.getComputedStyle(el);
|
|
859
|
+
if (style.visibility === 'hidden' || style.visibility === 'collapse') return false;
|
|
860
|
+
if (typeof el.checkVisibility === 'function') return el.checkVisibility();
|
|
861
|
+
if (style.display === 'none') return false;
|
|
862
|
+
if (checkOffsetParent && el.offsetParent === null && style.position !== 'fixed' &&
|
|
863
|
+
TAG !== 'BODY' && TAG !== 'HTML') return false;
|
|
864
|
+
return true;
|
|
865
|
+
},
|
|
866
|
+
|
|
867
|
+
// Returns true if the element is obscured at its center point — i.e.
|
|
868
|
+
// hit-testing elementFromPoint at the center returns something that is
|
|
869
|
+
// not the element or its descendant. Display:none / visibility:hidden /
|
|
870
|
+
// [hidden] short-circuit to true because Lightpanda returns a fake
|
|
871
|
+
// bounding rect for display:none elements.
|
|
872
|
+
isObscured: function(el) {
|
|
873
|
+
var doc = el.ownerDocument;
|
|
874
|
+
var win = doc.defaultView || window;
|
|
875
|
+
var style = win.getComputedStyle(el);
|
|
876
|
+
if (style.display === 'none') return true;
|
|
877
|
+
if (style.visibility === 'hidden' || style.visibility === 'collapse') return true;
|
|
878
|
+
var anc = el;
|
|
879
|
+
while (anc && anc.nodeType === 1) {
|
|
880
|
+
if (anc.hasAttribute && anc.hasAttribute('hidden')) return true;
|
|
881
|
+
anc = anc.parentNode;
|
|
882
|
+
}
|
|
883
|
+
var r = el.getBoundingClientRect();
|
|
884
|
+
if (r.width === 0 || r.height === 0) return true;
|
|
885
|
+
var cx = r.left + (r.width / 2);
|
|
886
|
+
var cy = r.top + (r.height / 2);
|
|
887
|
+
var w = win.innerWidth || doc.documentElement.clientWidth;
|
|
888
|
+
var h = win.innerHeight || doc.documentElement.clientHeight;
|
|
889
|
+
if (cx < 0 || cy < 0 || cx > w || cy > h) return true;
|
|
890
|
+
var hit = doc.elementFromPoint(cx, cy);
|
|
891
|
+
if (!hit) return true;
|
|
892
|
+
if (hit === el) return false;
|
|
893
|
+
return !el.contains(hit);
|
|
894
|
+
},
|
|
895
|
+
|
|
896
|
+
// HTML defines a disabled form control as one whose own `disabled`
|
|
897
|
+
// attribute is set OR whose ancestor select/optgroup/fieldset is disabled
|
|
898
|
+
// (with a fieldset-disabled exception for descendants of its first legend).
|
|
899
|
+
// `el.disabled` only reflects the element's own attribute, so we walk
|
|
900
|
+
// up the tree to honor the inherited cases.
|
|
901
|
+
isDisabled: function(el) {
|
|
902
|
+
if (el.disabled) return true;
|
|
903
|
+
var tag = (el.tagName || '').toUpperCase();
|
|
904
|
+
if (tag === 'OPTION') {
|
|
905
|
+
var p = el.parentElement;
|
|
906
|
+
while (p && (p.tagName || '').toUpperCase() === 'OPTGROUP') {
|
|
907
|
+
if (p.disabled) return true;
|
|
908
|
+
p = p.parentElement;
|
|
909
|
+
}
|
|
910
|
+
if (p && (p.tagName || '').toUpperCase() === 'SELECT' && p.disabled) return true;
|
|
911
|
+
}
|
|
912
|
+
var FORM = { INPUT:1, BUTTON:1, SELECT:1, TEXTAREA:1, OPTION:1 };
|
|
913
|
+
if (FORM[tag]) {
|
|
914
|
+
var node = el.parentElement;
|
|
915
|
+
while (node) {
|
|
916
|
+
if ((node.tagName || '').toUpperCase() === 'FIELDSET' && node.disabled) {
|
|
917
|
+
var firstLegend = null;
|
|
918
|
+
for (var c = node.firstElementChild; c; c = c.nextElementSibling) {
|
|
919
|
+
if ((c.tagName || '').toUpperCase() === 'LEGEND') { firstLegend = c; break; }
|
|
920
|
+
}
|
|
921
|
+
if (firstLegend && firstLegend.contains(el)) return false;
|
|
922
|
+
return true;
|
|
923
|
+
}
|
|
924
|
+
node = node.parentElement;
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
return false;
|
|
928
|
+
},
|
|
929
|
+
|
|
930
|
+
// True if the element is the host of a contenteditable region — either
|
|
931
|
+
// its own isContentEditable is true, or any ancestor has a non-"false"
|
|
932
|
+
// `contenteditable` attribute. Lightpanda doesn't expose
|
|
933
|
+
// `isContentEditable` on every element, so we walk ancestors as a
|
|
934
|
+
// fallback.
|
|
935
|
+
isContentEditable: function(el) {
|
|
936
|
+
if (el.isContentEditable) return true;
|
|
937
|
+
var n = el;
|
|
938
|
+
while (n && n.nodeType === 1) {
|
|
939
|
+
if (n.hasAttribute && n.hasAttribute('contenteditable')) {
|
|
940
|
+
var v = (n.getAttribute('contenteditable') || '').toLowerCase();
|
|
941
|
+
return v !== 'false';
|
|
942
|
+
}
|
|
943
|
+
n = n.parentElement;
|
|
944
|
+
}
|
|
945
|
+
return false;
|
|
946
|
+
},
|
|
947
|
+
|
|
948
|
+
// Walk descendants and accumulate text from visible nodes only. Inserts
|
|
949
|
+
// newlines around block-display containers so paragraphs/lists render with
|
|
950
|
+
// natural breaks (Capybara expects this from Chrome's innerText).
|
|
951
|
+
// Lightpanda's innerText returns textContent verbatim (no rendering, so no
|
|
952
|
+
// hidden-descendant filtering), so we DIY the visibility filtering here.
|
|
953
|
+
visibleText: function(el) {
|
|
954
|
+
var BLOCK_DISP = { BLOCK:1, FLEX:1, GRID:1, 'LIST-ITEM':1, TABLE:1, 'TABLE-ROW':1,
|
|
955
|
+
'TABLE-CAPTION':1, 'TABLE-CELL':1 };
|
|
956
|
+
var BLOCK_TAG = { ADDRESS:1, ARTICLE:1, ASIDE:1, BLOCKQUOTE:1, DETAILS:1, DIALOG:1,
|
|
957
|
+
DIV:1, DL:1, DT:1, DD:1, FIELDSET:1, FIGCAPTION:1, FIGURE:1,
|
|
958
|
+
FOOTER:1, FORM:1, H1:1, H2:1, H3:1, H4:1, H5:1, H6:1, HEADER:1,
|
|
959
|
+
HGROUP:1, HR:1, LI:1, MAIN:1, NAV:1, OL:1, P:1, PRE:1, SECTION:1,
|
|
960
|
+
TABLE:1, TR:1, UL:1 };
|
|
961
|
+
var self = this;
|
|
962
|
+
|
|
963
|
+
// Collapse runs of ASCII whitespace (preserving NBSP) to a single space —
|
|
964
|
+
// matches Chrome's innerText whitespace handling for text nodes.
|
|
965
|
+
function normText(s) {
|
|
966
|
+
return s.replace(/[\t\n\r\f\v ]+/g, ' ');
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
function walk(node) {
|
|
970
|
+
if (node.nodeType === 3) return normText(node.nodeValue);
|
|
971
|
+
// DocumentFragment / ShadowRoot — no element of its own to test
|
|
972
|
+
// for visibility, just walk children.
|
|
973
|
+
if (node.nodeType === 11) {
|
|
974
|
+
var fout = '';
|
|
975
|
+
for (var k = 0; k < node.childNodes.length; k++) fout += walk(node.childNodes[k]);
|
|
976
|
+
return fout;
|
|
977
|
+
}
|
|
978
|
+
if (node.nodeType !== 1) return '';
|
|
979
|
+
if (!self.isVisible(node, { checkOffsetParent: false })) return '';
|
|
980
|
+
var tag = (node.tagName || '').toUpperCase();
|
|
981
|
+
if (tag === 'TEXTAREA') return node.value || '';
|
|
982
|
+
if (tag === 'BR') return '\n';
|
|
983
|
+
var win = node.ownerDocument.defaultView || window;
|
|
984
|
+
var style = win.getComputedStyle(node);
|
|
985
|
+
var disp = (style.display || '').toUpperCase();
|
|
986
|
+
var isBlock = BLOCK_DISP[disp] || BLOCK_TAG[tag];
|
|
987
|
+
var out = '';
|
|
988
|
+
for (var i = 0; i < node.childNodes.length; i++) {
|
|
989
|
+
out += walk(node.childNodes[i]);
|
|
990
|
+
}
|
|
991
|
+
if (isBlock) out = '\n' + out + '\n';
|
|
992
|
+
return out;
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
return walk(el);
|
|
996
|
+
}
|
|
997
|
+
};
|
|
998
|
+
|
|
999
|
+
// --- XPathResult polyfill ---
|
|
1000
|
+
|
|
1001
|
+
if (typeof XPathResult === 'undefined') {
|
|
1002
|
+
window.XPathResult = {
|
|
1003
|
+
ORDERED_NODE_SNAPSHOT_TYPE: 7,
|
|
1004
|
+
FIRST_ORDERED_NODE_TYPE: 9,
|
|
1005
|
+
_polyfilled: true
|
|
1006
|
+
};
|
|
1007
|
+
}
|
|
1008
|
+
if (!document.evaluate) {
|
|
1009
|
+
document.evaluate = function(expression, contextNode) {
|
|
1010
|
+
var nodes = window._lightpanda.xpathFind(expression, contextNode);
|
|
1011
|
+
return {
|
|
1012
|
+
snapshotLength: nodes.length,
|
|
1013
|
+
snapshotItem: function(i) { return nodes[i] || null; },
|
|
1014
|
+
singleNodeValue: nodes[0] || null
|
|
1015
|
+
};
|
|
1016
|
+
};
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
// --- ID-shorthand rewriter for querySelector/querySelectorAll ---
|
|
1020
|
+
// Lightpanda has a CSS-engine bug where `#id` selectors fail after the body is
|
|
1021
|
+
// modified via innerHTML and then replaced (e.g. Turbo Drive's snapshot+swap).
|
|
1022
|
+
// `[id="..."]` always works, so rewrite `#foo` -> `[id="foo"]` outside of
|
|
1023
|
+
// brackets and quoted strings before delegating to the native engine.
|
|
1024
|
+
|
|
1025
|
+
(function() {
|
|
1026
|
+
if (typeof Document === 'undefined' || typeof Element === 'undefined') return;
|
|
1027
|
+
|
|
1028
|
+
function rewriteIdShorthand(selector) {
|
|
1029
|
+
if (typeof selector !== 'string' || selector.indexOf('#') < 0) return selector;
|
|
1030
|
+
var out = '', i = 0, n = selector.length, depth = 0, quote = null;
|
|
1031
|
+
while (i < n) {
|
|
1032
|
+
var c = selector.charAt(i);
|
|
1033
|
+
if (quote) {
|
|
1034
|
+
out += c;
|
|
1035
|
+
if (c === '\\' && i + 1 < n) { out += selector.charAt(i + 1); i += 2; continue; }
|
|
1036
|
+
if (c === quote) quote = null;
|
|
1037
|
+
i++; continue;
|
|
1038
|
+
}
|
|
1039
|
+
if (c === '"' || c === "'") { quote = c; out += c; i++; continue; }
|
|
1040
|
+
if (c === '[') { depth++; out += c; i++; continue; }
|
|
1041
|
+
if (c === ']') { if (depth > 0) depth--; out += c; i++; continue; }
|
|
1042
|
+
if (c === '\\' && i + 1 < n) { out += c + selector.charAt(i + 1); i += 2; continue; }
|
|
1043
|
+
if (c === '#' && depth === 0) {
|
|
1044
|
+
var j = i + 1, start = j;
|
|
1045
|
+
while (j < n) {
|
|
1046
|
+
var cc = selector.charCodeAt(j);
|
|
1047
|
+
if (cc === 92 && j + 1 < n) { j += 2; continue; }
|
|
1048
|
+
// CSS identifier chars: A-Z a-z 0-9 _ - or non-ASCII (>= 128)
|
|
1049
|
+
if ((cc >= 48 && cc <= 57) || (cc >= 65 && cc <= 90) ||
|
|
1050
|
+
(cc >= 97 && cc <= 122) || cc === 45 || cc === 95 || cc >= 128) { j++; continue; }
|
|
1051
|
+
break;
|
|
1052
|
+
}
|
|
1053
|
+
if (j > start) {
|
|
1054
|
+
var id = selector.substring(start, j);
|
|
1055
|
+
out += '[id="' + id.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"]';
|
|
1056
|
+
i = j; continue;
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
out += c; i++;
|
|
1060
|
+
}
|
|
1061
|
+
return out;
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
function patch(proto) {
|
|
1065
|
+
if (!proto) return;
|
|
1066
|
+
var origQS = proto.querySelector;
|
|
1067
|
+
var origQSA = proto.querySelectorAll;
|
|
1068
|
+
if (typeof origQS === 'function') {
|
|
1069
|
+
proto.querySelector = function(s) { return origQS.call(this, rewriteIdShorthand(s)); };
|
|
1070
|
+
}
|
|
1071
|
+
if (typeof origQSA === 'function') {
|
|
1072
|
+
proto.querySelectorAll = function(s) { return origQSA.call(this, rewriteIdShorthand(s)); };
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
patch(Document.prototype);
|
|
1077
|
+
patch(Element.prototype);
|
|
1078
|
+
if (typeof DocumentFragment !== 'undefined') patch(DocumentFragment.prototype);
|
|
1079
|
+
})();
|
|
1080
|
+
|
|
1081
|
+
// --- requestSubmit polyfill ---
|
|
1082
|
+
// Required for Turbo form interception. Turbo listens for the `submit` event,
|
|
1083
|
+
// but form.submit() doesn't fire it. requestSubmit() does.
|
|
1084
|
+
|
|
1085
|
+
if (typeof HTMLFormElement !== 'undefined' && !HTMLFormElement.prototype.requestSubmit) {
|
|
1086
|
+
HTMLFormElement.prototype.requestSubmit = function(submitter) {
|
|
1087
|
+
if (submitter) {
|
|
1088
|
+
var validTypes = {submit: 1, image: 1};
|
|
1089
|
+
if (!validTypes[(submitter.type || '').toLowerCase()]) {
|
|
1090
|
+
throw new TypeError('The specified element is not a submit button.');
|
|
1091
|
+
}
|
|
1092
|
+
if (submitter.form !== this) {
|
|
1093
|
+
throw new DOMException('The specified element is not owned by this form element.', 'NotFoundError');
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
var event;
|
|
1097
|
+
if (typeof SubmitEvent === 'function') {
|
|
1098
|
+
event = new SubmitEvent('submit', {bubbles: true, cancelable: true, submitter: submitter || null});
|
|
1099
|
+
} else {
|
|
1100
|
+
event = new Event('submit', {bubbles: true, cancelable: true});
|
|
1101
|
+
event.submitter = submitter || null;
|
|
1102
|
+
}
|
|
1103
|
+
if (this.dispatchEvent(event)) {
|
|
1104
|
+
this.submit();
|
|
1105
|
+
}
|
|
1106
|
+
};
|
|
1107
|
+
}
|
|
1108
|
+
})();
|