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.
@@ -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 #3 (narrower than originally diagnosed; verified 2026-05-04 against build 6005) ──
39
- // Native dispatch DOES propagate to ancestors with event.target preserved but if
40
- // ANY listener throws, Lightpanda halts the whole dispatch path (incl. the bubble
41
- // phase) instead of reporting the exception and continuing per DOM §2.9 step 4.
42
- // Stimulus / Turbo Drive listeners that throw silently swallow document-level
43
- // delegation. Workaround: catch the propagated JsException and re-walk parents
44
- // manually, spoofing event.target via Object.defineProperty for delegated handlers.
45
- (function patchDispatch() {
46
- if (!window.EventTarget || !EventTarget.prototype.dispatchEvent) return;
47
- var orig = EventTarget.prototype.dispatchEvent;
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
- EventTarget.prototype.dispatchEvent = function (event) {
50
- try {
51
- return orig.call(this, event);
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
- var originalTarget = this;
56
- try {
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
- var node = this.parentNode;
64
- while (node) {
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
- Object.defineProperty(event, "currentTarget", {
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
- return !event.defaultPrevented;
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
- # For submit buttons (`<button type=submit>`, `<input type=submit>`,
381
- # `<input type=image>`): route through `form.requestSubmit(this)` so the
382
- # browser dispatches a real `SubmitEvent` with submitter set, honors the
383
- # submitter's `formaction` / `formmethod` / `formenctype`, and includes
384
- # the submitter's name/value in the form data. A manual
385
- # `dispatchEvent(new Event('submit'))` + `form.submit()` would lose all of
386
- # that and break Turbo Drive / Hotwire form handling. We can't rely on
387
- # the synthetic click's default action because synthetic events don't
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 = true;
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
- var type = (this.type || '').toLowerCase();
400
- var isSubmitButton =
401
- (tag === 'BUTTON' && (type === 'submit' || type === '')) ||
402
- (tag === 'INPUT' && (type === 'submit' || type === 'image'));
403
- if (isSubmitButton && this.form) {
404
- this.form.requestSubmit(this);
405
- } else if (tag === 'A' && this.href && this.target !== '_blank') {
406
- window.location.href = this.href;
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
- # Build 6051 = main HEAD d360fcc0 (2026-05-04); ships in nightly
28
- # published 2026-05-05 ~03:30 UTC for all four platforms.
29
- MINIMUM_NIGHTLY_BUILD = Gem::Version.new("6051")
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
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Capybara
4
4
  module Lightpanda
5
- VERSION = "0.2.1"
5
+ VERSION = "0.3.0"
6
6
  end
7
7
  end
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.2.1
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.6
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: []