capybara-simulated 0.0.7 → 0.1.1

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 (35) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +303 -158
  3. data/lib/capybara/simulated/asset_cache.rb +232 -0
  4. data/lib/capybara/simulated/browser.rb +3409 -845
  5. data/lib/capybara/simulated/driver.rb +341 -134
  6. data/lib/capybara/simulated/errors.rb +9 -5
  7. data/lib/capybara/simulated/js/bridge.bundle.js +19738 -0
  8. data/lib/capybara/simulated/js/snapshot_stubs.js +110 -0
  9. data/lib/capybara/simulated/node.rb +151 -163
  10. data/lib/capybara/simulated/quickjs_runtime.rb +424 -0
  11. data/lib/capybara/simulated/runtime_shared.rb +183 -0
  12. data/lib/capybara/simulated/script_cache.rb +168 -0
  13. data/lib/capybara/simulated/sourcemap.rb +119 -0
  14. data/lib/capybara/simulated/stack_resolver.rb +97 -0
  15. data/lib/capybara/simulated/trace.rb +111 -0
  16. data/lib/capybara/simulated/v8_runtime.rb +987 -0
  17. data/lib/capybara/simulated/version.rb +3 -1
  18. data/lib/capybara/simulated/webauthn_state.rb +367 -0
  19. data/lib/capybara/simulated/whitespace_normalizer.rb +45 -0
  20. data/lib/capybara/simulated/worker_runtime.rb +30 -0
  21. data/lib/capybara/simulated.rb +31 -4
  22. data/lib/capybara-simulated.rb +2 -0
  23. data/vendor/js/vendor.bundle.js +13 -0
  24. metadata +24 -32
  25. data/vendor/esbuild-wasm/LICENSE.md +0 -21
  26. data/vendor/esbuild-wasm/bin/esbuild +0 -91
  27. data/vendor/esbuild-wasm/esbuild.wasm +0 -0
  28. data/vendor/esbuild-wasm/lib/main.js +0 -2337
  29. data/vendor/esbuild-wasm/wasm_exec.js +0 -575
  30. data/vendor/esbuild-wasm/wasm_exec_node.js +0 -40
  31. data/vendor/js/bundle-modules.mjs +0 -168
  32. data/vendor/js/csim.bundle.js +0 -91560
  33. data/vendor/js/entry.mjs +0 -23
  34. data/vendor/js/prelude.js +0 -190
  35. data/vendor/js/runtime.js +0 -2208
