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