capybara-lightpanda 0.2.1 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 42246ea44b80c592e6779cf2aa95890c7635fb057b0061064b63bf15004dcde6
4
- data.tar.gz: 63131038538438b32d39d8e46a36005df0306e8daadaa38ff88540874c6c46aa
3
+ metadata.gz: 1cb36e68fb7bcc35abcbae5a3417091eb54444f7b57e161cb44cb6436d243684
4
+ data.tar.gz: 9eff2c5cd278d16ed8ad55d106dca14dab3597a87c3315c46371b98672a35163
5
5
  SHA512:
6
- metadata.gz: 1728491bdd3d3dac24559cc663ee250af16f90d7944d2c27be0b879ddbfc23a2a00181f39970d7622fd1b640449d97c7d373c4f6a414aae9073dc0f3cf92d1f8
7
- data.tar.gz: 47e0408c55b5150347656d8136b6dc993b39d38a051a39efec480e2d913cf799f45c18242fd031bbb6962f79b7a39d63cc2eec8d2c14e2f42f34ed3b029af867
6
+ metadata.gz: 156126b7f57785869aca23b79fe02bc3fe7370cf5b2826999715d5e69794a6b62f8c69bb64601e5e67f935d946e2348274c8ec76d79b674930b239d6b2383677
7
+ data.tar.gz: c73dab9284d0d8e0091794e2f88674951bafc64af2fa2e87200926a60d7d86767f31cd5114d5348af51013fe3b4846f1066d0bdca886a5a1051432673b76ef52
data/CHANGELOG.md CHANGED
@@ -1,5 +1,37 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.2.2] - 2026-05-06
4
+
5
+ > **Update Lightpanda before upgrading.** Requires a nightly build ≥ 6065 (published 2026-05-06). The driver refuses to start against older binaries.
6
+
7
+ ### Fixed
8
+
9
+ - Turbo Drive `<form>` submissions now intercept correctly. Forms inside a
10
+ Hotwire / Turbo Drive page no longer crash on submit, and Turbo's `submit`
11
+ interceptors fire as they should — `click_button`, `find('form').submit`,
12
+ and Enter-key implicit submission all complete end-to-end.
13
+ - `evaluate_script` and `execute_script` calls with top-level `const` / `let`
14
+ no longer collide across calls. Consecutive scripts that each declare the
15
+ same identifier used to fail with `Identifier 'foo' has already been
16
+ declared`; they're now isolated. `execute_script` also now raises on
17
+ JavaScript errors instead of silently swallowing them.
18
+ - Same-document fragment-only `<a href="#…">` clicks update the URL hash
19
+ instead of triggering a real navigation. Tests that drive DOM updates from
20
+ an anchor click no longer lose pending `setTimeout` callbacks or have form
21
+ values cleared from under them.
22
+ - `body` returns an empty string rather than crashing during the brief window
23
+ after `reset_session!` when the new session has a target but no document yet.
24
+ - Stale element references during cross-document navigation now resolve to
25
+ `nil` internally instead of bubbling a browser error up to your test,
26
+ letting Capybara's automatic-reload pick a fresh element.
27
+
28
+ ### Internal
29
+
30
+ - One internal polyfill removed: Lightpanda now matches the spec when a DOM
31
+ event listener throws (a throwing listener no longer halts the rest of the
32
+ bubble walk), so the gem doesn't need to compensate. No code change required
33
+ on your end.
34
+
3
35
  ## [0.2.1] - 2026-05-05
4
36
 
5
37
  ### Fixed
@@ -238,7 +238,11 @@ module Capybara
238
238
  end
239
239
 
240
240
  def body
241
- evaluate("document.documentElement.outerHTML")
241
+ # Guard against the brief window after a fresh BrowserContext / target
242
+ # is created where the V8 context exists but `document.documentElement`
243
+ # is still null. Hit by Capybara's `#reset_session! resets page body`
244
+ # spec since the 0.2.0 Ferrum-style reset rewrite.
245
+ evaluate("(document.documentElement && document.documentElement.outerHTML) || ''")
242
246
  end
243
247
  alias html body
244
248
 
@@ -247,9 +251,25 @@ module Capybara
247
251
  # and dispatch via Runtime.callFunctionOn so `arguments[i]` is bound.
248
252
  # Both paths use `returnByValue: false` and unwrap so DOM-node returns
249
253
  # come back as `{ "__lightpanda_node__" => ... }` for the Driver to wrap.
254
+ #
255
+ # Even the no-args path wraps the expression in an IIFE to isolate
256
+ # top-level `const`/`let` declarations. Upstream Lightpanda retains
257
+ # those bindings across `Runtime.evaluate` calls (V8 starts each call
258
+ # with fresh lexical scope per spec), so a second `const sel = ...`
259
+ # raises `SyntaxError: Identifier 'sel' has already been declared`.
260
+ # Wrapping pushes the declarations into a function scope that gets
261
+ # discarded when the IIFE returns.
262
+ #
263
+ # Use direct `eval` inside the IIFE so the user's text can be a bare
264
+ # expression (`'foo'`), a `throw` statement, OR a multi-statement
265
+ # script with `const`/`let`. `eval`'s completion-value semantics
266
+ # return the last expression's value in all cases. A naive
267
+ # `return EXPR;` wrap would syntax-error on `throw …` and on
268
+ # multi-statement scripts.
250
269
  def evaluate(expression, *args)