data/vendor/js/runtime.js DELETED
@@ -1,2208 +0,0 @@
1
- // Driver-side runtime running inside a single mini_racer Context.
2
- // `__csim_bundle` is the IIFE exposing happy-dom (built by build.mjs).
3
- // Every Capybara session shares this Context — DOM state is reset per
4
- // visit by recreating the window, never the V8 isolate.
5
-
6
- (function () {
7
- const {Window, URL: WhatwgURL, URLSearchParams: WhatwgURLSearchParams,
8
- installXPath} = globalThis.__csim_bundle;
9
- if (!globalThis.URL) globalThis.URL = WhatwgURL;
10
- if (!globalThis.URLSearchParams) globalThis.URLSearchParams = WhatwgURLSearchParams;
11
-
12
- const handles = new Map();
13
- let nextHandleId = 0;
14
-
15
- // Capybara's `have_text` matchers and several internal checks call into
16
- // `visibleText` repeatedly against the same handle while polling. The
17
- // text-collection walks happy-dom's textContent / styles each call, so
18
- // re-running it 1000+ times per spec adds up. Cache by handle and drop
19
- // the whole cache the moment any mutating runtime method runs (or
20
- // `drainTimers` advances the virtual clock — Stimulus / Turbo timers
21
- // may have rewritten the page). The cache is intentionally narrow:
22
- // we measured a broader MutationObserver-driven cache as a wash, since
23
- // the dispatch overhead ate the cache wins; pinning a bare `Map.clear()`
24
- // to known-mutating call sites is much cheaper.
25
- const visibleTextCache = new Map();
26
- function clearReadCache() { if (visibleTextCache.size) visibleTextCache.clear(); }
27
- let currentWindow = null;
28
- let currentDocument = null;
29
- let activeHandleId = null;
30
-
31
- const modalQueue = [];
32
- let modalResponses = {alert: [], confirm: [], prompt: []};
33
- // Stack of {type, text, response} pushed by accept_modal /
34
- // dismiss_modal blocks; consumed by the modal stubs above.
35
- const modalHandlers = [];
36
- let asyncSlot = null;
37
-
38
- function track(node) {
39
- if (node == null) return null;
40
- for (const [id, n] of handles) if (n === node) return id;
41
- const id = ++nextHandleId;
42
- handles.set(id, node);
43
- return id;
44
- }
45
- function lookup(id) {
46
- if (id == null) return null;
47
- const node = handles.get(id);
48
- if (!node) throw new Error('stale or unknown node handle: ' + id);
49
- // Element is detached from the live document — surface a stale-handle
50
- // error so Capybara's automatic_reload retry kicks in.
51
- if (node.nodeType === 1 && node.isConnected === false) {
52
- throw new Error('stale or unknown node handle: ' + id + ' (detached)');
53
- }
54
- return node;
55
- }
56
-
57
- function resetState() {
58
- if (currentWindow) {
59
- try { currentWindow.happyDOM && currentWindow.happyDOM.close && currentWindow.happyDOM.close(); }
60
- catch (_) {}
61
- }
62
- handles.clear();
63
- visibleTextCache.clear();
64
- nextHandleId = 0;
65
- activeHandleId = null;
66
- modalQueue.length = 0;
67
- modalHandlers.length = 0;
68
- asyncSlot = null;
69
- currentWindow = null;
70
- currentDocument = null;
71
- if (typeof globalThis.__csim_clearTimers === 'function') {
72
- try { globalThis.__csim_clearTimers(); } catch (_) {}
73
- }
74
- }
75
-
76
- // happy-dom keys its private fields with module-level `Symbol("name")`s,
77
- // so within a single bundle a description maps to one stable symbol.
78
- // Cache by description to skip the O(symbols) walk on hot paths
79
- // (`MutationObserver.observe` runs this every time).
80
- const SYMBOL_CACHE = new Map();
81
- function findHDSymbol(obj, description) {
82
- let sym = SYMBOL_CACHE.get(description);
83
- if (sym) return sym;
84
- sym = Object.getOwnPropertySymbols(obj).find((s) => s.description === description);
85
- if (sym) SYMBOL_CACHE.set(description, sym);
86
- return sym;
87
- }
88
-
89
- function patchWindowGlobals(win) {
90
- // happy-dom expects a handful of constructors to live on the window
91
- // because page-supplied scripts and selector parsers grab them via
92
- // `this.window.SyntaxError` etc. mini_racer's bare V8 isolate does not
93
- // wire those up automatically.
94
- const PASS = ['SyntaxError', 'TypeError', 'Error', 'RangeError',
95
- 'ReferenceError', 'Promise', 'Map', 'Set', 'WeakMap',
96
- 'WeakSet', 'Symbol', 'Proxy', 'Reflect', 'Array',
97
- 'Object', 'String', 'Number', 'Boolean', 'JSON',
98
- 'Math', 'Date', 'RegExp', 'ArrayBuffer', 'Uint8Array',
99
- 'Int8Array', 'Uint16Array', 'Int16Array', 'Uint32Array',
100
- 'Int32Array', 'Float32Array', 'Float64Array', 'DataView'];
101
- for (const name of PASS) {
102
- if (typeof globalThis[name] !== 'undefined' && !win[name]) {
103
- win[name] = globalThis[name];
104
- }
105
- }
106
- // Modal stubs — happy-dom does not implement alert/confirm/prompt.
107
- // Two response paths live in parallel:
108
- //
109
- // * `modalHandlers` — text-matched handlers pushed by
110
- // `accept_modal` / `dismiss_modal` blocks. Picked first because
111
- // they encode the user's intent and support nesting.
112
- // * `modalResponses` — legacy unkeyed FIFO queue still consumed
113
- // when no matching handler is found, kept for backwards
114
- // compatibility with code that calls `set_modal_responses`
115
- // directly.
116
- //
117
- // For `prompt(message, defaultValue)`, falling through to the
118
- // default response returns the page-supplied default when no
119
- // handler / queue entry overrides it.
120
- const mkResponder = (type) => function (message, defaultValue) {
121
- const msg = message == null ? null : String(message);
122
- const recorded = {type, message: msg, response: null};
123
- const handlerIdx = modalHandlers.findIndex((h) => h.type === type && modalTextMatches(h.text, msg));
124
- let resolved = false;
125
- if (handlerIdx >= 0) {
126
- const handler = modalHandlers[handlerIdx];
127
- modalHandlers.splice(handlerIdx, 1);
128
- recorded.response = encodeModalResponse(type, handler.response, defaultValue);
129
- resolved = true;
130
- } else {
131
- const queue = modalResponses[type] || [];
132
- if (queue.length > 0) {
133
- recorded.response = encodeModalResponse(type, queue.shift(), defaultValue);
134
- resolved = true;
135
- }
136
- }
137
- if (!resolved) {
138
- if (type === 'prompt') recorded.response = defaultValue == null ? null : String(defaultValue);
139
- else recorded.response = type === 'confirm' ? true : undefined;
140
- }
141
- modalQueue.push(recorded);
142
- return recorded.response;
143
- };
144
- function encodeModalResponse(type, v, defaultValue) {
145
- if (type !== 'prompt') return v;
146
- if (v === false) return null;
147
- if (v == null) return defaultValue == null ? '' : String(defaultValue);
148
- return v;
149
- }
150
- function modalTextMatches(matcher, message) {
151
- if (matcher == null) return true;
152
- if (typeof matcher === 'string') return String(message || '').indexOf(matcher) >= 0;
153
- if (typeof matcher === 'object' && 'regexp' in matcher) {
154
- try {
155
- const flags = decodeRegexpFlags(matcher.flags || 0);
156
- return new RegExp(matcher.regexp, flags).test(String(message || ''));
157
- } catch (_) { return false; }
158
- }
159
- return false;
160
- }
161
- function decodeRegexpFlags(rubyFlags) {
162
- // Ruby Regexp#options bitset: 1=IGNORECASE, 2=EXTENDED, 4=MULTILINE.
163
- let s = '';
164
- if (rubyFlags & 1) s += 'i';
165
- if (rubyFlags & 4) s += 'm';
166
- return s;
167
- }
168
- win.alert = mkResponder('alert');
169
- win.confirm = mkResponder('confirm');
170
- win.prompt = mkResponder('prompt');
171
- // Replace happy-dom's real-time timer scheduler with our synchronous
172
- // queue so drainTimers() can run pending callbacks on demand. Without
173
- // this, setTimeout-based handlers (jQuery's $(elem).click → setTimeout)
174
- // never fire because the driver runs the test in a single tick.
175
- win.setTimeout = globalThis.setTimeout;
176
- win.clearTimeout = globalThis.clearTimeout;
177
- win.setInterval = globalThis.setInterval;
178
- win.clearInterval = globalThis.clearInterval;
179
- win.setImmediate = globalThis.setImmediate;
180
- win.clearImmediate = globalThis.clearImmediate;
181
- win.queueMicrotask = globalThis.queueMicrotask;
182
- win.requestAnimationFrame = (cb) => globalThis.setTimeout(() => cb(Date.now()), 16);
183
- win.cancelAnimationFrame = (id) => globalThis.clearTimeout(id);
184
- installValidationMessages(win);
185
- installMutationObserverPin(win);
186
- installAttrNodeValue(win);
187
- installXPath(win);
188
- }
189
-
190
- // happy-dom 20's `Attr` class implements `value` / `textContent` / `name`
191
- // but skips `nodeValue`, so it inherits `Node.prototype.nodeValue` which
192
- // returns `null`. The DOM spec says `Attr.nodeValue === Attr.value`
193
- // (https://dom.spec.whatwg.org/#dom-node-nodevalue), and any XPath engine
194
- // (including wgxpath) reads attributes via `nodeValue` to compute their
195
- // string-value — without this shim every attribute compares as the string
196
- // `"null"` and predicates like `[@id = //label/@for]` collapse to
197
- // `"null" === "null"` (matches every element with any id).
198
- function installAttrNodeValue(win) {
199
- const proto = win.Attr && win.Attr.prototype;
200
- if (!proto || proto.__csim_node_value) return;
201
- Object.defineProperty(proto, 'nodeValue', {
202
- configurable: true,
203
- get() { return this.value; },
204
- set(v) { this.value = v; }
205
- });
206
- proto.__csim_node_value = true;
207
- }
208
-
209
- // happy-dom's MutationObserverListener wraps its dispatch arrow in a
210
- // `WeakRef` with no other strong reference. V8 collects it before the
211
- // next mutation arrives; `target[mutationListeners]` still carries the
212
- // listener but `callback.deref()` returns undefined and every record
213
- // (plus the subtree-propagation hop in `appendChild`) is silently
214
- // dropped. Replace each WeakRef with a strong-reference shim that
215
- // keeps the same `.deref()` shape.
216
- function installMutationObserverPin(win) {
217
- const MO = win.MutationObserver;
218
- if (!MO || !MO.prototype || MO.prototype.__csim_pinned) return;
219
- const origObserve = MO.prototype.observe;
220
- MO.prototype.observe = function (target, options) {
221
- const result = origObserve.call(this, target, options);
222
- if (target && typeof target === 'object') {
223
- const sym = findHDSymbol(target, 'mutationListeners');
224
- const listeners = sym && target[sym];
225
- if (listeners && listeners.length) {
226
- for (const ml of listeners) {
227
- const cb = ml && ml.callback;
228
- if (cb && typeof cb.deref === 'function' && !cb.__csim_strong) {
229
- const fn = cb.deref();
230
- if (fn) {
231
- ml.callback = {deref() { return fn; }, __csim_strong: true};
232
- }
233
- }
234
- }
235
- }
236
- }
237
- return result;
238
- };
239
- MO.prototype.__csim_pinned = true;
240
- }
241
-
242
- // happy-dom computes `validity` flags but always reports an empty
243
- // `validationMessage`. Capybara's `have_field validation_message:` test
244
- // and Rails apps that surface the constraint-validation message rely
245
- // on the Chrome-style English defaults; shim them once per Window.
246
- function installValidationMessages(win) {
247
- const protos = [win.HTMLInputElement && win.HTMLInputElement.prototype,
248
- win.HTMLTextAreaElement && win.HTMLTextAreaElement.prototype,
249
- win.HTMLSelectElement && win.HTMLSelectElement.prototype];
250
- for (const proto of protos) {
251
- if (!proto || proto.__csim_validation_patched) continue;
252
- Object.defineProperty(proto, 'validationMessage', {
253
- configurable: true,
254
- get() {
255
- if (!this.validity || this.validity.valid) return '';
256
- const v = this.validity;
257
- if (v.valueMissing) return 'Please fill out this field.';
258
- if (v.typeMismatch) return 'Please enter a valid value.';
259
- if (v.patternMismatch) return 'Please match the requested format.';
260
- if (v.tooShort) return 'Please lengthen this text to ' + this.minLength + ' characters or more.';
261
- if (v.tooLong) return 'Please shorten this text to ' + this.maxLength + ' characters or less.';
262
- if (v.rangeUnderflow) return 'Value must be greater than or equal to ' + this.min + '.';
263
- if (v.rangeOverflow) return 'Value must be less than or equal to ' + this.max + '.';
264
- if (v.stepMismatch) return 'Please enter a valid value.';
265
- if (v.badInput) return 'Please enter a number.';
266
- if (v.customError && this.__csim_customMessage) return this.__csim_customMessage;
267
- return '';
268
- }
269
- });
270
- proto.__csim_validation_patched = true;
271
- }
272
- }
273
-
274
- // happy-dom's CustomElementRegistry stubs out the upgrade step
275
- // (`upgrade(_root) { /* Do nothing */ }`), so existing tags in the
276
- // parsed DOM never get promoted to their custom element class. This
277
- // breaks Turbo: a `<turbo-frame>` in server-rendered HTML stays as a
278
- // plain HTMLElement and FrameController is never constructed, so frame
279
- // form submissions fall through to a session-level handler that does
280
- // not attach the `Turbo-Frame` request header. Patch `define` to walk
281
- // the document and re-create matching elements through
282
- // `document.createElement`, which runs the registered constructor and
283
- // wires `connectedCallback` on insert.
284
- function installCustomElementUpgrade(win) {
285
- if (!win.customElements || win.customElements.__csim_patched) return;
286
- const registry = win.customElements;
287
- const origDefine = registry.define.bind(registry);
288
- registry.define = function (name, ctor, options) {
289
- const out = origDefine(name, ctor, options);
290
- try { upgradeExisting(name); } catch (_) {}
291
- return out;
292
- };
293
- registry.__csim_patched = true;
294
-
295
- function upgradeExisting(name) {
296
- const doc = currentDocument;
297
- if (!doc || !doc.getElementsByTagName) return;
298
- const ctor = registry.get(name);
299
- const matches = Array.from(doc.getElementsByTagName(name));
300
- for (const old of matches) {
301
- if (ctor && old instanceof ctor) {
302
- // happy-dom 20's auto-upgrade swaps the element returned by
303
- // `getElementsByTagName` to an instance of the registered class
304
- // but leaves the original (unupgraded) element sitting in the
305
- // document's `elementIdMap`. Subsequent `getElementById` calls
306
- // then hand back the ghost — empty id, wrong class. Re-toggle
307
- // the id attribute so happy-dom evicts the stale entry and
308
- // re-registers the live element.
309
- rebindIdIndex(old);
310
- continue;
311
- }
312
- const parent = old.parentNode;
313
- if (!parent) continue;
314
- const next = old.nextSibling;
315
- const attrs = [];
316
- for (const attr of Array.from(old.attributes || [])) {
317
- attrs.push([attr.name, attr.value]);
318
- }
319
- const children = [];
320
- while (old.firstChild) children.push(old.removeChild(old.firstChild));
321
- for (const [n] of attrs) {
322
- try { old.removeAttribute(n); } catch (_) {}
323
- }
324
- parent.removeChild(old);
325
- const fresh = doc.createElement(name);
326
- for (const [n, v] of attrs) {
327
- try { fresh.setAttribute(n, v); } catch (_) {}
328
- }
329
- for (const child of children) fresh.appendChild(child);
330
- if (next && next.parentNode === parent) parent.insertBefore(fresh, next);
331
- else parent.appendChild(fresh);
332
- }
333
- }
334
-
335
- function rebindIdIndex(el) {
336
- const id = el.getAttribute && el.getAttribute('id');
337
- if (!id) return;
338
- // Reach through happy-dom's private elementIdMap symbol — the
339
- // public removeAttribute/setAttribute path appends the live element
340
- // *after* the ghost, so `getElementById` (which returns
341
- // entry.elements[0]) keeps handing back the wrong instance until
342
- // we evict the ghost manually.
343
- try {
344
- const doc = currentWindow && currentWindow.document;
345
- if (!doc) return;
346
- const sym = findHDSymbol(doc, 'elementIdMap');
347
- if (!sym) return;
348
- const map = doc[sym];
349
- const entry = map && map.get(id);
350
- if (!entry || !entry.elements) return;
351
- entry.elements = entry.elements.filter((e) => e === el || (e.isConnected && e.getAttribute && e.getAttribute('id') === id));
352
- if (entry.elements.indexOf(el) < 0) entry.elements.unshift(el);
353
- } catch (_) {}
354
- }
355
- }
356
-
357
- // Replace happy-dom's network-going `fetch` with one that round-trips
358
- // through the host Rack app. Synchronous under the hood — `__csim_fetch`
359
- // is attached from Ruby and returns immediately — but the wrapper still
360
- // resolves a Promise so consumers (Turbo, Stimulus) `await` normally.
361
- function installFetchShim(win) {
362
- const ResponseCtor = win.Response;
363
- const HeadersCtor = win.Headers;
364
- const URLSearchParamsCtor = win.URLSearchParams || globalThis.URLSearchParams;
365
- const FormDataCtor = win.FormData;
366
- const DOMExceptionCtor = win.DOMException || Error;
367
-
368
- function headerEntries(input) {
369
- if (!input) return [];
370
- if (typeof input.entries === 'function') return Array.from(input.entries());
371
- if (Array.isArray(input)) return input.slice();
372
- return Object.keys(input).map((k) => [k, input[k]]);
373
- }
374
-
375
- function encodeMultipart(formData) {
376
- const boundary = '----CapybaraSimulatedFetch' + Math.random().toString(16).slice(2);
377
- const parts = [];
378
- for (const [name, value] of formData.entries()) {
379
- if (value && typeof value === 'object' && 'name' in value && 'arrayBuffer' in value) {
380
- // File / Blob — serialise basename only; binary upload via fetch
381
- // is rare in Turbo and would need binary-safe transport.
382
- parts.push('--' + boundary + '\r\n' +
383
- 'Content-Disposition: form-data; name="' + name + '"; filename="' + (value.name || '') + '"\r\n' +
384
- 'Content-Type: ' + (value.type || 'application/octet-stream') + '\r\n\r\n' +
385
- '\r\n');
386
- } else {
387
- parts.push('--' + boundary + '\r\n' +
388
- 'Content-Disposition: form-data; name="' + name + '"\r\n\r\n' +
389
- String(value) + '\r\n');
390
- }
391
- }
392
- return {body: parts.join('') + '--' + boundary + '--\r\n', contentType: 'multipart/form-data; boundary=' + boundary};
393
- }
394
-
395
- function serializeBody(body, headers) {
396
- if (body == null) return '';
397
- if (typeof body === 'string') return body;
398
- if (URLSearchParamsCtor && body instanceof URLSearchParamsCtor) {
399
- if (!headers['Content-Type'] && !headers['content-type']) {
400
- headers['Content-Type'] = 'application/x-www-form-urlencoded;charset=UTF-8';
401
- }
402
- return body.toString();
403
- }
404
- if (FormDataCtor && body instanceof FormDataCtor) {
405
- const enc = encodeMultipart(body);
406
- if (!headers['Content-Type'] && !headers['content-type']) {
407
- headers['Content-Type'] = enc.contentType;
408
- }
409
- return enc.body;
410
- }
411
- // ArrayBuffer / TypedArray — best-effort to a Latin-1 string
412
- if (body && body.byteLength != null) {
413
- const view = body.buffer ? new Uint8Array(body.buffer, body.byteOffset || 0, body.byteLength) : new Uint8Array(body);
414
- let s = '';
415
- for (let i = 0; i < view.length; i++) s += String.fromCharCode(view[i]);
416
- return s;
417
- }
418
- return String(body);
419
- }
420
-
421
- win.fetch = function (input, init) {
422
- return new Promise((resolve, reject) => {
423
- try {
424
- let url, method = 'GET', headers = {}, body = null, signal = null;
425
- if (input && typeof input === 'object' && input.url) {
426
- url = input.url;
427
- method = input.method || 'GET';
428
- try { for (const [k, v] of input.headers) headers[k] = v; } catch (_) {}
429
- } else {
430
- url = String(input);
431
- }
432
- if (init) {
433
- if (init.method) method = init.method;
434
- for (const [k, v] of headerEntries(init.headers)) headers[k] = v;
435
- if (init.body != null) body = init.body;
436
- if (init.signal) signal = init.signal;
437
- }
438
- if (signal && signal.aborted) {
439
- return reject(new DOMExceptionCtor('The operation was aborted.', 'AbortError'));
440
- }
441
- const serialized = serializeBody(body, headers);
442
- const result = globalThis.__csim_fetch(method.toUpperCase(), url, headers, serialized);
443
- if (!result) return reject(new TypeError('fetch failed'));
444
- if (result.error) return reject(new TypeError(String(result.error)));
445
- const respHeaders = new HeadersCtor();
446
- const rh = result.headers || {};
447
- for (const k of Object.keys(rh)) {
448
- const v = rh[k];
449
- const parts = typeof v === 'string' ? v.split('\n') : (Array.isArray(v) ? v : [String(v)]);
450
- for (const part of parts) {
451
- try { respHeaders.append(k, part); } catch (_) {}
452
- }
453
- }
454
- const r = new ResponseCtor(result.body == null ? '' : result.body, {
455
- status: result.status || 200,
456
- statusText: result.statusText || '',
457
- headers: respHeaders
458
- });
459
- try { Object.defineProperty(r, 'url', {value: result.finalUrl || url, configurable: true}); } catch (_) {}
460
- try { Object.defineProperty(r, 'redirected', {value: !!result.redirected, configurable: true}); } catch (_) {}
461
- resolve(r);
462
- } catch (e) {
463
- reject(e);
464
- }
465
- });
466
- };
467
- }
468
-
469
- function visibleAncestorChainOk(el) {
470
- let cur = el;
471
- while (cur && cur.nodeType === 1) {
472
- if (isHidden(cur)) return false;
473
- // Inside a closed <details>, only the <summary> branch is visible.
474
- if ((cur.tagName || '').toUpperCase() === 'DETAILS' &&
475
- !boolPropOrAttr(cur, 'open')) {
476
- // Walk back from el to cur; if we passed a <summary> on the way,
477
- // this branch stays visible.
478
- let probe = el;
479
- while (probe && probe !== cur) {
480
- if ((probe.tagName || '').toUpperCase() === 'SUMMARY') return true;
481
- probe = probe.parentNode;
482
- }
483
- return false;
484
- }
485
- cur = cur.parentNode;
486
- }
487
- return true;
488
- }
489
-
490
- function isHidden(el) {
491
- if (!el || el.nodeType !== 1) return false;
492
- const tag = (el.tagName || '').toLowerCase();
493
- if (tag === 'script' || tag === 'style' || tag === 'head' || tag === 'title' ||
494
- tag === 'noscript' || tag === 'template') return true;
495
- if (el.hidden) return true;
496
- if (el.type === 'hidden') return true;
497
- const style = el.getAttribute && el.getAttribute('style');
498
- if (style && /display\s*:\s*none/i.test(style)) return true;
499
- if (style && /visibility\s*:\s*hidden/i.test(style)) return true;
500
- // Stylesheet-driven hiding: consult getComputedStyle so CSS rules
501
- // (`.hidden_until_hover { display: none }` and the like) are seen.
502
- if (currentWindow && currentWindow.getComputedStyle) {
503
- try {
504
- const cs = currentWindow.getComputedStyle(el);
505
- if (cs && (cs.display === 'none' || cs.visibility === 'hidden')) return true;
506
- } catch (_) {}
507
- }
508
- return false;
509
- }
510
-
511
- function visibleTextOf(el, opts) {
512
- if (!el) return '';
513
- if (el.nodeType === 3) {
514
- let tc = el.textContent || '';
515
- // CSS whitespace collapsing: runs of whitespace (incl. newlines)
516
- // become a single space, except inside <pre>/<textarea> contexts
517
- // where the caller signals to preserve whitespace via opts.pre.
518
- if (!(opts && opts.pre)) tc = tc.replace(/[ \t\r\n\f]+/g, ' ');
519
- const tt = opts && opts.textTransform;
520
- if (tt === 'uppercase') tc = tc.toUpperCase();
521
- else if (tt === 'lowercase') tc = tc.toLowerCase();
522
- else if (tt === 'capitalize') tc = tc.replace(/(?:^|\s)\S/g, c => c.toUpperCase());
523
- return tc;
524
- }
525
- // DocumentFragment / ShadowRoot / Document: descend into children.
526
- if (el.nodeType === 9 || el.nodeType === 11) {
527
- const parts = [];
528
- for (const child of el.childNodes || []) parts.push(visibleTextOf(child, opts));
529
- return parts.join('');
530
- }
531
- if (el.nodeType !== 1) return '';
532
- if (isHidden(el)) return '';
533
- const tag = (el.tagName || '').toLowerCase();
534
- let childOpts = opts;
535
- const pre = (opts && opts.pre) || tag === 'pre' || tag === 'textarea';
536
- const style = el.getAttribute && el.getAttribute('style');
537
- let tt = opts && opts.textTransform;
538
- if (style) {
539
- const m = /text-transform\s*:\s*(uppercase|lowercase|capitalize|none)/i.exec(style);
540
- if (m) tt = m[1].toLowerCase() === 'none' ? null : m[1].toLowerCase();
541
- }
542
- if (pre !== !!(opts && opts.pre) || tt !== (opts && opts.textTransform)) {
543
- childOpts = {pre, textTransform: tt};
544
- }
545
- const parts = [];
546
- for (const child of el.childNodes) parts.push(visibleTextOf(child, childOpts));
547
- let out = parts.join('');
548
- const block = ['p','div','section','article','header','footer','nav','aside',
549
- 'h1','h2','h3','h4','h5','h6','ul','ol','li','tr','td','th',
550
- 'pre','address','blockquote','dl','dt','dd','fieldset','form',
551
- 'hr','table','br'];
552
- if (block.includes(tag)) {
553
- // Only mark block boundaries when the block actually contributes
554
- // text — otherwise empty wrappers (e.g. shadow hosts) break inline
555
- // adjacency between their siblings.
556
- if (tag === 'br' || /[^\s]/.test(out)) out = '\n' + out + '\n';
557
- }
558
- return out;
559
- }
560
-
561
- function buildXPath(el) {
562
- if (!el || el.nodeType !== 1) return '';
563
- // Elements rooted in a ShadowRoot have no XPath in the host document;
564
- // Capybara expects the same sentinel as real browsers' driver code.
565
- let probe = el;
566
- while (probe) {
567
- if (probe.nodeType === 11 && probe.host) return '(: Shadow DOM element - no XPath :)';
568
- probe = probe.parentNode;
569
- }
570
- const segments = [];
571
- let cur = el;
572
- while (cur && cur.nodeType === 1) {
573
- const tag = (cur.tagName || '').toLowerCase();
574
- let index = 1;
575
- let sib = cur.previousSibling;
576
- while (sib) {
577
- if (sib.nodeType === 1 && (sib.tagName || '').toLowerCase() === tag) index++;
578
- sib = sib.previousSibling;
579
- }
580
- segments.unshift(tag + '[' + index + ']');
581
- cur = cur.parentNode;
582
- if (cur && cur.nodeType === 9) break;
583
- }
584
- return '/' + segments.join('/');
585
- }
586
-
587
- function boolPropOrAttr(el, name) {
588
- if (!el) return false;
589
- if (el[name] === true) return true;
590
- if (el[name] !== undefined && el[name] !== null && el[name] !== false) return true;
591
- if (el.hasAttribute && el.hasAttribute(name.toLowerCase())) return true;
592
- return false;
593
- }
594
- function isMultipleSelect(el) { return boolPropOrAttr(el, 'multiple'); }
595
-
596
- // True when a link's load should be handled by Turbo's FrameController:
597
- // it lives inside a `<turbo-frame>` ancestor, none of its closer
598
- // ancestors / submitters opted out via `data-turbo="false"` or
599
- // `data-turbo-frame="_top"`. Used to decide whether the runtime
600
- // should do its own Rack navigate after a click whose default got
601
- // preventDefaulted.
602
- function isFrameScopedLink(el) {
603
- const frame = el.closest && el.closest('turbo-frame');
604
- if (!frame) return false;
605
- const turboFrameAttr = el.getAttribute && el.getAttribute('data-turbo-frame');
606
- if (turboFrameAttr === '_top') return false;
607
- const turboAttr = el.closest && el.closest('[data-turbo]');
608
- if (turboAttr && turboAttr.getAttribute('data-turbo') === 'false') return false;
609
- return true;
610
- }
611
-
612
- function makeEvent(type, opts) {
613
- const init = Object.assign({bubbles: true, cancelable: true}, opts || {});
614
- const Ctor = (currentWindow && currentWindow.MouseEvent && /click|mouse|dblclick|contextmenu/.test(type))
615
- ? currentWindow.MouseEvent
616
- : (currentWindow && currentWindow.KeyboardEvent && /key/.test(type))
617
- ? currentWindow.KeyboardEvent
618
- : (currentWindow && currentWindow.FocusEvent && /focus|blur/.test(type))
619
- ? currentWindow.FocusEvent
620
- : currentWindow.Event;
621
- return new Ctor(type, init);
622
- }
623
-
624
- // Resolve the form an input/button is bound to, honouring the HTML5
625
- // `form="<id>"` attribute. happy-dom's `el.form` only returns an
626
- // ancestor form, so we look up by id when the attribute is set.
627
- function formForControl(el) {
628
- if (!el) return null;
629
- const fid = el.getAttribute && el.getAttribute('form');
630
- if (fid) {
631
- const byId = currentDocument.getElementById(fid);
632
- if (byId && (byId.tagName || '').toUpperCase() === 'FORM') return byId;
633
- }
634
- return el.form || (el.closest && el.closest('form'));
635
- }
636
-
637
- function countSubmittableTextInputs(form) {
638
- let n = 0;
639
- for (const el of form.querySelectorAll('input')) {
640
- const t = (el.type || (el.getAttribute && el.getAttribute('type')) || 'text').toLowerCase();
641
- if (/^(text|email|number|search|tel|url|password|date|time|datetime-local|month|week|color|range)$/.test(t)) {
642
- n++;
643
- }
644
- }
645
- return n;
646
- }
647
-
648
- function fieldsFromForm(form, submitter) {
649
- const out = [];
650
- // Build the list in document order. happy-dom's `form.elements` does
651
- // not always include form-attribute associated controls, so collect
652
- // both the directly contained controls and any document-wide controls
653
- // bound via `form="<id>"`, then sort them by source position.
654
- const formId = form.getAttribute && form.getAttribute('id');
655
- const els = new Set();
656
- for (const el of form.querySelectorAll('input,textarea,select,button')) {
657
- if (!el.hasAttribute('form') || el.getAttribute('form') === formId) {
658
- els.add(el);
659
- }
660
- }
661
- if (formId) {
662
- for (const el of currentDocument.querySelectorAll(
663
- `input[form="${cssEscape(formId)}"], textarea[form="${cssEscape(formId)}"], ` +
664
- `select[form="${cssEscape(formId)}"], button[form="${cssEscape(formId)}"]`)) {
665
- els.add(el);
666
- }
667
- }
668
- const ordered = Array.from(els);
669
- ordered.sort((a, b) => {
670
- const pos = a.compareDocumentPosition && a.compareDocumentPosition(b);
671
- if (pos & 0x04) return -1; // a precedes b
672
- if (pos & 0x02) return 1; // b precedes a
673
- return 0;
674
- });
675
- for (const el of ordered) {
676
- if (!el.name) continue;
677
- if (boolPropOrAttr(el, 'disabled')) continue;
678
- const tag = (el.tagName || '').toUpperCase();
679
- const type = (el.type || (el.getAttribute && el.getAttribute('type')) || '').toLowerCase();
680
- if (tag === 'BUTTON' || type === 'submit' || type === 'image' || type === 'reset') {
681
- if (el !== submitter) continue;
682
- if (type === 'reset') continue;
683
- out.push([el.name, el.value || '']);
684
- continue;
685
- }
686
- if (type === 'checkbox' || type === 'radio') {
687
- // `checked` is the IDL property reflecting live state. Don't fall
688
- // back to `hasAttribute('checked')` here — that's the *default*
689
- // state used by form reset, and Stimulus controllers commonly
690
- // toggle the property directly (`el.checked = false`) without
691
- // touching the attribute. Reading the attribute would cause the
692
- // serializer to submit unchecked boxes as if they were on.
693
- if (el.checked === true) {
694
- const v = el.getAttribute && el.getAttribute('value');
695
- out.push([el.name, v == null ? 'on' : v]);
696
- }
697
- continue;
698
- }
699
- if (tag === 'SELECT') {
700
- const opts = Array.from(el.options || []);
701
- if (opts.length === 0) continue; // unfillable, do not submit
702
- if (isMultipleSelect(el)) {
703
- for (const opt of opts) {
704
- if (boolPropOrAttr(opt, 'selected')) out.push([el.name, opt.value || '']);
705
- }
706
- continue;
707
- }
708
- const sel = opts.find(o => boolPropOrAttr(o, 'selected')) || opts[0];
709
- out.push([el.name, sel.value || '']);
710
- continue;
711
- }
712
- if (type === 'file') {
713
- const files = el.__csim_files || [];
714
- if (files.length === 0) {
715
- // Empty file inputs still appear in multipart bodies as empty
716
- // file parts in real browsers. The Ruby side decides whether to
717
- // emit them based on enctype.
718
- out.push([el.name, {file: true, paths: []}]);
719
- } else {
720
- out.push([el.name, {file: true, paths: files.slice()}]);
721
- }
722
- continue;
723
- }
724
- let v = el.value == null ? '' : String(el.value);
725
- if (tag === 'TEXTAREA') {
726
- // Strip the single leading newline that HTML5 stripped from the
727
- // initial textarea content — only when the driver has not been
728
- // asked to set the value (otherwise the user's own leading
729
- // newline must round-trip).
730
- if (!el.__csim_user_set) {
731
- if (v.startsWith('\r\n')) v = v.slice(2);
732
- else if (v.startsWith('\n')) v = v.slice(1);
733
- }
734
- // Submit with CRLF line endings per HTML5.
735
- v = v.replace(/\r?\n/g, '\r\n');
736
- }
737
- out.push([el.name, v]);
738
- }
739
- return out;
740
- }
741
-
742
- function submitDescriptor(form, submitter, options) {
743
- // happy-dom auto-dispatches a `submit` event when an INPUT/BUTTON of
744
- // type=submit is clicked (via form.requestSubmit). When we land here
745
- // through that path, dispatching another submit event creates a
746
- // duplicate without the `submitter` field — Turbo's FormSubmitObserver
747
- // sees the second one and intercepts even when the original submitter
748
- // had `data-turbo="false"`. Skip our dispatch in that case and rely on
749
- // the prevented flag the click path captured. Keep dispatching for
750
- // direct `submit(id)` calls and implicit-submit-on-Enter where no
751
- // upstream submit event has fired yet.
752
- const skipDispatch = options && options.skipDispatch;
753
- const alreadyPrevented = !!(options && options.prevented);
754
- if (!skipDispatch) {
755
- const evt = makeEvent('submit', {submitter: submitter || undefined});
756
- if (!form.dispatchEvent(evt)) return {action: 'none'};
757
- } else if (alreadyPrevented) {
758
- return {action: 'none'};
759
- }
760
- // The button's formaction/formmethod/formenctype override the form's
761
- // attributes when present (HTML5 spec).
762
- const fa = (submitter && submitter.getAttribute && submitter.getAttribute('formaction'));
763
- const fm = (submitter && submitter.getAttribute && submitter.getAttribute('formmethod'));
764
- const fe = (submitter && submitter.getAttribute && submitter.getAttribute('formenctype'));
765
- return {
766
- action: 'submit',
767
- url: fa || form.getAttribute('action') || '',
768
- method: (fm || form.getAttribute('method') || 'GET').toUpperCase(),
769
- enctype: fe || form.getAttribute('enctype') || 'application/x-www-form-urlencoded',
770
- fields: fieldsFromForm(form, submitter)
771
- };
772
- }
773
-
774
- function unwrapScriptArgs(args) {
775
- return args.map((a) => {
776
- if (a && typeof a === 'object' && '__csim_handle' in a) return lookup(a.__csim_handle);
777
- return a;
778
- });
779
- }
780
- function wrapScriptResult(value) {
781
- if (value && typeof value === 'object' && value.nodeType === 1) {
782
- return {__csim_handle: track(value)};
783
- }
784
- if (Array.isArray(value)) return value.map(wrapScriptResult);
785
- return value;
786
- }
787
-
788
- function attachInlineHandlers(root) {
789
- const all = root.querySelectorAll('*');
790
- for (const el of all) {
791
- if (!el.attributes) continue;
792
- for (const attr of Array.from(el.attributes)) {
793
- if (!attr.name || !attr.name.startsWith('on')) continue;
794
- const eventName = attr.name.slice(2);
795
- const body = attr.value || '';
796
- if (!body) continue;
797
- try {
798
- const fn = new Function('event', body);
799
- el.addEventListener(eventName, function (event) {
800
- try { fn.call(this, event); }
801
- catch (_) {}
802
- });
803
- } catch (_) {}
804
- }
805
- }
806
- }
807
-
808
- // Walk the document for focusable elements (anchors with href, form
809
- // controls that aren't disabled or hidden, anything with a non-negative
810
- // tabindex) and advance the active element to the next one in tab
811
- // order. happy-dom doesn't ship Tab navigation, so each `send_keys(:tab)`
812
- // call would otherwise be a no-op for `document.activeElement`.
813
- function advanceFocus(_from, reverse) {
814
- if (!currentDocument) return;
815
- const cands = Array.from(currentDocument.querySelectorAll(
816
- 'a[href], button, input:not([type=hidden]), select, textarea, [tabindex]'
817
- )).filter((el) => {
818
- if (el.disabled) return false;
819
- const ti = el.getAttribute('tabindex');
820
- if (ti && parseInt(ti, 10) < 0) return false;
821
- // Hidden elements can't take focus.
822
- if (typeof el.offsetParent !== 'undefined' && el.offsetParent === null && el.tagName !== 'BODY') {
823
- // happy-dom returns null for everything (no layout), so don't
824
- // exclude on offsetParent alone — fall through.
825
- }
826
- return true;
827
- });
828
- const positiveTab = (el) => {
829
- const ti = parseInt(el.getAttribute('tabindex') || '0', 10);
830
- return ti > 0 ? ti : null;
831
- };
832
- const indexed = cands.filter((el) => positiveTab(el) !== null)
833
- .map((el, i) => ({el, ti: positiveTab(el), i}))
834
- .sort((a, b) => a.ti - b.ti || a.i - b.i)
835
- .map((x) => x.el);
836
- const natural = cands.filter((el) => positiveTab(el) === null);
837
- const ordered = indexed.concat(natural);
838
- if (reverse) ordered.reverse();
839
- if (ordered.length === 0) return;
840
- const cur = currentDocument.activeElement;
841
- const at = ordered.indexOf(cur);
842
- const next = ordered[(at >= 0 && at + 1 < ordered.length) ? at + 1 : 0];
843
- if (next && typeof next.focus === 'function') {
844
- next.focus();
845
- activeHandleId = track(next);
846
- }
847
- }
848
-
849
- // ---- send_keys helpers ------------------------------------------------
850
- function isModifier(name) {
851
- return name === 'Shift' || name === 'Control' || name === 'Alt' || name === 'Meta';
852
- }
853
- function isSpecialKey(name) {
854
- return name.length > 1 && /^[A-Z]/.test(name);
855
- }
856
- function modifierProp(name) {
857
- return ({Shift: 'shiftKey', Control: 'ctrlKey', Alt: 'altKey', Meta: 'metaKey'})[name];
858
- }
859
- function keyForEvent(name, opts) {
860
- if (isSpecialKey(name)) return name;
861
- if (opts && opts.shiftKey) return name.toUpperCase();
862
- return name;
863
- }
864
- function keyCodeFor(name) {
865
- if (isModifier(name)) return ({Shift: 16, Control: 17, Alt: 18, Meta: 91})[name];
866
- if (name === 'Enter') return 13;
867
- if (name === 'Tab') return 9;
868
- if (name === 'Backspace') return 8;
869
- if (name === 'Delete') return 46;
870
- if (name === 'Escape') return 27;
871
- if (name === ' ') return 32;
872
- if (name === 'ArrowLeft') return 37;
873
- if (name === 'ArrowUp') return 38;
874
- if (name === 'ArrowRight') return 39;
875
- if (name === 'ArrowDown') return 40;
876
- if (name === 'Home') return 36;
877
- if (name === 'End') return 35;
878
- if (name === 'PageUp') return 33;
879
- if (name === 'PageDown') return 34;
880
- if (name.length === 1) {
881
- const c = name.toUpperCase().charCodeAt(0);
882
- if (c >= 48 && c <= 57) return c; // 0-9
883
- if (c >= 65 && c <= 90) return c; // A-Z
884
- return name.charCodeAt(0);
885
- }
886
- return 0;
887
- }
888
- function keyCodeName(name) {
889
- if (isModifier(name)) return name === 'Meta' ? 'MetaLeft' : (name + 'Left');
890
- if (name === 'Enter') return 'Enter';
891
- if (name === 'Tab') return 'Tab';
892
- if (name === 'Backspace') return 'Backspace';
893
- if (name === 'Delete') return 'Delete';
894
- if (name === 'Escape') return 'Escape';
895
- if (name === ' ') return 'Space';
896
- if (/^Arrow/.test(name)) return name;
897
- if (name.length === 1) {
898
- const ch = name.toUpperCase();
899
- if (ch >= '0' && ch <= '9') return 'Digit' + ch;
900
- if (ch >= 'A' && ch <= 'Z') return 'Key' + ch;
901
- }
902
- return name;
903
- }
904
-
905
- function drainTimers(ms) {
906
- if (typeof globalThis.__csim_runTimers === 'function') {
907
- try { return !!globalThis.__csim_runTimers(ms); } catch (_) {}
908
- }
909
- return false;
910
- }
911
-
912
- // happy-dom has no layout engine, so getBoundingClientRect always returns
913
- // (0,0,0,0). We approximate the box for click-offset routing by reading
914
- // computed `top`/`left`/`width`/`height` (in px) from the element and
915
- // each ancestor. Falls back to inline `style.*` if the computed value is
916
- // unset. Anything beyond simple px positioning yields 0, which is a
917
- // truthful signal that we cannot resolve geometry for the element.
918
- function pxValue(v) {
919
- if (v == null) return 0;
920
- const m = String(v).match(/^(-?\d+(?:\.\d+)?)/);
921
- if (!m) return 0;
922
- const n = parseFloat(m[1]);
923
- return isFinite(n) ? n : 0;
924
- }
925
- function readPx(el, computed, prop) {
926
- let v = computed && computed[prop];
927
- if (!v || v === 'auto') v = el.style && el.style[prop];
928
- return pxValue(v);
929
- }
930
- function simRect(el) {
931
- const win = currentWindow;
932
- if (!el || !win) return {x: 0, y: 0, width: 0, height: 0};
933
- let cs = null;
934
- try { cs = win.getComputedStyle(el); } catch (_) {}
935
- const width = readPx(el, cs, 'width');
936
- const height = readPx(el, cs, 'height');
937
- let x = 0, y = 0, cur = el;
938
- while (cur && cur.nodeType === 1) {
939
- let ccs = null;
940
- try { ccs = win.getComputedStyle(cur); } catch (_) {}
941
- x += readPx(cur, ccs, 'left');
942
- y += readPx(cur, ccs, 'top');
943
- cur = cur.parentElement;
944
- }
945
- return {x, y, width, height};
946
- }
947
-
948
- // Translate an offset-style click descriptor (`offsetX`, `offsetY`,
949
- // `w3cOffset`) into absolute clientX/clientY using the simulated rect.
950
- // Mutates `m` in place. With no offset given, click lands at the
951
- // element's center; rect collapses to (0,0,0,0) for elements we cannot
952
- // resolve, so the click defaults to the document origin in that case.
953
- function applyClickOffset(el, m) {
954
- const hasOffset = ('offsetX' in m) || ('offsetY' in m);
955
- const w3c = !!m.w3cOffset;
956
- delete m.w3cOffset;
957
- const ox = m.offsetX; delete m.offsetX;
958
- const oy = m.offsetY; delete m.offsetY;
959
- if ('clientX' in m && 'clientY' in m) return;
960
- const r = simRect(el);
961
- const baseX = r.x + (w3c || !hasOffset ? r.width / 2 : 0);
962
- const baseY = r.y + (w3c || !hasOffset ? r.height / 2 : 0);
963
- m.clientX = baseX + (Number(ox) || 0);
964
- m.clientY = baseY + (Number(oy) || 0);
965
- }
966
-
967
- globalThis.__csim = {
968
- loadHTML(html, url) {
969
- resetState();
970
- // Wipe globals that scripts may have published on the previous Window
971
- // so that re-loading e.g. jQuery on the next page starts from a clean
972
- // slate. Without this, jQuery's `isReady` carries over and ready
973
- // callbacks never fire on subsequent visits.
974
- const STALE = ['jQuery', '$', 'jquery', 'JQuery'];
975
- for (const k of STALE) {
976
- try { delete globalThis[k]; } catch (_) { globalThis[k] = undefined; }
977
- }
978
- const win = new Window({url: url || 'http://www.example.com/'});
979
- patchWindowGlobals(win);
980
- installFetchShim(win);
981
- installCustomElementUpgrade(win);
982
- installHistoryTracking(win);
983
- currentWindow = win;
984
- currentDocument = win.document;
985
- // Prefer happy-dom's incremental innerHTML parser — it preserves
986
- // form-attribute ordering and other quirks the spec relies on. Some
987
- // real-world Rails/Turbo pages trip an internal "insertBefore: new
988
- // node is a parent of the node to insert to" DOMException there;
989
- // when that happens, fall back to DOMParser, which builds a fresh
990
- // Document and we adopt its <head>/<body>. The DOMParser path
991
- // reorders some form-associated controls so we only use it when
992
- // the primary path fails outright.
993
- const raw = html || '<html><body></body></html>';
994
- try {
995
- win.document.documentElement.innerHTML = stripDoctype(raw);
996
- } catch (_parserErr) {
997
- const parsed = new win.DOMParser().parseFromString(raw, 'text/html');
998
- const dstHead = win.document.head;
999
- const dstBody = win.document.body;
1000
- while (dstHead.firstChild) dstHead.removeChild(dstHead.firstChild);
1001
- while (dstBody.firstChild) dstBody.removeChild(dstBody.firstChild);
1002
- if (parsed.head) {
1003
- for (const child of Array.from(parsed.head.childNodes)) dstHead.appendChild(child);
1004
- }
1005
- if (parsed.body) {
1006
- for (const child of Array.from(parsed.body.childNodes)) dstBody.appendChild(child);
1007
- for (const attr of Array.from(parsed.body.attributes || [])) {
1008
- try { dstBody.setAttribute(attr.name, attr.value); } catch (_) {}
1009
- }
1010
- }
1011
- if (parsed.documentElement) {
1012
- for (const attr of Array.from(parsed.documentElement.attributes || [])) {
1013
- try { win.document.documentElement.setAttribute(attr.name, attr.value); } catch (_) {}
1014
- }
1015
- }
1016
- }
1017
- // Mirror onto globalThis so user scripts (eval/Function) can reach the
1018
- // active document without naming the window explicitly. The Proxy
1019
- // mirrors property writes — `window.Turbo = ...` (a common Rails
1020
- // turbo-rails pattern) needs `globalThis.Turbo` to appear too,
1021
- // because in a real browser `globalThis === window`.
1022
- globalThis.window = new Proxy(win, {
1023
- set(target, key, value) {
1024
- try { target[key] = value; } catch (_) {}
1025
- try { globalThis[key] = value; } catch (_) {}
1026
- return true;
1027
- },
1028
- get(target, key) {
1029
- return target[key];
1030
- },
1031
- has(target, key) {
1032
- return key in target;
1033
- }
1034
- });
1035
- globalThis.document = win.document;
1036
- globalThis.location = win.location;
1037
- globalThis.history = win.history;
1038
- globalThis.alert = win.alert;
1039
- globalThis.confirm = win.confirm;
1040
- globalThis.prompt = win.prompt;
1041
- globalThis.fetch = win.fetch.bind(win);
1042
- globalThis.Response = win.Response;
1043
- globalThis.Request = win.Request;
1044
- globalThis.Headers = win.Headers;
1045
- // Module bundles eval at the top level (globalThis), not inside the
1046
- // window. Mirror the page-level globals user code typically expects
1047
- // so Turbo / Stimulus / arbitrary libraries find them without
1048
- // having to namespace through `window`.
1049
- globalThis.requestAnimationFrame = win.requestAnimationFrame;
1050
- globalThis.cancelAnimationFrame = win.cancelAnimationFrame;
1051
- globalThis.MutationObserver = win.MutationObserver;
1052
- globalThis.IntersectionObserver = win.IntersectionObserver || function () {
1053
- return {observe() {}, unobserve() {}, disconnect() {}, takeRecords() { return []; }};
1054
- };
1055
- globalThis.ResizeObserver = win.ResizeObserver || function () {
1056
- return {observe() {}, unobserve() {}, disconnect() {}};
1057
- };
1058
- globalThis.CustomEvent = win.CustomEvent;
1059
- globalThis.Event = win.Event;
1060
- globalThis.EventTarget = win.EventTarget;
1061
- globalThis.HTMLElement = win.HTMLElement;
1062
- globalThis.Element = win.Element;
1063
- globalThis.Node = win.Node;
1064
- globalThis.DOMParser = win.DOMParser;
1065
- globalThis.FormData = win.FormData;
1066
- globalThis.URL = win.URL || globalThis.URL;
1067
- globalThis.URLSearchParams = win.URLSearchParams || globalThis.URLSearchParams;
1068
- globalThis.AbortController = win.AbortController;
1069
- globalThis.AbortSignal = win.AbortSignal;
1070
- globalThis.navigator = win.navigator;
1071
- globalThis.customElements = win.customElements;
1072
- globalThis.HTMLElement = win.HTMLElement;
1073
- // mini_racer has no host module loader, so dynamic `import()` is
1074
- // rewritten to `globalThis.__csim_import` by the bundler. The
1075
- // registry is populated by per-module assignments emitted in the
1076
- // bundle body, but those run *after* the static-import evaluation
1077
- // phase — and that is the same phase where library code (e.g.
1078
- // stimulus-loading's `eagerLoadControllersFrom`) calls dynamic
1079
- // `import()`. To handle that ordering, every call before drain
1080
- // is queued; we resolve the queue once the bundle has finished
1081
- // evaluating and the registry is populated.
1082
- globalThis.__csim_modules = {};
1083
- globalThis.__csim_pending_imports = [];
1084
- globalThis.__csim_import = function (specifier) {
1085
- const mod = globalThis.__csim_modules[specifier];
1086
- if (mod) return Promise.resolve(mod);
1087
- return new Promise(function (resolve, reject) {
1088
- globalThis.__csim_pending_imports.push({specifier, resolve, reject});
1089
- });
1090
- };
1091
- globalThis.__csim_drain_imports = function () {
1092
- const queue = globalThis.__csim_pending_imports;
1093
- globalThis.__csim_pending_imports = [];
1094
- for (const {specifier, resolve, reject} of queue) {
1095
- const mod = globalThis.__csim_modules[specifier];
1096
- if (mod) resolve(mod);
1097
- else reject(new Error('capybara-simulated: dynamic import not pre-bundled: ' + specifier));
1098
- }
1099
- };
1100
- // Turbo's `extractForeignFrameElement` calls `CSS.escape(id)` to
1101
- // build a selector. happy-dom doesn't expose CSS yet — provide a
1102
- // minimal shim covering the identifier-safe subset.
1103
- globalThis.CSS = globalThis.CSS || win.CSS || {
1104
- escape(s) {
1105
- return String(s).replace(/[^a-zA-Z0-9_ -￿-]/g, (c) => '\\' + c);
1106
- }
1107
- };
1108
- // Bound event-target methods so libraries can do
1109
- // `addEventListener('DOMContentLoaded', ...)` at top level. Real
1110
- // browsers expose these because globalThis === window; we have to
1111
- // forward them explicitly.
1112
- globalThis.addEventListener = win.addEventListener.bind(win);
1113
- globalThis.removeEventListener = win.removeEventListener.bind(win);
1114
- globalThis.dispatchEvent = win.dispatchEvent.bind(win);
1115
- globalThis.matchMedia = win.matchMedia ? win.matchMedia.bind(win) : (() => ({matches: false, media: '', addListener() {}, removeListener() {}, addEventListener() {}, removeEventListener() {}, dispatchEvent() { return false; }}));
1116
- globalThis.getComputedStyle = win.getComputedStyle.bind(win);
1117
- globalThis.scrollTo = () => {};
1118
- globalThis.scrollBy = () => {};
1119
- globalThis.innerWidth = win.innerWidth || 1024;
1120
- globalThis.innerHeight = win.innerHeight || 768;
1121
- return true;
1122
- },
1123
-
1124
- runInlineScripts() {
1125
- if (!currentDocument) return;
1126
- const scripts = currentDocument.querySelectorAll('script:not([src])');
1127
- const sources = [];
1128
- for (const s of scripts) sources.push(s.textContent || '');
1129
- for (const code of sources) {
1130
- try { (0, eval)(code); } catch (_) {}
1131
- }
1132
- attachInlineHandlers(currentDocument);
1133
- syncWindowGlobals();
1134
- fireReadyEvents();
1135
- },
1136
-
1137
- syncWindowGlobals() { syncWindowGlobals(); },
1138
-
1139
- externalScriptSources() {
1140
- if (!currentDocument) return [];
1141
- const out = [];
1142
- // Skip type="module" — those are handled separately via
1143
- // `moduleScriptDetails` so the Ruby driver can run them through
1144
- // esbuild instead of treating them as classic scripts.
1145
- for (const s of currentDocument.querySelectorAll('script[src]:not([type="module"])')) {
1146
- const t = (s.getAttribute('type') || '').toLowerCase();
1147
- if (t && t !== 'text/javascript' && t !== 'application/javascript') continue;
1148
- const src = s.getAttribute('src');
1149
- if (src) out.push(src);
1150
- }
1151
- return out;
1152
- },
1153
-
1154
- // Collect everything the Ruby module loader needs in one round-trip:
1155
- // the importmap JSON (inline only — Rails always inlines it) and the
1156
- // ordered list of `<script type="module">` entries with either
1157
- // `inline` source or `src`. Returns null when the page has no module
1158
- // scripts so the Ruby side can short-circuit.
1159
- moduleScriptDetails() {
1160
- if (!currentDocument) return null;
1161
- const out = {importmap: '', entries: []};
1162
- const im = currentDocument.querySelector('script[type="importmap"]');
1163
- if (im) out.importmap = im.textContent || '';
1164
- const mods = currentDocument.querySelectorAll('script[type="module"]');
1165
- for (const s of mods) {
1166
- const src = s.getAttribute('src');
1167
- if (src) out.entries.push({src});
1168
- else if (s.textContent && s.textContent.trim()) out.entries.push({inline: s.textContent});
1169
- }
1170
- if (!out.importmap && out.entries.length === 0) return null;
1171
- return out;
1172
- },
1173
-
1174
- pushModalHandler(handler) {
1175
- modalHandlers.push(handler);
1176
- return true;
1177
- },
1178
- popModalHandler(type, text) {
1179
- const sameText = (a, b) => {
1180
- if (a == null && b == null) return true;
1181
- if (typeof a === 'string' && typeof b === 'string') return a === b;
1182
- if (a && b && typeof a === 'object' && typeof b === 'object' && a.regexp === b.regexp) return true;
1183
- return false;
1184
- };
1185
- const idx = modalHandlers.findIndex((h) => h.type === type && sameText(h.text, text));
1186
- if (idx >= 0) modalHandlers.splice(idx, 1);
1187
- return true;
1188
- },
1189
-
1190
- setModalResponses(responses) {
1191
- modalResponses = {
1192
- alert: (responses && responses.alert) || [],
1193
- confirm: (responses && responses.confirm) || [],
1194
- prompt: (responses && responses.prompt) || []
1195
- };
1196
- return true;
1197
- },
1198
-
1199
- drainModalQueue() {
1200
- const out = modalQueue.slice();
1201
- modalQueue.length = 0;
1202
- return out;
1203
- },
1204
-
1205
- evaluate(code, args) {
1206
- const params = unwrapScriptArgs(Array.isArray(args) ? args : []);
1207
- // Strip trailing semicolons / whitespace so `(...);` style scripts
1208
- // still parse when wrapped in `return (...)`.
1209
- const stripped = String(code).replace(/[;\s]+$/, '');
1210
- try {
1211
- const fn = new Function('return (' + stripped + ');');
1212
- return wrapScriptResult(fn.apply(currentWindow, params));
1213
- } catch (_) {
1214
- const fn = new Function(code);
1215
- return wrapScriptResult(fn.apply(currentWindow, params));
1216
- }
1217
- },
1218
- executeScript(code, args) {
1219
- const params = unwrapScriptArgs(Array.isArray(args) ? args : []);
1220
- const fn = new Function(code);
1221
- fn.apply(currentWindow, params);
1222
- return null;
1223
- },
1224
-
1225
- // The user script receives a callback as its final argument and is
1226
- // expected to invoke it (synchronously or asynchronously) with the
1227
- // result. The Ruby side drives the virtual clock between polls until
1228
- // the callback fires or the wait budget runs out.
1229
- startAsync(code, args) {
1230
- const params = unwrapScriptArgs(Array.isArray(args) ? args : []);
1231
- asyncSlot = {done: false};
1232
- const slot = asyncSlot;
1233
- const callback = (value) => {
1234
- if (slot.done) return;
1235
- slot.done = true;
1236
- slot.value = value;
1237
- };
1238
- try {
1239
- const fn = new Function(code);
1240
- fn.apply(currentWindow, params.concat([callback]));
1241
- } catch (e) {
1242
- slot.done = true;
1243
- slot.error = String((e && e.stack) || e);
1244
- }
1245
- return true;
1246
- },
1247
- pollAsync() {
1248
- const slot = asyncSlot;
1249
- if (!slot) return {done: true, error: 'no async script in flight'};
1250
- if (!slot.done) return {done: false};
1251
- asyncSlot = null;
1252
- if ('error' in slot) return {done: true, error: slot.error};
1253
- return {done: true, value: wrapScriptResult(slot.value)};
1254
- },
1255
-
1256
- html() {
1257
- if (!currentDocument || !currentDocument.documentElement) return '';
1258
- return String('<!doctype html>' + currentDocument.documentElement.outerHTML);
1259
- },
1260
- title() {
1261
- if (!currentDocument) return '';
1262
- // Prefer the raw `<title>` text content so leading/trailing
1263
- // whitespace round-trips through Capybara's title matchers.
1264
- const t = currentDocument.querySelector && currentDocument.querySelector('title');
1265
- if (t && t.textContent != null) return String(t.textContent);
1266
- return String(currentDocument.title || '');
1267
- },
1268
-
1269
- findXPath(xpath, contextId) {
1270
- if (!currentDocument) return [];
1271
- const root = contextId ? lookup(contextId) : (currentDocument.documentElement || currentDocument);
1272
- const fast = tryFastXPath(xpath, root);
1273
- if (fast) return fast.map(track);
1274
- try {
1275
- const result = currentDocument.evaluate(
1276
- xpath, root, null,
1277
- /* UNORDERED_NODE_ITERATOR_TYPE */ 4, null
1278
- );
1279
- const ids = [];
1280
- let n;
1281
- while ((n = result.iterateNext())) ids.push(track(n));
1282
- return ids;
1283
- } catch (e) {
1284
- return findViaXPathFallback(xpath, root);
1285
- }
1286
- },
1287
- findCSS(css, contextId) {
1288
- if (!currentDocument) return [];
1289
- const root = contextId ? lookup(contextId) : currentDocument;
1290
- if (!root || !root.querySelectorAll) return [];
1291
- const matches = root.querySelectorAll(rewriteCSS(css));
1292
- const ids = [];
1293
- for (const el of matches) ids.push(track(el));
1294
- return ids;
1295
- },
1296
-
1297
- tagName(id) {
1298
- const node = lookup(id);
1299
- // ShadowRoot has nodeType 11 with no tagName; Capybara's element
1300
- // inspect formatter expects a string here.
1301
- if (node && node.nodeType === 11) return 'ShadowRoot';
1302
- return String((node.tagName || '').toLowerCase());
1303
- },
1304
- innerHTML(id) { return String(lookup(id).innerHTML || ''); },
1305
- outerHTML(id) { return String(lookup(id).outerHTML || ''); },
1306
- attr(id, name) {
1307
- const el = lookup(id);
1308
- if (el.hasAttribute(name)) return el.getAttribute(name);
1309
- // Capybara's `node[:validationMessage]` and friends expect the IDL
1310
- // property when no matching HTML attribute exists — Selenium's
1311
- // attribute() does the same fallback. Returning null here would
1312
- // hide computed values (validationMessage, validity, etc.).
1313
- const direct = el[name];
1314
- if (direct === undefined) return null;
1315
- if (direct === null || typeof direct === 'string' || typeof direct === 'boolean' || typeof direct === 'number') {
1316
- return direct;
1317
- }
1318
- return null;
1319
- },
1320
- prop(id, name) {
1321
- const v = lookup(id)[name];
1322
- return v === undefined ? null : v;
1323
- },
1324
- allText(id) { return String(lookup(id).textContent || ''); },
1325
- visibleText(id) {
1326
- const cached = visibleTextCache.get(id);
1327
- if (cached !== undefined) return cached;
1328
- const el = lookup(id);
1329
- // If any ancestor of `el` is hidden, the element renders nothing.
1330
- const txt = visibleAncestorChainOk(el) ? String(visibleTextOf(el)) : '';
1331
- visibleTextCache.set(id, txt);
1332
- return txt;
1333
- },
1334
- path(id) { return String(buildXPath(lookup(id))); },
1335
- rect(id) {
1336
- const el = lookup(id);
1337
- return {x: 0, y: 0, width: 0, height: 0, top: 0, left: 0,
1338
- right: 0, bottom: 0, tagName: (el.tagName || '').toLowerCase()};
1339
- },
1340
-
1341
- value(id) {
1342
- const el = lookup(id);
1343
- const tag = (el.tagName || '').toUpperCase();
1344
- if (tag === 'SELECT' && isMultipleSelect(el)) {
1345
- return Array.from(el.options).filter(o => boolPropOrAttr(o, 'selected'))
1346
- .map(o => String(o.value == null ? '' : o.value));
1347
- }
1348
- const type = (el.type || (el.getAttribute && el.getAttribute('type')) || '').toLowerCase();
1349
- if (type === 'checkbox' || type === 'radio') {
1350
- const v = el.getAttribute && el.getAttribute('value');
1351
- return v == null ? 'on' : String(v);
1352
- }
1353
- if (tag === 'TEXTAREA') {
1354
- // HTML spec: a single leading newline directly after <textarea> is
1355
- // stripped from the *initial* value. happy-dom does not apply
1356
- // that rule. Only strip when our driver has not written to the
1357
- // field yet — once `setValue` ran we treat the buffer as user
1358
- // content and preserve it byte-for-byte.
1359
- let raw = el.value == null ? '' : String(el.value);
1360
- if (!el.__csim_user_set) {
1361
- if (raw.startsWith('\r\n')) raw = raw.slice(2);
1362
- else if (raw.startsWith('\n')) raw = raw.slice(1);
1363
- }
1364
- return raw;
1365
- }
1366
- if ('value' in el) return el.value == null ? '' : String(el.value);
1367
- return null;
1368
- },
1369
-
1370
- setValue(id, value) {
1371
- clearReadCache();
1372
- const el = lookup(id);
1373
- const tag = (el.tagName || '').toUpperCase();
1374
- const type = (el.type || (el.getAttribute && el.getAttribute('type')) || '').toLowerCase();
1375
- // contenteditable inherits — walk ancestors looking for an explicit
1376
- // value other than `false`.
1377
- let ceCur = el, ce = null;
1378
- while (ceCur && ceCur.nodeType === 1) {
1379
- const v = ceCur.getAttribute && ceCur.getAttribute('contenteditable');
1380
- if (v !== null && v !== undefined) { ce = v; break; }
1381
- ceCur = ceCur.parentNode;
1382
- }
1383
- if (ce !== null && ce !== undefined && ce !== 'false') {
1384
- el.textContent = String(value == null ? '' : value);
1385
- el.dispatchEvent(makeEvent('input'));
1386
- el.dispatchEvent(makeEvent('change'));
1387
- return true;
1388
- }
1389
- if (tag === 'SELECT' && isMultipleSelect(el) && Array.isArray(value)) {
1390
- for (const opt of el.options) {
1391
- if (value.includes(opt.value)) opt.setAttribute('selected', '');
1392
- else opt.removeAttribute('selected');
1393
- }
1394
- } else if (type === 'checkbox') {
1395
- // Real browsers fire click + change when set/unset via UI. Drive a
1396
- // click whenever the desired state differs from the current state
1397
- // so user-bound `click` handlers run. Use the IDL property only —
1398
- // Stimulus controllers commonly toggle `el.checked` directly,
1399
- // which leaves the `checked` HTML attribute stale.
1400
- const want = !!value;
1401
- const have = el.checked === true;
1402
- if (want !== have) {
1403
- el.dispatchEvent(makeEvent('click'));
1404
- if (el.checked) el.setAttribute('checked', '');
1405
- else el.removeAttribute('checked');
1406
- }
1407
- } else if (type === 'radio') {
1408
- if (value) {
1409
- // Setting a radio also deselects every same-named peer.
1410
- if (el.name) {
1411
- for (const peer of currentDocument.querySelectorAll('input[type=radio][name="' + cssEscape(el.name) + '"]')) {
1412
- if (peer === el) { peer.setAttribute('checked', ''); peer.checked = true; }
1413
- else { peer.removeAttribute('checked'); peer.checked = false; }
1414
- }
1415
- } else {
1416
- el.setAttribute('checked', '');
1417
- el.checked = true;
1418
- }
1419
- } else {
1420
- el.removeAttribute('checked');
1421
- el.checked = false;
1422
- }
1423
- } else if (type === 'file') {
1424
- el.__csim_files = Array.isArray(value) ? value : [value];
1425
- } else {
1426
- let str = String(value == null ? '' : value);
1427
- // Pre-formatted ISO timestamps from Ruby: trim to whatever the
1428
- // <input type="..."> actually accepts.
1429
- if (type === 'time' && /^\d{4}-\d{2}-\d{2}T(\d{2}:\d{2})/.test(str)) {
1430
- str = str.replace(/^\d{4}-\d{2}-\d{2}T/, '');
1431
- } else if (type === 'date' && /^(\d{4}-\d{2}-\d{2})/.test(str)) {
1432
- str = str.match(/^\d{4}-\d{2}-\d{2}/)[0];
1433
- }
1434
- if (type === 'range') {
1435
- // <input type=range> rounds to the nearest valid step from min.
1436
- const min = parseFloat(el.getAttribute('min')) || 0;
1437
- const max = parseFloat(el.getAttribute('max'));
1438
- const step = parseFloat(el.getAttribute('step')) || 1;
1439
- const num = parseFloat(str);
1440
- if (Number.isFinite(num) && step > 0) {
1441
- let snapped = Math.round((num - min) / step) * step + min;
1442
- if (Number.isFinite(max)) snapped = Math.min(snapped, max);
1443
- snapped = Math.max(snapped, min);
1444
- // Trim floating-point fuzz introduced by the rounding above.
1445
- const decimals = (String(step).split('.')[1] || '').length;
1446
- str = snapped.toFixed(decimals);
1447
- }
1448
- }
1449
- // HTML "implicit submission": a `\n` in a text-like input on a form
1450
- // with only one such control should submit the form. Strip the \n
1451
- // from the stored value but remember to fire submit afterwards.
1452
- let implicitSubmit = false;
1453
- if (tag === 'INPUT' && /^(text|email|number|search|tel|url|password|date|time|datetime-local|month|week|color|range)?$/.test(type) && str.endsWith('\n')) {
1454
- str = str.replace(/\n$/, '');
1455
- const form = formForControl(el);
1456
- if (form && countSubmittableTextInputs(form) === 1) implicitSubmit = true;
1457
- }
1458
- const maxLen = parseInt(el.getAttribute && el.getAttribute('maxlength'), 10);
1459
- if (Number.isFinite(maxLen) && maxLen >= 0) str = str.slice(0, maxLen);
1460
- el.value = str;
1461
- el.__csim_user_set = true;
1462
- el.dispatchEvent(makeEvent('input'));
1463
- el.dispatchEvent(makeEvent('change'));
1464
- if (implicitSubmit) {
1465
- const form = formForControl(el);
1466
- if (form) {
1467
- const desc = submitDescriptor(form, null);
1468
- return desc;
1469
- }
1470
- }
1471
- return true;
1472
- }
1473
- el.dispatchEvent(makeEvent('input'));
1474
- el.dispatchEvent(makeEvent('change'));
1475
- return true;
1476
- },
1477
-
1478
- selectOption(id) {
1479
- clearReadCache();
1480
- const opt = lookup(id);
1481
- const select = opt.parentNode && (opt.parentNode.tagName === 'SELECT'
1482
- ? opt.parentNode
1483
- : opt.closest && opt.closest('select'));
1484
- if (!select) return false;
1485
- if (!isMultipleSelect(select)) {
1486
- for (const o of select.options) {
1487
- if (o === opt) o.setAttribute('selected', '');
1488
- else o.removeAttribute('selected');
1489
- }
1490
- } else {
1491
- opt.setAttribute('selected', '');
1492
- }
1493
- select.dispatchEvent(makeEvent('input'));
1494
- select.dispatchEvent(makeEvent('change'));
1495
- return true;
1496
- },
1497
- unselectOption(id) {
1498
- clearReadCache();
1499
- const opt = lookup(id);
1500
- const select = opt.closest && opt.closest('select');
1501
- if (!select || !isMultipleSelect(select)) return false;
1502
- opt.removeAttribute('selected');
1503
- select.dispatchEvent(makeEvent('input'));
1504
- select.dispatchEvent(makeEvent('change'));
1505
- return true;
1506
- },
1507
-
1508
- visible(id) { return visibleAncestorChainOk(lookup(id)); },
1509
- checked(id) { return boolPropOrAttr(lookup(id), 'checked'); },
1510
- selected(id) { return boolPropOrAttr(lookup(id), 'selected'); },
1511
- disabled(id) {
1512
- const el = lookup(id);
1513
- const tag = (el.tagName || '').toUpperCase();
1514
- // Only form controls and `<option>`s actually honour the `disabled`
1515
- // attribute. Plain anchors etc. with stray `disabled` markers must
1516
- // not be reported as disabled.
1517
- const DISABLEABLE = new Set(['BUTTON', 'INPUT', 'SELECT', 'TEXTAREA',
1518
- 'OPTGROUP', 'OPTION', 'FIELDSET']);
1519
- if (DISABLEABLE.has(tag) && boolPropOrAttr(el, 'disabled')) return true;
1520
- // An option inside a disabled <select> or <optgroup> is disabled too.
1521
- if (tag === 'OPTION') {
1522
- let p = el.parentNode;
1523
- while (p && p.nodeType === 1) {
1524
- const pt = (p.tagName || '').toUpperCase();
1525
- if (pt === 'OPTGROUP' && boolPropOrAttr(p, 'disabled')) return true;
1526
- if (pt === 'SELECT' && boolPropOrAttr(p, 'disabled')) return true;
1527
- p = p.parentNode;
1528
- }
1529
- }
1530
- let cur = el.parentNode;
1531
- while (cur && cur.nodeType === 1) {
1532
- if ((cur.tagName || '').toUpperCase() === 'FIELDSET' && boolPropOrAttr(cur, 'disabled')) {
1533
- const legend = cur.querySelector && cur.querySelector('legend');
1534
- if (legend && legend.contains && legend.contains(el)) return false;
1535
- return true;
1536
- }
1537
- cur = cur.parentNode;
1538
- }
1539
- return false;
1540
- },
1541
- readonly(id) { return boolPropOrAttr(lookup(id), 'readOnly') ||
1542
- boolPropOrAttr(lookup(id), 'readonly'); },
1543
- multiple(id) { return boolPropOrAttr(lookup(id), 'multiple'); },
1544
-
1545
- focus(id) { clearReadCache(); activeHandleId = id; lookup(id).dispatchEvent(makeEvent('focus', {bubbles: false})); return true; },
1546
- blur(id) { clearReadCache(); lookup(id).dispatchEvent(makeEvent('blur', {bubbles: false})); if (activeHandleId === id) activeHandleId = null; return true; },
1547
- activeElement() {
1548
- // Honour the JS-side `document.activeElement` first — happy-dom
1549
- // updates it when scripts call `focus()` directly, which the
1550
- // tracker variable does not see. Fall back to the runtime's
1551
- // tracker when the document hasn't initialised yet.
1552
- const el = currentDocument && currentDocument.activeElement;
1553
- if (el) return track(el);
1554
- return activeHandleId;
1555
- },
1556
-
1557
- hover(id) {
1558
- clearReadCache();
1559
- const el = lookup(id);
1560
- el.dispatchEvent(makeEvent('mouseover'));
1561
- el.dispatchEvent(makeEvent('mouseenter', {bubbles: false}));
1562
- return true;
1563
- },
1564
- trigger(id, name) { clearReadCache(); lookup(id).dispatchEvent(makeEvent(name)); return true; },
1565
-
1566
- drainTimers(ms) {
1567
- // Only clear the read cache if a timer actually fired — most
1568
- // `advance_virtual_clock` calls from Ruby have nothing to do but
1569
- // tick the wall clock. Without this the visibleText cache is
1570
- // invalidated on every cross-bridge call and never hits.
1571
- if (drainTimers(ms)) clearReadCache();
1572
- return true;
1573
- },
1574
-
1575
- consumeHistoryPushed() {
1576
- const v = _historyPushed;
1577
- _historyPushed = false;
1578
- return v;
1579
- },
1580
-
1581
- shadowRoot(id) {
1582
- const el = lookup(id);
1583
- const root = el && el.shadowRoot;
1584
- return root ? track(root) : null;
1585
- },
1586
-
1587
- mouseDown(id, button, modifiers) {
1588
- const el = lookup(id);
1589
- const m = Object.assign({button: button || 0}, modifiers || {});
1590
- applyClickOffset(el, m);
1591
- el.dispatchEvent(makeEvent('mousedown', m));
1592
- return true;
1593
- },
1594
-
1595
- click(id, button, modifiers, skipDown) {
1596
- clearReadCache();
1597
- const el = lookup(id);
1598
- activeHandleId = id;
1599
- const m = Object.assign({button: button || 0}, modifiers || {});
1600
- applyClickOffset(el, m);
1601
- // Real browsers dispatch mousedown → mouseup → click (or contextmenu
1602
- // for the right button). Page handlers like the Capybara test suite's
1603
- // `mouse_down_time` / `click_delay` rely on the down/up pair.
1604
- if (!skipDown) el.dispatchEvent(makeEvent('mousedown', m));
1605
- el.dispatchEvent(makeEvent('mouseup', m));
1606
- const eventType = button === 2 ? 'contextmenu' : 'click';
1607
- const evt = makeEvent(eventType, m);
1608
- // For anchor elements we used to attach a preventDefault suppressor
1609
- // so happy-dom's HTMLAnchorElement.dispatchEvent wouldn't call
1610
- // `window.open(href)` and double-navigate. The suppressor masked
1611
- // legitimate `preventDefault()` calls from Turbo / Stimulus link
1612
- // interceptors though, which made the runtime always fall through
1613
- // to a Ruby-side `navigate` even when Turbo had already taken
1614
- // responsibility for the link via a `<turbo-frame>` visit. Track
1615
- // whether *something else* prevented the default by sampling the
1616
- // event's defaultPrevented before our late capture listener
1617
- // re-prevents to keep happy-dom out of the way.
1618
- const tagU = (el.tagName || '').toUpperCase();
1619
- const isLink = tagU === 'A' && el.getAttribute('href') != null;
1620
- // happy-dom's HTMLButtonElement / HTMLInputElement.dispatchEvent
1621
- // auto-dispatches `submit` on the form when this is a submit-type
1622
- // control click. Capture whether that submit was preventDefaulted so
1623
- // submitDescriptor doesn't fire a redundant submit event (which
1624
- // would lose the `submitter` field and confuse Turbo).
1625
- const submitForm = (tagU === 'BUTTON' || (tagU === 'INPUT' && /^(submit|image)$/i.test(el.type || '')))
1626
- ? formForControl(el)
1627
- : null;
1628
- let autoSubmitFired = false;
1629
- let lastAutoSubmit = null;
1630
- let autoSubmitListener = null;
1631
- if (submitForm) {
1632
- // Capture-phase listener so it always fires before any bubble-
1633
- // phase handler (notably Turbo's FormSubmitObserver attached to
1634
- // document). The final `defaultPrevented` value is whatever later
1635
- // listeners chose, so read it after dispatch returns.
1636
- autoSubmitListener = (e) => {
1637
- autoSubmitFired = true;
1638
- lastAutoSubmit = e;
1639
- };
1640
- submitForm.addEventListener('submit', autoSubmitListener, true);
1641
- }
1642
- const proceeded = el.dispatchEvent(evt);
1643
- let autoSubmitPrevented = !!(lastAutoSubmit && lastAutoSubmit.defaultPrevented);
1644
- if (submitForm) {
1645
- try { submitForm.removeEventListener('submit', autoSubmitListener, true); } catch (_) {}
1646
- }
1647
- // For links, we want happy-dom's HTMLAnchorElement default
1648
- // (window.open / location set) NOT to fire — the Ruby driver does
1649
- // the navigate. preventDefault here acts only on happy-dom's
1650
- // default action because user listeners have already run during
1651
- // the dispatch above; their decisions are reflected in
1652
- // `proceeded` (false ⇔ defaultPrevented).
1653
- const externallyPrevented = isLink && !proceeded;
1654
- if (isLink) try { evt.preventDefault(); } catch (_) {}
1655
- if (!proceeded && !isLink) return {action: 'none'};
1656
- if (button === 2) return {action: 'none'};
1657
-
1658
- const tag = (el.tagName || '').toUpperCase();
1659
- if (tag === 'LABEL') {
1660
- // happy-dom already redirects label clicks to their associated
1661
- // form control (toggling checkboxes/radios), so we don't need to
1662
- // route the click manually. We do still need to mirror the new
1663
- // state into the `checked` attribute (form serialisation reads
1664
- // it) and, for radios, deselect peers that share the same name.
1665
- const targetId = el.getAttribute && el.getAttribute('for');
1666
- let target = null;
1667
- if (targetId) target = currentDocument.getElementById(targetId);
1668
- if (!target) target = el.querySelector && el.querySelector('input,textarea,select,button');
1669
- if (target) {
1670
- if (target.checked) target.setAttribute('checked', '');
1671
- else target.removeAttribute('checked');
1672
- if ((target.type || '').toLowerCase() === 'radio' && target.name && target.checked) {
1673
- for (const peer of currentDocument.querySelectorAll('input[type=radio][name="' + cssEscape(target.name) + '"]')) {
1674
- if (peer === target) continue;
1675
- peer.checked = false;
1676
- peer.removeAttribute('checked');
1677
- }
1678
- }
1679
- }
1680
- }
1681
- if (tag === 'A' && el.getAttribute('href') != null) {
1682
- const href = el.getAttribute('href');
1683
- if (!href || href.startsWith('#') || href.startsWith('javascript:')) {
1684
- return {action: 'none'};
1685
- }
1686
- // When the link sits inside a `<turbo-frame>` (and isn't escaping
1687
- // to the top with `data-turbo-frame="_top"` or `data-turbo="false"`),
1688
- // Turbo's FrameController intercepts the click and replaces the
1689
- // frame's contents asynchronously. Don't double-navigate via
1690
- // Rack in that case — the frame visit owns the load. Top-level
1691
- // Drive visits still go through Ruby navigate so the response
1692
- // is rendered as a fresh page.
1693
- if (externallyPrevented && isFrameScopedLink(el)) return {action: 'none'};
1694
- return {action: 'navigate', href};
1695
- }
1696
- if (tag === 'BUTTON') {
1697
- const btnType = (el.type || 'submit').toLowerCase();
1698
- if (btnType === 'submit') {
1699
- const form = formForControl(el);
1700
- if (form) return submitDescriptor(form, el, {skipDispatch: autoSubmitFired, prevented: autoSubmitPrevented});
1701
- }
1702
- if (btnType === 'reset') {
1703
- const form = formForControl(el);
1704
- if (form && form.reset) form.reset();
1705
- }
1706
- }
1707
- if (tag === 'INPUT') {
1708
- const type = (el.type || '').toLowerCase();
1709
- if (type === 'submit' || type === 'image') {
1710
- const form = formForControl(el);
1711
- if (form) return submitDescriptor(form, el, {skipDispatch: autoSubmitFired, prevented: autoSubmitPrevented});
1712
- }
1713
- if (type === 'reset') {
1714
- const form = formForControl(el);
1715
- if (form && form.reset) form.reset();
1716
- }
1717
- if (type === 'checkbox') {
1718
- // happy-dom toggles `el.checked` automatically on dispatchEvent
1719
- // for click on a checkbox. Sync the attribute so attribute-based
1720
- // queries (and form serialisation) stay consistent.
1721
- if (el.checked) el.setAttribute('checked', '');
1722
- else el.removeAttribute('checked');
1723
- el.dispatchEvent(makeEvent('change'));
1724
- }
1725
- if (type === 'radio') {
1726
- // happy-dom selects `el` on click but does not deselect peers.
1727
- if (el.name) {
1728
- for (const peer of currentDocument.querySelectorAll('input[type=radio][name="' + cssEscape(el.name) + '"]')) {
1729
- if (peer === el) { peer.setAttribute('checked', ''); peer.checked = true; }
1730
- else { peer.removeAttribute('checked'); peer.checked = false; }
1731
- }
1732
- } else {
1733
- el.setAttribute('checked', '');
1734
- el.checked = true;
1735
- }
1736
- el.dispatchEvent(makeEvent('change'));
1737
- }
1738
- }
1739
- return {action: 'none'};
1740
- },
1741
-
1742
- doubleClick(id, modifiers) {
1743
- clearReadCache();
1744
- const el = lookup(id);
1745
- const m = Object.assign({button: 0}, modifiers || {});
1746
- applyClickOffset(el, m);
1747
- el.dispatchEvent(makeEvent('mousedown', m));
1748
- el.dispatchEvent(makeEvent('mouseup', m));
1749
- el.dispatchEvent(makeEvent('click', m));
1750
- el.dispatchEvent(makeEvent('mousedown', m));
1751
- el.dispatchEvent(makeEvent('mouseup', m));
1752
- el.dispatchEvent(makeEvent('click', m));
1753
- el.dispatchEvent(makeEvent('dblclick', m));
1754
- return {action: 'none'};
1755
- },
1756
- rightClick(id, modifiers, skipDown) {
1757
- clearReadCache();
1758
- const el = lookup(id);
1759
- const m = Object.assign({button: 2}, modifiers || {});
1760
- applyClickOffset(el, m);
1761
- if (!skipDown) el.dispatchEvent(makeEvent('mousedown', m));
1762
- el.dispatchEvent(makeEvent('mouseup', m));
1763
- el.dispatchEvent(makeEvent('contextmenu', m));
1764
- return {action: 'none'};
1765
- },
1766
-
1767
- // Mirrors Capybara's HTML5 drop helper: build a DataTransfer holding
1768
- // the supplied files and/or strings, attach it to the dragenter,
1769
- // dragover and drop events, and dispatch them on the target. happy-dom
1770
- // exposes `DragEvent` as a plain `Event`, so we paste `dataTransfer`
1771
- // onto each event before dispatch.
1772
- drop(id, items) {
1773
- clearReadCache();
1774
- const el = lookup(id);
1775
- const win = currentWindow;
1776
- const dt = new win.DataTransfer();
1777
- for (const item of items || []) {
1778
- if (item.kind === 'file') {
1779
- const file = new win.File([item.contents || ''], item.name || '', {type: item.type || ''});
1780
- dt.items.add(file);
1781
- } else {
1782
- dt.items.add(String(item.data == null ? '' : item.data), String(item.type || ''));
1783
- }
1784
- }
1785
- const fire = (name) => {
1786
- const ev = makeEvent(name, {bubbles: true, cancelable: true});
1787
- try {
1788
- Object.defineProperty(ev, 'dataTransfer', {value: dt, writable: false, configurable: true});
1789
- } catch (_) {
1790
- ev.dataTransfer = dt;
1791
- }
1792
- el.dispatchEvent(ev);
1793
- };
1794
- fire('dragenter');
1795
- fire('dragover');
1796
- fire('drop');
1797
- return true;
1798
- },
1799
-
1800
- submit(id) { clearReadCache(); return submitDescriptor(lookup(id), null); },
1801
-
1802
- sendKeys(id, keys) {
1803
- clearReadCache();
1804
- const el = lookup(id);
1805
- const editable = (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA');
1806
- let formToSubmit = null;
1807
- // Modifiers held for the rest of the call after a top-level
1808
- // `:shift` etc. (Selenium semantics). [:shift, 'o'] combos hold
1809
- // the modifier just for `tail`.
1810
- const heldModifiers = new Set();
1811
- const cursor = {pos: editable ? (el.value || '').length : 0};
1812
-
1813
- const fireKey = (name, opts) => {
1814
- const detail = Object.assign({key: keyForEvent(name, opts), code: keyCodeName(name), keyCode: keyCodeFor(name)}, opts || {});
1815
- for (const m of heldModifiers) detail[modifierProp(m)] = true;
1816
- if (opts && opts.modifiers) for (const m of opts.modifiers) detail[modifierProp(m)] = true;
1817
- el.dispatchEvent(makeEvent('keydown', detail));
1818
- if (!isSpecialKey(name)) el.dispatchEvent(makeEvent('keypress', detail));
1819
- applyKeyEffect(name, detail);
1820
- el.dispatchEvent(makeEvent('keyup', detail));
1821
- };
1822
- const applyKeyEffect = (name, detail) => {
1823
- if (!editable) return;
1824
- const cur = el.value == null ? '' : el.value;
1825
- const shift = !!detail.shiftKey;
1826
- const ctrlOrMeta = !!detail.ctrlKey || !!detail.metaKey;
1827
- if (ctrlOrMeta) return; // hotkeys: skip text effect
1828
- if (name === 'Backspace') {
1829
- if (cursor.pos === 0) return;
1830
- el.value = cur.slice(0, cursor.pos - 1) + cur.slice(cursor.pos);
1831
- cursor.pos -= 1;
1832
- el.dispatchEvent(makeEvent('input'));
1833
- } else if (name === 'Delete') {
1834
- if (cursor.pos === cur.length) return;
1835
- el.value = cur.slice(0, cursor.pos) + cur.slice(cursor.pos + 1);
1836
- el.dispatchEvent(makeEvent('input'));
1837
- } else if (name === 'ArrowLeft') cursor.pos = Math.max(0, cursor.pos - 1);
1838
- else if (name === 'ArrowRight') cursor.pos = Math.min(cur.length, cursor.pos + 1);
1839
- else if (name === 'Home') cursor.pos = 0;
1840
- else if (name === 'End') cursor.pos = cur.length;
1841
- else if (!isSpecialKey(name)) {
1842
- const ch = shift ? name.toUpperCase() : name;
1843
- el.value = cur.slice(0, cursor.pos) + ch + cur.slice(cursor.pos);
1844
- cursor.pos += ch.length;
1845
- el.dispatchEvent(makeEvent('input'));
1846
- } else if (name === 'Enter' && (el.tagName === 'INPUT')) {
1847
- formToSubmit = el.form || el.closest('form');
1848
- }
1849
- };
1850
-
1851
- const handle = (k) => {
1852
- if (typeof k === 'string') {
1853
- for (const ch of k) fireKey(ch, {});
1854
- return;
1855
- }
1856
- if (!k || typeof k !== 'object') return;
1857
- if ('combo' in k) {
1858
- // Press each modifier (firing keydown), execute the tail with
1859
- // them held, then release in reverse order.
1860
- for (const mod of k.combo) {
1861
- heldModifiers.add(mod);
1862
- el.dispatchEvent(makeEvent('keydown', {key: mod, code: keyCodeName(mod), keyCode: keyCodeFor(mod), [modifierProp(mod)]: true}));
1863
- }
1864
- for (const part of k.tail) handle(part);
1865
- for (let i = k.combo.length - 1; i >= 0; i--) {
1866
- const mod = k.combo[i];
1867
- el.dispatchEvent(makeEvent('keyup', {key: mod, code: keyCodeName(mod), keyCode: keyCodeFor(mod), [modifierProp(mod)]: true}));
1868
- heldModifiers.delete(mod);
1869
- }
1870
- return;
1871
- }
1872
- if ('special' in k) {
1873
- const name = String(k.special);
1874
- if (isModifier(name)) {
1875
- // Top-level modifier symbol toggles a held-down state for
1876
- // the remainder of the call.
1877
- if (heldModifiers.has(name)) heldModifiers.delete(name);
1878
- else heldModifiers.add(name);
1879
- return;
1880
- }
1881
- if (name === 'Enter' && el.tagName === 'INPUT') {
1882
- formToSubmit = el.form || el.closest('form');
1883
- }
1884
- if (name === 'Tab') advanceFocus(el, !!heldModifiers.has('Shift'));
1885
- fireKey(name, {});
1886
- }
1887
- };
1888
-
1889
- for (const k of keys) handle(k);
1890
- if (formToSubmit) return submitDescriptor(formToSubmit, null);
1891
- return {action: 'none'};
1892
- }
1893
- };
1894
-
1895
- // Real browsers expose every property of `window` as a global. mini_racer
1896
- // keeps globalThis and the happy-dom Window separate, so libraries that
1897
- // attach to `window` (jQuery, page boot scripts) need their additions
1898
- // mirrored back onto globalThis for subsequent `<script>` tags to see.
1899
- const _SKIP_SYNC = new Set([
1900
- 'self', 'window', 'document', 'top', 'parent', 'frames', 'opener',
1901
- 'globalThis', 'happyDOM'
1902
- ]);
1903
- // happy-dom does not fire DOMContentLoaded / load on its own when we
1904
- // assign innerHTML and replay scripts in a single tick. Frameworks like
1905
- // jQuery hold their initialisation behind `$(document).ready(...)`, so
1906
- // we synthesise both events at the end of script setup.
1907
- function fireReadyEvents() {
1908
- if (!currentWindow || !currentDocument) return;
1909
- try {
1910
- currentDocument.dispatchEvent(new currentWindow.Event('DOMContentLoaded', {bubbles: true, cancelable: false}));
1911
- } catch (_) {}
1912
- try {
1913
- currentWindow.dispatchEvent(new currentWindow.Event('load', {bubbles: false, cancelable: false}));
1914
- } catch (_) {}
1915
- }
1916
-
1917
- function syncWindowGlobals() {
1918
- if (!currentWindow) return;
1919
- for (const key of Object.keys(currentWindow)) {
1920
- if (_SKIP_SYNC.has(key)) continue;
1921
- if (key.startsWith('_')) continue;
1922
- try {
1923
- if (globalThis[key] === undefined && currentWindow[key] !== undefined) {
1924
- globalThis[key] = currentWindow[key];
1925
- }
1926
- } catch (_) {}
1927
- }
1928
- }
1929
-
1930
- function cssEscape(s) {
1931
- return String(s).replace(/(["\\\]\[\(\)])/g, '\\$1');
1932
- }
1933
-
1934
- // happy-dom's selector parser does not implement form-state pseudo
1935
- // classes like `:enabled`, `:read-write`, `:optional`, treating them
1936
- // as no-match. Rewrite them to their attribute-based equivalents,
1937
- // which are close enough for Capybara's form interaction selectors.
1938
- // We deliberately do not touch pseudos happy-dom does support
1939
- // (`:disabled`, `:checked`, `:not`, `:nth-child`, etc.).
1940
- const PSEUDO_REWRITES = [
1941
- [/:enabled\b/g, ':not([disabled])'],
1942
- [/:read-write\b/g, ':not([readonly])'],
1943
- [/:read-only\b/g, '[readonly]'],
1944
- [/:optional\b/g, ':not([required])']
1945
- ];
1946
- // happy-dom's parser also rejects CSS identifier escape sequences in
1947
- // `#id` / `.class` (e.g. `#\31 foo` for `#1foo`). Decode the escapes
1948
- // and rewrite to attribute selectors, which it accepts.
1949
- const CSS_IDENT_RE = /(?:\\[0-9a-fA-F]{1,6}\s?|\\.|[\w-])+/.source;
1950
- const ESCAPED_ID_RE = new RegExp('#(' + CSS_IDENT_RE + ')', 'g');
1951
- const ESCAPED_CLASS_RE = new RegExp('\\.(' + CSS_IDENT_RE + ')', 'g');
1952
- function unescapeIdent(s) {
1953
- return s.replace(/\\([0-9a-fA-F]{1,6})\s?|\\(.)/g, (_m, hex, ch) =>
1954
- hex ? String.fromCodePoint(parseInt(hex, 16)) : ch);
1955
- }
1956
- function rewriteCSS(css) {
1957
- let out = String(css || '');
1958
- for (const [re, repl] of PSEUDO_REWRITES) out = out.replace(re, repl);
1959
- if (out.indexOf('\\') !== -1) {
1960
- out = out.replace(ESCAPED_ID_RE, (m, ident) =>
1961
- ident.indexOf('\\') === -1 ? m : '[id="' + unescapeIdent(ident).replace(/"/g, '\\"') + '"]');
1962
- out = out.replace(ESCAPED_CLASS_RE, (m, ident) =>
1963
- ident.indexOf('\\') === -1 ? m : '[class~="' + unescapeIdent(ident).replace(/"/g, '\\"') + '"]');
1964
- }
1965
- return out;
1966
- }
1967
-
1968
- function stripDoctype(html) {
1969
- return String(html || '').replace(/^\s*<!doctype[^>]*>\s*/i, '');
1970
- }
1971
-
1972
- // Capybara emits XPath; happy-dom has no document.evaluate. The fallback
1973
- // covers a useful subset by translating to CSS, with a hand-rolled text()
1974
- // and contains() filter on top.
1975
- // history.pushState / replaceState change window.location.href without a
1976
- // real fetch. Wrap them to set a flag the Ruby driver inspects so it
1977
- // does not turn the URL change into a Rack request.
1978
- let _historyPushed = false;
1979
- function installHistoryTracking(win) {
1980
- if (!win.history) return;
1981
- const wrap = (name) => {
1982
- const orig = win.history[name];
1983
- if (typeof orig !== 'function' || orig.__csim_wrapped) return;
1984
- const wrapped = function () {
1985
- _historyPushed = true;
1986
- return orig.apply(win.history, arguments);
1987
- };
1988
- wrapped.__csim_wrapped = true;
1989
- win.history[name] = wrapped;
1990
- };
1991
- wrap('pushState');
1992
- wrap('replaceState');
1993
- }
1994
-
1995
- // Capybara's xpath gem produces a small set of stable XPath shapes for
1996
- // its built-in selectors (link, button, link_or_button, select, option,
1997
- // field, fillable_field, …). Even after the facade fix fontoxpath spends
1998
- // ~4ms on each of these — partly because the predicates redundantly walk
1999
- // the whole document for `//label[text=X]/@for`. When we recognise the
2000
- // pattern we can skip fontoxpath entirely and resolve the match through
2001
- // happy-dom's native `getElementById` + `querySelectorAll('label[for]')`,
2002
- // which is O(label-count) instead of O(elements × labels).
2003
- //
2004
- // Returns an array of nodes on a hit, or `null` on miss (caller falls
2005
- // back to fontoxpath).
2006
- function tryFastXPath(xpath, root) {
2007
- if (typeof xpath !== 'string') return null;
2008
- // Capybara appends `(./@<test_id> = 'X')` to every locator predicate when
2009
- // `Capybara.test_id` is set. The aria-label clause is the standard last
2010
- // attr; a `)) or (` immediately after means a 6th (test_id) clause was
2011
- // injected. Bail to fontoxpath in that case — falling through is just
2012
- // slower, not broken.
2013
- if (/aria-label\s*(?:=|,) '[^']+'\)\)\s+or\s+\(/.test(xpath)) return null;
2014
- const ms = NORM_TEXT_RX.exec(xpath);
2015
- if (ms) return findByNormText(root, ms[1], ms[3], ms[2] === 'contains');
2016
- const ll = LOCATE_FIELD_RX.exec(xpath);
2017
- if (ll) return findByLabel(root, ll[1], ll[2]);
2018
- const lb = LINK_OR_BUTTON_RX.exec(xpath);
2019
- if (lb) return findLinkOrButton(root, lb[1], lb[2] === 'contains');
2020
- return null;
2021
- }
2022
-
2023
- // .//TAG[(normalize-space(string(.)) = 'X')]
2024
- // .//TAG[contains(normalize-space(string(.)), 'X')]
2025
- // .//TAG[normalize-space(string(.)) = 'X'] (no outer parens — rare)
2026
- const NORM_TEXT_RX = /^\.\/\/([a-z][\w-]*)\[\(?(contains)?\(?normalize-space\(string\(\.\)\)(?:, ?| = )'((?:[^'\\]|\\.)*)'\)?\)?\]$/;
2027
-
2028
- function findByNormText(root, tag, text, isContains) {
2029
- const out = [];
2030
- const norm = (s) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim();
2031
- for (const el of root.querySelectorAll(tag)) {
2032
- const t = norm(el.textContent);
2033
- if (isContains ? t.indexOf(text) >= 0 : t === text) out.push(el);
2034
- }
2035
- return out;
2036
- }
2037
-
2038
- // .//select[((((@id = 'X') or (@name = 'X')) or (@placeholder = 'X'))
2039
- // or (@id = //label[(normalize-space(string(.)) = 'X')]/@for))
2040
- // or (@aria-label = 'X'))]
2041
- // | .//label[(normalize-space(string(.)) = 'X')]//.//select
2042
- //
2043
- // Capybara's `:select` selector. The same locator value is repeated in
2044
- // every clause, so capture once and verify.
2045
- const LOCATE_FIELD_RX = new RegExp(
2046
- '^\\.\\/\\/(select|textarea)\\[\\(\\(\\(\\(\\(\\.\\/@id = \'([^\']+)\'\\)' +
2047
- ' or \\(\\.\\/@name = \'\\2\'\\)\\)' +
2048
- ' or \\(\\.\\/@placeholder = \'\\2\'\\)\\)' +
2049
- ' or \\(\\.\\/@id = \\/\\/label\\[\\(normalize-space\\(string\\(\\.\\)\\) = \'\\2\'\\)\\]\\/@for\\)\\)' +
2050
- ' or \\(\\.\\/@aria-label = \'\\2\'\\)\\)\\]' +
2051
- '(?: \\| \\.\\/\\/label\\[\\(normalize-space\\(string\\(\\.\\)\\) = \'\\2\'\\)\\]\\/\\/\\.\\/\\/\\1)?$'
2052
- );
2053
-
2054
- function findByLabel(root, tag, value) {
2055
- const norm = (s) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim();
2056
- const matches = new Set();
2057
- for (const el of root.querySelectorAll(tag)) {
2058
- if (
2059
- el.getAttribute('id') === value ||
2060
- el.getAttribute('name') === value ||
2061
- el.getAttribute('placeholder') === value ||
2062
- el.getAttribute('aria-label') === value
2063
- ) matches.add(el);
2064
- }
2065
- // <label for="…">text</label> — anywhere in the *document*, then resolve
2066
- // its target by id and verify the target is inside `root`.
2067
- const doc = root.ownerDocument || (root.nodeType === 9 ? root : currentDocument);
2068
- if (doc) {
2069
- for (const label of doc.querySelectorAll('label[for]')) {
2070
- if (norm(label.textContent) !== value) continue;
2071
- const target = doc.getElementById(label.getAttribute('for'));
2072
- if (
2073
- target &&
2074
- (target.tagName || '').toLowerCase() === tag &&
2075
- (root === doc.documentElement || root === doc || root.contains(target))
2076
- ) matches.add(target);
2077
- }
2078
- }
2079
- // <label>text<select/></label> — descendant of root.
2080
- for (const label of root.querySelectorAll('label')) {
2081
- if (norm(label.textContent) !== value) continue;
2082
- for (const child of label.querySelectorAll(tag)) matches.add(child);
2083
- }
2084
- return Array.from(matches);
2085
- }
2086
-
2087
- function unescXp(s) { return s.replace(/\\(.)/g, '$1'); }
2088
-
2089
- // .//a[./@href][((((@id = 'X') or [normalize-space(string(.)) = 'X' | contains(...)])
2090
- // or [@title = 'X' | contains(...)]) or .//img[(@alt = 'X' | contains)]) or [@aria-label = 'X' | contains])]
2091
- // | .//input[type=submit/...][...] | .//label[X]//*[input-or-button]
2092
- // | .//input[type=image][...] | .//button[...] | .//label[X]//* (dup) | .//input[image] (dup)
2093
- //
2094
- // Capybara's `:link_or_button` selector — by far the most common shape we
2095
- // see (~25% of all xpath calls in a Rails system spec). The detector
2096
- // anchors on the link clause (5 OR'd predicates ending with @aria-label)
2097
- // and reads off whether the suite is in exact-match or contains mode.
2098
- // If the last clause isn't aria-label (e.g. Capybara.test_id is set, or
2099
- // enable_aria_label is off), we bail and fontoxpath handles it.
2100
- const LINK_OR_BUTTON_RX = new RegExp(
2101
- '^\\.\\/\\/a\\[\\.\\/@href\\]' +
2102
- '\\[\\(\\(\\(\\(\\(' +
2103
- '\\.\\/@id = \'([^\']+)\'\\)' +
2104
- ' or (?:\\(normalize-space\\(string\\(\\.\\)\\) = \'\\1\'\\)|(contains)\\(normalize-space\\(string\\(\\.\\)\\), \'\\1\'\\))\\)' +
2105
- ' or (?:\\(\\.\\/@title = \'\\1\'\\)|contains\\(\\.\\/@title, \'\\1\'\\))\\)' +
2106
- ' or \\.\\/\\/img\\[(?:\\(\\.\\/@alt = \'\\1\'\\)|contains\\(\\.\\/@alt, \'\\1\'\\))\\]\\)' +
2107
- ' or (?:\\(\\.\\/@aria-label = \'\\1\'\\)|contains\\(\\.\\/@aria-label, \'\\1\'\\))\\)' +
2108
- '\\]' +
2109
- ' \\| \\.\\/\\/input\\['
2110
- );
2111
-
2112
- function findLinkOrButton(root, value, isContains) {
2113
- const norm = (s) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim();
2114
- const eq = (s) => isContains ? (s != null && String(s).indexOf(value) >= 0) : (s === value);
2115
- const eqText = (s) => isContains ? norm(s).indexOf(value) >= 0 : norm(s) === value;
2116
- const matches = new Set();
2117
-
2118
- // <a href> by id (always exact), text/title/aria-label, child <img alt>
2119
- for (const a of root.querySelectorAll('a[href]')) {
2120
- if (
2121
- a.getAttribute('id') === value ||
2122
- eqText(a.textContent) ||
2123
- eq(a.getAttribute('title')) ||
2124
- eq(a.getAttribute('aria-label'))
2125
- ) { matches.add(a); continue; }
2126
- for (const img of a.querySelectorAll('img')) {
2127
- if (eq(img.getAttribute('alt'))) { matches.add(a); break; }
2128
- }
2129
- }
2130
-
2131
- // <input type=submit/reset/image/button>
2132
- for (const input of root.querySelectorAll(
2133
- 'input[type=submit],input[type=reset],input[type=image],input[type=button]'
2134
- )) {
2135
- if (
2136
- input.getAttribute('id') === value ||
2137
- input.getAttribute('name') === value ||
2138
- eq(input.getAttribute('value')) ||
2139
- eq(input.getAttribute('title')) ||
2140
- eq(input.getAttribute('aria-label'))
2141
- ) matches.add(input);
2142
- }
2143
-
2144
- // <input type=image> alt / aria-label
2145
- for (const input of root.querySelectorAll('input[type=image]')) {
2146
- if (eq(input.getAttribute('alt')) || eq(input.getAttribute('aria-label'))) matches.add(input);
2147
- }
2148
-
2149
- // <button> id/name/value/title/aria-label/text/child <img alt>
2150
- for (const btn of root.querySelectorAll('button')) {
2151
- if (
2152
- btn.getAttribute('id') === value ||
2153
- btn.getAttribute('name') === value ||
2154
- eq(btn.getAttribute('value')) ||
2155
- eq(btn.getAttribute('title')) ||
2156
- eq(btn.getAttribute('aria-label')) ||
2157
- eqText(btn.textContent)
2158
- ) { matches.add(btn); continue; }
2159
- for (const img of btn.querySelectorAll('img')) {
2160
- if (eq(img.getAttribute('alt'))) { matches.add(btn); break; }
2161
- }
2162
- }
2163
-
2164
- // <label>text<input|button/></label>
2165
- for (const label of root.querySelectorAll('label')) {
2166
- if (!eqText(label.textContent)) continue;
2167
- for (const c of label.querySelectorAll(
2168
- 'input[type=submit],input[type=reset],input[type=image],input[type=button],button'
2169
- )) matches.add(c);
2170
- }
2171
-
2172
- // <label for="…">text</label> (anywhere in the document) → input/button
2173
- const doc = root.ownerDocument || (root.nodeType === 9 ? root : currentDocument);
2174
- if (doc && doc.querySelectorAll) {
2175
- for (const label of doc.querySelectorAll('label[for]')) {
2176
- if (!eqText(label.textContent)) continue;
2177
- const target = doc.getElementById(label.getAttribute('for'));
2178
- if (!target) continue;
2179
- const tag = (target.tagName || '').toLowerCase();
2180
- const type = ((target.getAttribute && target.getAttribute('type')) || '').toLowerCase();
2181
- const clickable = (tag === 'input' && /^(submit|reset|image|button)$/.test(type)) || tag === 'button';
2182
- if (!clickable) continue;
2183
- if (root === doc.documentElement || root === doc || root.contains(target)) matches.add(target);
2184
- }
2185
- }
2186
-
2187
- return Array.from(matches);
2188
- }
2189
-
2190
- function findViaXPathFallback(xpath, root) {
2191
- if (typeof xpath !== 'string') return [];
2192
- const m = xpath.match(/^\.?\/\/(?:descendant::)?([A-Za-z*][\w*]*)(?:\[(.+)\])?$/);
2193
- if (!m) {
2194
- // Last resort: take the whole thing as a tag name.
2195
- try {
2196
- const els = root.querySelectorAll(xpath);
2197
- const ids = [];
2198
- for (const el of els) ids.push(track(el));
2199
- return ids;
2200
- } catch (_) { return []; }
2201
- }
2202
- const tag = m[1];
2203
- const els = root.querySelectorAll(tag);
2204
- const ids = [];
2205
- for (const el of els) ids.push(track(el));
2206
- return ids;
2207
- }
2208
- })();