capybara-simulated 0.0.6 → 0.1.0

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