251
270
  if args.empty?
252
- response = page_command("Runtime.evaluate", expression: expression, returnByValue: false, awaitPromise: true)
271
+ wrapped = "(function(){return eval(#{expression.to_json})})()"
272
+ response = page_command("Runtime.evaluate", expression: wrapped, returnByValue: false, awaitPromise: true)
253
273
  if response["exceptionDetails"]
254
274
  debug_js_failure("evaluate", expression, response)
255
275
  raise JavaScriptError, response
@@ -263,9 +283,20 @@ module Capybara
263
283
  end
264
284
 
265
285
  # Execute JS without returning a value.
286
+ #
287
+ # Like `evaluate`, the no-args path wraps in an IIFE — same upstream
288
+ # `const`/`let` leak. Also raises on JS exceptions so silent
289
+ # failures don't mask test bugs (the previous fast path swallowed them
290
+ # because `awaitPromise: false` was checked but `exceptionDetails` was
291
+ # not).
266
292
  def execute(expression, *args)
267
293
  if args.empty?
268
- page_command("Runtime.evaluate", expression: expression, returnByValue: false, awaitPromise: false)
294
+ wrapped = "(function(){#{expression}})()"
295
+ response = page_command("Runtime.evaluate", expression: wrapped, returnByValue: false, awaitPromise: false)
296
+ if response["exceptionDetails"]
297
+ debug_js_failure("execute", expression, response)
298
+ raise JavaScriptError, response
299
+ end
269
300
  return nil
270
301
  end
271
302
 
@@ -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
@@ -390,10 +385,7 @@ module Capybara
390
385
  function() {
391
386
  var EventCtor = (typeof MouseEvent !== 'undefined') ? MouseEvent : Event;
392
387
  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 */ }
388
+ var notCancelled = this.dispatchEvent(clickEvt);
397
389
  if (!notCancelled || clickEvt.defaultPrevented) return;
398
390
  var tag = this.tagName;
399
391
  var type = (this.type || '').toLowerCase();
@@ -401,9 +393,35 @@ module Capybara
401
393
  (tag === 'BUTTON' && (type === 'submit' || type === '')) ||
402
394
  (tag === 'INPUT' && (type === 'submit' || type === 'image'));
403
395
  if (isSubmitButton && this.form) {
404
- this.form.requestSubmit(this);
396
+ // Lightpanda raises a JsException from requestSubmit when a
397
+ // bubble-phase listener (e.g. Turbo's submitBubbled) calls
398
+ // preventDefault + stopImmediatePropagation on the SubmitEvent.
399
+ // Per HTML spec a cancelled submission should be a silent no-op.
400
+ // Log unexpected errors via console.warn so they remain
401
+ // diagnosable (LIGHTPANDA_DEBUG surfaces console output) instead
402
+ // of silently swallowing future regressions.
403
+ try {
404
+ this.form.requestSubmit(this);
405
+ } catch (e) {
406
+ try { console.warn('[capybara-lightpanda] requestSubmit threw:', e && e.message ? e.message : e); } catch (_) {}
407
+ }
405
408
  } else if (tag === 'A' && this.href && this.target !== '_blank') {
406
- window.location.href = this.href;
409
+ // Same-document fragment-only navigation: just update hash (or do
410
+ // nothing if identical). Mirrors Chrome — assigning location.href
411
+ // to a same-document URL on Lightpanda triggers a real navigation
412
+ // tick that cancels pending setTimeout callbacks and clears form
413
+ // values, which breaks any test driving DOM updates from a click
414
+ // handler on `<a href="#...">`.
415
+ var dest = new URL(this.href, document.baseURI);
416
+ var here = new URL(window.location.href);
417
+ if (dest.origin === here.origin && dest.pathname === here.pathname &&
418
+ dest.search === here.search) {
419
+ if (dest.hash !== here.hash) {
420
+ window.location.hash = dest.hash;
421
+ }
422
+ } else {
423
+ window.location.href = this.href;
424
+ }
407
425
  }
408
426
  }
409
427
  JS
@@ -8,7 +8,7 @@ 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
11
+ # Floor for the cookie/navigation/redirect/modal/keyboard/css/forms/dispatch
12
12
  # 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),
@@ -23,10 +23,12 @@ 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
+ # Build 6065 = main HEAD 61364437 (2026-05-06, PR #2368 merge);
30
+ # ships in nightly published 2026-05-06 ~03:30 UTC for all four platforms.
31
+ MINIMUM_NIGHTLY_BUILD = Gem::Version.new("6065")
30
32
 
31
33
  attr_reader :pid, :ws_url, :version, :nightly_build
32
34
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Capybara
4
4
  module Lightpanda
5
- VERSION = "0.2.1"
5
+ VERSION = "0.2.2"
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.2.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Navid Emad