capybara-lightpanda 0.2.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +46 -0
- data/lib/capybara/lightpanda/browser.rb +242 -21
- data/lib/capybara/lightpanda/javascripts/index.js +0 -759
- data/lib/capybara/lightpanda/javascripts/polyfills.js +165 -34
- data/lib/capybara/lightpanda/node.rb +28 -28
- data/lib/capybara/lightpanda/process.rb +13 -6
- data/lib/capybara/lightpanda/version.rb +1 -1
- metadata +2 -2
|
@@ -5,6 +5,78 @@
|
|
|
5
5
|
(function () {
|
|
6
6
|
"use strict";
|
|
7
7
|
|
|
8
|
+
// ── Bug #7 — HTMLFormElement / HTMLButtonElement / HTMLInputElement form-* IDL gaps ──
|
|
9
|
+
// Lightpanda doesn't expose `form.enctype`, `form.method`, `form.action`,
|
|
10
|
+
// `form.target`, nor the submitter-side `formEnctype` / `formMethod` /
|
|
11
|
+
// `formAction` / `formTarget` overrides. Per WHATWG HTML these must always
|
|
12
|
+
// return a string (with the spec's missing-value default) so consumers can
|
|
13
|
+
// call `.toLowerCase()` etc. directly. Turbo's `FormSubmission` constructor
|
|
14
|
+
// does exactly that and crashes with `Cannot read properties of undefined
|
|
15
|
+
// (reading 'toLowerCase')` when it touches enctype.
|
|
16
|
+
//
|
|
17
|
+
// Polyfill strategy: only define the IDL getter when it's missing on the
|
|
18
|
+
// prototype, so a future Lightpanda nightly that adds native support wins
|
|
19
|
+
// automatically. Each getter falls back to the underlying attribute, with
|
|
20
|
+
// the spec's default if the attribute is absent. For submitter overrides
|
|
21
|
+
// (formEnctype, formMethod, etc.) we return the empty string when the
|
|
22
|
+
// override attribute is unset — Turbo and Hotwire all use the
|
|
23
|
+
// `submitter.formX || form.X` idiom, which resolves correctly when the
|
|
24
|
+
// submitter side returns "".
|
|
25
|
+
(function patchFormIDL() {
|
|
26
|
+
var ENCTYPE_VALUES = ["application/x-www-form-urlencoded", "multipart/form-data", "text/plain"];
|
|
27
|
+
function normEnctype(v) {
|
|
28
|
+
if (!v) return "application/x-www-form-urlencoded";
|
|
29
|
+
v = String(v).toLowerCase();
|
|
30
|
+
return ENCTYPE_VALUES.indexOf(v) >= 0 ? v : "application/x-www-form-urlencoded";
|
|
31
|
+
}
|
|
32
|
+
function normMethod(v) {
|
|
33
|
+
if (!v) return "get";
|
|
34
|
+
v = String(v).toLowerCase();
|
|
35
|
+
return (v === "post" || v === "dialog") ? v : "get";
|
|
36
|
+
}
|
|
37
|
+
function defineIfMissing(proto, name, getter) {
|
|
38
|
+
if (!proto || name in proto) return;
|
|
39
|
+
try { Object.defineProperty(proto, name, { configurable: true, enumerable: true, get: getter }); } catch (_) {}
|
|
40
|
+
}
|
|
41
|
+
if (typeof HTMLFormElement !== "undefined") {
|
|
42
|
+
var fp = HTMLFormElement.prototype;
|
|
43
|
+
defineIfMissing(fp, "enctype", function () { return normEnctype(this.getAttribute("enctype")); });
|
|
44
|
+
defineIfMissing(fp, "method", function () { return normMethod(this.getAttribute("method")); });
|
|
45
|
+
defineIfMissing(fp, "action", function () {
|
|
46
|
+
var a = this.getAttribute("action");
|
|
47
|
+
if (a == null || a === "") return (this.ownerDocument && this.ownerDocument.URL) || "";
|
|
48
|
+
try { return new URL(a, (this.ownerDocument && this.ownerDocument.URL) || undefined).href; }
|
|
49
|
+
catch (_) { return a; }
|
|
50
|
+
});
|
|
51
|
+
defineIfMissing(fp, "target", function () { return this.getAttribute("target") || ""; });
|
|
52
|
+
}
|
|
53
|
+
function patchSubmitter(Ctor) {
|
|
54
|
+
if (typeof Ctor === "undefined") return;
|
|
55
|
+
var p = Ctor.prototype;
|
|
56
|
+
// Empty string is the spec's missing-value default for the submitter-side
|
|
57
|
+
// IDL attrs — keep Turbo's `submitter.formX || form.X` idiom flowing
|
|
58
|
+
// through to the form's value.
|
|
59
|
+
defineIfMissing(p, "formEnctype", function () {
|
|
60
|
+
var v = this.getAttribute("formenctype");
|
|
61
|
+
return v == null ? "" : normEnctype(v);
|
|
62
|
+
});
|
|
63
|
+
defineIfMissing(p, "formMethod", function () {
|
|
64
|
+
var v = this.getAttribute("formmethod");
|
|
65
|
+
return v == null ? "" : normMethod(v);
|
|
66
|
+
});
|
|
67
|
+
defineIfMissing(p, "formAction", function () {
|
|
68
|
+
var a = this.getAttribute("formaction");
|
|
69
|
+
if (a == null || a === "") return "";
|
|
70
|
+
try { return new URL(a, (this.ownerDocument && this.ownerDocument.URL) || undefined).href; }
|
|
71
|
+
catch (_) { return a; }
|
|
72
|
+
});
|
|
73
|
+
defineIfMissing(p, "formTarget", function () { return this.getAttribute("formtarget") || ""; });
|
|
74
|
+
defineIfMissing(p, "formNoValidate", function () { return this.hasAttribute("formnovalidate"); });
|
|
75
|
+
}
|
|
76
|
+
patchSubmitter(typeof HTMLButtonElement !== "undefined" ? HTMLButtonElement : null);
|
|
77
|
+
patchSubmitter(typeof HTMLInputElement !== "undefined" ? HTMLInputElement : null);
|
|
78
|
+
})();
|
|
79
|
+
|
|
8
80
|
// ── Bug #4 — HTMLDialogElement.{showModal, show, close} non implémentés ──
|
|
9
81
|
// https://html.spec.whatwg.org/multipage/interactive-elements.html#the-dialog-element
|
|
10
82
|
if (typeof HTMLDialogElement !== "undefined") {
|
|
@@ -35,47 +107,106 @@
|
|
|
35
107
|
}
|
|
36
108
|
}
|
|
37
109
|
|
|
38
|
-
// ── Bug #
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
// phase
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
110
|
+
// ── Bug #8 (added 2026-05-05) — sync remove + re-add lost across dispatch phases ──
|
|
111
|
+
// WHATWG DOM specifies that each phase of a dispatch snapshots `currentTarget`'s
|
|
112
|
+
// listener list AT THAT PHASE. Listeners removed and re-added during the capture
|
|
113
|
+
// phase correctly appear in the bubble-phase snapshot in Chrome/Cuprite. Lightpanda
|
|
114
|
+
// takes the snapshot once at dispatch start, so a remove+add during capture loses
|
|
115
|
+
// the listener for the in-flight bubble. This breaks Turbo's `FormSubmitObserver`
|
|
116
|
+
// pattern, where `submitCaptured` does `remove+add` on its own `submitBubbled` to
|
|
117
|
+
// ensure that handler runs LAST in bubble — under Lightpanda, `submitBubbled` is
|
|
118
|
+
// dropped entirely and Turbo never intercepts form submissions.
|
|
119
|
+
//
|
|
120
|
+
// Native form submission via `requestSubmit()` doesn't route through JS-exposed
|
|
121
|
+
// `dispatchEvent`, so we can't detect "in-flight dispatch" by patching that. The
|
|
122
|
+
// workaround instead targets the remove+add idiom directly: defer every
|
|
123
|
+
// `removeEventListener` to a microtask. When `addEventListener` runs in the same
|
|
124
|
+
// synchronous turn with the SAME (target, type, fn, capture), we cancel the
|
|
125
|
+
// pending remove — the listener was never actually unregistered, so the in-flight
|
|
126
|
+
// bubble snapshot still contains it. Genuine removes (no matching add follows)
|
|
127
|
+
// happen at end-of-tick, indistinguishable from the unpatched behavior modulo
|
|
128
|
+
// tick boundary.
|
|
129
|
+
//
|
|
130
|
+
// Scope: only capture-phase `submit` listeners, the exact tuple Turbo Drive
|
|
131
|
+
// uses. This avoids changing observable DOM semantics for arbitrary
|
|
132
|
+
// remove/dispatch sequences elsewhere on the page — synchronous remove +
|
|
133
|
+
// dispatch outside this narrow tuple still fires the native `removeEventListener`
|
|
134
|
+
// path immediately.
|
|
135
|
+
//
|
|
136
|
+
// Trade-offs (within the narrowed scope):
|
|
137
|
+
// • Code that removes a capture-phase submit listener and then reads
|
|
138
|
+
// listener state synchronously before the microtask flush will see it
|
|
139
|
+
// as still-attached. No known framework does this on `submit`.
|
|
140
|
+
// • If something removes capture-phase submit listener X then adds Y of
|
|
141
|
+
// the same type/capture before the flush, the add happens immediately
|
|
142
|
+
// but the deferred remove fires after, removing X *after* Y is
|
|
143
|
+
// registered. Y persists, X is gone. Same end state as without the
|
|
144
|
+
// polyfill, just reordered in time.
|
|
145
|
+
(function patchListenerLifecycle() {
|
|
146
|
+
if (!window.EventTarget || !EventTarget.prototype.addEventListener) return;
|
|
147
|
+
if (typeof Promise === "undefined") return; // need microtasks
|
|
148
|
+
var origAdd = EventTarget.prototype.addEventListener;
|
|
149
|
+
var origRemove = EventTarget.prototype.removeEventListener;
|
|
150
|
+
|
|
151
|
+
function captureFlag(opts) {
|
|
152
|
+
return opts === true || (opts && typeof opts === "object" && opts.capture === true);
|
|
153
|
+
}
|
|
48
154
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
} catch (err) {
|
|
53
|
-
if (!event || !event.bubbles || !this.parentNode) throw err;
|
|
155
|
+
function inScope(type, capture) {
|
|
156
|
+
return type === "submit" && capture === true;
|
|
157
|
+
}
|
|
54
158
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
Object.defineProperty(event, "target", {
|
|
58
|
-
value: originalTarget,
|
|
59
|
-
configurable: true,
|
|
60
|
-
});
|
|
61
|
-
} catch (_) { /* target not redefinable — continue anyway */ }
|
|
159
|
+
var pending = []; // [{ target, type, fn, capture, cancelled }]
|
|
160
|
+
var flushScheduled = false;
|
|
62
161
|
|
|
63
|
-
|
|
64
|
-
|
|
162
|
+
function scheduleFlush() {
|
|
163
|
+
if (flushScheduled) return;
|
|
164
|
+
flushScheduled = true;
|
|
165
|
+
Promise.resolve().then(function () {
|
|
166
|
+
flushScheduled = false;
|
|
167
|
+
var queue = pending;
|
|
168
|
+
pending = [];
|
|
169
|
+
for (var i = 0; i < queue.length; i++) {
|
|
170
|
+
var r = queue[i];
|
|
171
|
+
if (r.cancelled) continue;
|
|
65
172
|
try {
|
|
66
|
-
|
|
67
|
-
value: node,
|
|
68
|
-
configurable: true,
|
|
69
|
-
});
|
|
173
|
+
origRemove.call(r.target, r.type, r.fn, r.capture);
|
|
70
174
|
} catch (_) {}
|
|
71
|
-
try {
|
|
72
|
-
orig.call(node, event);
|
|
73
|
-
} catch (_) { /* ignore intermediate crashes; keep propagating */ }
|
|
74
|
-
if (event.cancelBubble) break;
|
|
75
|
-
node = node.parentNode || node.host || null;
|
|
76
175
|
}
|
|
77
|
-
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
EventTarget.prototype.removeEventListener = function (type, fn, opts) {
|
|
180
|
+
var capture = captureFlag(opts);
|
|
181
|
+
if (!fn || !inScope(type, capture)) return origRemove.call(this, type, fn, opts);
|
|
182
|
+
pending.push({
|
|
183
|
+
target: this,
|
|
184
|
+
type: type,
|
|
185
|
+
fn: fn,
|
|
186
|
+
capture: capture,
|
|
187
|
+
cancelled: false,
|
|
188
|
+
});
|
|
189
|
+
scheduleFlush();
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
EventTarget.prototype.addEventListener = function (type, fn, opts) {
|
|
193
|
+
if (!fn) return origAdd.call(this, type, fn, opts);
|
|
194
|
+
var capture = captureFlag(opts);
|
|
195
|
+
if (inScope(type, capture)) {
|
|
196
|
+
// Cancel a pending remove for the same tuple (LIFO so the most recent
|
|
197
|
+
// pending remove wins for the remove-then-add idiom). Always call
|
|
198
|
+
// native add too: addEventListener is idempotent per DOM spec, and
|
|
199
|
+
// skipping risks losing the listener on a later unmatched remove.
|
|
200
|
+
for (var i = pending.length - 1; i >= 0; i--) {
|
|
201
|
+
var r = pending[i];
|
|
202
|
+
if (r.cancelled) continue;
|
|
203
|
+
if (r.target === this && r.type === type && r.fn === fn && r.capture === capture) {
|
|
204
|
+
r.cancelled = true;
|
|
205
|
+
break;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
78
208
|
}
|
|
209
|
+
return origAdd.call(this, type, fn, opts);
|
|
79
210
|
};
|
|
80
211
|
})();
|
|
81
212
|
})();
|
|
@@ -237,6 +237,8 @@ module Capybara
|
|
|
237
237
|
|
|
238
238
|
def backend_node_id
|
|
239
239
|
@backend_node_id ||= driver.browser.backend_node_id(@remote_object_id)
|
|
240
|
+
rescue BrowserError
|
|
241
|
+
nil
|
|
240
242
|
end
|
|
241
243
|
|
|
242
244
|
private
|
|
@@ -363,13 +365,6 @@ module Capybara
|
|
|
363
365
|
end
|
|
364
366
|
end
|
|
365
367
|
|
|
366
|
-
# Native `this.click()` reaches all ancestors on the happy path, but if any
|
|
367
|
-
# listener throws (Stimulus / Turbo edge cases) Lightpanda halts dispatch
|
|
368
|
-
# instead of reporting the exception per DOM §2.9 step 4 (see UPSTREAM_BUGS.md
|
|
369
|
-
# Bug #3). Dispatching via JS routes through `polyfills.js`'s patchDispatch
|
|
370
|
-
# IIFE, which catches the throw and re-walks parents manually so document-
|
|
371
|
-
# level delegated handlers still see the event.
|
|
372
|
-
#
|
|
373
368
|
# We dispatch a `MouseEvent` (not a generic `Event`) because Turbo's link
|
|
374
369
|
# and form interceptors guard with `event instanceof MouseEvent` before
|
|
375
370
|
# they consider intercepting — a synthetic `Event('click')` is silently
|
|
@@ -377,33 +372,38 @@ module Capybara
|
|
|
377
372
|
# the manual default action below, which does a full-page navigation
|
|
378
373
|
# instead of a frame swap.
|
|
379
374
|
#
|
|
380
|
-
#
|
|
381
|
-
# `<
|
|
382
|
-
#
|
|
383
|
-
#
|
|
384
|
-
#
|
|
385
|
-
#
|
|
386
|
-
#
|
|
387
|
-
# the
|
|
388
|
-
# trigger the implicit form-submission default action per DOM spec.
|
|
375
|
+
# Submit buttons (`<input type=submit>`, `<input type=image>`,
|
|
376
|
+
# `<button type=submit>`): native click on the dispatched MouseEvent
|
|
377
|
+
# already runs the form-submission default action via Frame.submitForm
|
|
378
|
+
# in Lightpanda (extension of PR #2312 for image to all submit
|
|
379
|
+
# variants). A manual `form.requestSubmit(this)` here would fire a
|
|
380
|
+
# second SubmitEvent and double-submit the form — observed on nightly
|
|
381
|
+
# 6167 as duplicate `turbo:submit-start` events; the first request
|
|
382
|
+
# gets aborted by the second and Turbo never renders the response.
|
|
389
383
|
CLICK_JS = <<~JS
|
|
390
384
|
function() {
|
|
391
385
|
var EventCtor = (typeof MouseEvent !== 'undefined') ? MouseEvent : Event;
|
|
392
386
|
var clickEvt = new EventCtor('click', { bubbles: true, cancelable: true });
|
|
393
|
-
var notCancelled =
|
|
394
|
-
try {
|
|
395
|
-
notCancelled = this.dispatchEvent(clickEvt);
|
|
396
|
-
} catch (e) { /* patchDispatch in polyfills.js rescues bubble phase */ }
|
|
387
|
+
var notCancelled = this.dispatchEvent(clickEvt);
|
|
397
388
|
if (!notCancelled || clickEvt.defaultPrevented) return;
|
|
398
389
|
var tag = this.tagName;
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
390
|
+
if (tag === 'A' && this.href && this.target !== '_blank') {
|
|
391
|
+
// Same-document fragment-only navigation: just update hash (or do
|
|
392
|
+
// nothing if identical). Mirrors Chrome — assigning location.href
|
|
393
|
+
// to a same-document URL on Lightpanda triggers a real navigation
|
|
394
|
+
// tick that cancels pending setTimeout callbacks and clears form
|
|
395
|
+
// values, which breaks any test driving DOM updates from a click
|
|
396
|
+
// handler on `<a href="#...">`.
|
|
397
|
+
var dest = new URL(this.href, document.baseURI);
|
|
398
|
+
var here = new URL(window.location.href);
|
|
399
|
+
if (dest.origin === here.origin && dest.pathname === here.pathname &&
|
|
400
|
+
dest.search === here.search) {
|
|
401
|
+
if (dest.hash !== here.hash) {
|
|
402
|
+
window.location.hash = dest.hash;
|
|
403
|
+
}
|
|
404
|
+
} else {
|
|
405
|
+
window.location.href = this.href;
|
|
406
|
+
}
|
|
407
407
|
}
|
|
408
408
|
}
|
|
409
409
|
JS
|
|
@@ -8,8 +8,8 @@ module Capybara
|
|
|
8
8
|
READY_PATTERN = /server running.*address\s*=\s*(\d+\.\d+\.\d+\.\d+:\d+)/m
|
|
9
9
|
ADDRESS_IN_USE_PATTERN = /err=AddressInUse/
|
|
10
10
|
|
|
11
|
-
# Floor for the cookie/navigation/redirect/modal/keyboard/css/forms
|
|
12
|
-
# fixes the gem now relies on: PR #2255 (Network.clearBrowserCookies
|
|
11
|
+
# Floor for the cookie/navigation/redirect/modal/keyboard/css/forms/dispatch/
|
|
12
|
+
# xpath/history fixes the gem now relies on: PR #2255 (Network.clearBrowserCookies
|
|
13
13
|
# empty params + Network.getAllCookies), PR #2257
|
|
14
14
|
# (window.location.pathname/.search assignment triggers navigation),
|
|
15
15
|
# PR #2265 (URL fragment inherited across fragment-less redirect),
|
|
@@ -23,10 +23,17 @@ module Capybara
|
|
|
23
23
|
# on labeled control), PR #2286 (HTML constraint validation API:
|
|
24
24
|
# el.validity, validationMessage, checkValidity, reportValidity),
|
|
25
25
|
# PR #2342 (<summary> click toggles parent <details>.open),
|
|
26
|
-
# PR #2352 (HTMLInputElement.pattern + patternMismatch via V8 RegExp)
|
|
27
|
-
#
|
|
28
|
-
#
|
|
29
|
-
|
|
26
|
+
# PR #2352 (HTMLInputElement.pattern + patternMismatch via V8 RegExp),
|
|
27
|
+
# PR #2368 (events: report listener exceptions instead of halting
|
|
28
|
+
# dispatch — lets us drop the polyfills.js patchDispatch IIFE),
|
|
29
|
+
# PR #2289 (Page.getNavigationHistory + Page.navigateToHistoryEntry —
|
|
30
|
+
# lets us drop the history.back()/history.forward() JS workaround in
|
|
31
|
+
# Browser#back / #forward), PR #2305 (XPath 1.0: Document.evaluate,
|
|
32
|
+
# XPathResult, XPathEvaluator, XPathExpression — lets us drop the
|
|
33
|
+
# ~700 LOC XPath polyfill in javascripts/index.js).
|
|
34
|
+
# Build 6109 = main HEAD cfcfe4ee (2026-05-11, after PR #2289 and
|
|
35
|
+
# PR #2305 merges); will ship in nightly published 2026-05-12 ~03:30 UTC.
|
|
36
|
+
MINIMUM_NIGHTLY_BUILD = Gem::Version.new("6109")
|
|
30
37
|
|
|
31
38
|
attr_reader :pid, :ws_url, :version, :nightly_build
|
|
32
39
|
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: capybara-lightpanda
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Navid Emad
|
|
@@ -115,7 +115,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
115
115
|
- !ruby/object:Gem::Version
|
|
116
116
|
version: '0'
|
|
117
117
|
requirements: []
|
|
118
|
-
rubygems_version: 4.0.
|
|
118
|
+
rubygems_version: 4.0.10
|
|
119
119
|
specification_version: 4
|
|
120
120
|
summary: Capybara driver for the Lightpanda headless browser
|
|
121
121
|
test_files: []
|