capybara-lightpanda 0.2.0 → 0.2.1

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: 6ffb56a5986bbbe2ecfd36662d9e8bfed778046344f013ec028121100f9f87a5
4
- data.tar.gz: c9a86b39d5ab675b6e00dfeb22b26a8aaef10b3a4d88e7455ce517452ec1c317
3
+ metadata.gz: 42246ea44b80c592e6779cf2aa95890c7635fb057b0061064b63bf15004dcde6
4
+ data.tar.gz: 63131038538438b32d39d8e46a36005df0306e8daadaa38ff88540874c6c46aa
5
5
  SHA512:
6
- metadata.gz: 883cc590c12520bf5be6c617c106ddf9b5e859384115b768ea2c1b434b4e62ee848354cf171facac43092577aec7ed8aef7dbd43871dc5b5c1415363d26e80e8
7
- data.tar.gz: fbd160fa34522967f09275f5f9fd5da225f9f2ec365e233576a495be7e29873a221c1d42edf1cef8d585b7972c0813e6b15cb876f6eb1b6879836248144907ab
6
+ metadata.gz: 1728491bdd3d3dac24559cc663ee250af16f90d7944d2c27be0b879ddbfc23a2a00181f39970d7622fd1b640449d97c7d373c4f6a414aae9073dc0f3cf92d1f8
7
+ data.tar.gz: 47e0408c55b5150347656d8136b6dc993b39d38a051a39efec480e2d913cf799f45c18242fd031bbb6962f79b7a39d63cc2eec8d2c14e2f42f34ed3b029af867
data/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.2.1] - 2026-05-05
4
+
5
+ ### Fixed
6
+
7
+ - Turbo Frame links now correctly swap the frame instead of falling through to a full-page navigation. Affects any test that clicks a link or submits a form inside a `<turbo-frame>`.
8
+ - Internal driver errors (`NoMethodError`, `NameError`, `Errno::*`) inside `extract_node_object_ids` and `page_ready?` are no longer swallowed and silently downgraded to `[]` / `false` — they surface as real exceptions so bugs are visible.
9
+
10
+ ### Internal
11
+
12
+ - Local test suite migrated from RSpec to Minitest::Spec. The Capybara shared-spec battery (`spec/features/session_spec.rb`) still runs on RSpec.
13
+
3
14
  ## [0.2.0] - 2026-05-04
4
15
 
5
16
  Reliability and feature polish as Lightpanda matured. **Update Lightpanda before upgrading**: this release requires a current nightly (the gem will tell you if yours is too old).
@@ -73,7 +73,7 @@ module Capybara
73
73
  end
74
74
 
75
75
  def find
76
- env_path = ENV.fetch("LIGHTPANDA_PATH", nil)
76
+ env_path = ENV.fetch("LIGHTPANDA_BIN", nil)
77
77
  return env_path if env_path && File.executable?(env_path)
78
78
 
79
79
  path_binary = find_in_path
@@ -8,7 +8,7 @@ module Capybara
8
8
  class Browser
9
9
  extend Forwardable
10
10
 
11
- attr_reader :options, :process, :client, :target_id, :session_id, :frame_stack
11
+ attr_reader :options, :process, :client, :target_id, :session_id, :browser_context_id, :frame_stack
12
12
 
13
13
  delegate %i[on off] => :client
14
14
 
@@ -29,6 +29,7 @@ module Capybara
29
29
  @client = nil
30
30
  @target_id = nil
31
31
  @session_id = nil
32
+ @browser_context_id = nil
32
33
  @started = false
33
34
  @page_events_enabled = false
34
35
  @modal_messages = []
@@ -52,13 +53,25 @@ module Capybara
52
53
  @client = Client.new(@process.ws_url, @options)
53
54
  end
54
55
 
56
+ create_browser_context
55
57
  create_page
56
58
 
57
59
  @started = true
58
60
  end
59
61
 
62
+ # Per-session BrowserContext (Chrome's incognito-profile primitive).
63
+ # Cookies, storage, and targets created within the context are wiped
64
+ # when it's disposed — so `reset` is one CDP call instead of an
65
+ # explicit cookies.clear / storage.clear / close-target dance.
66
+ # Mirrors ferrum's Contexts model.
67
+ def create_browser_context
68
+ result = @client.command("Target.createBrowserContext")
69
+ @browser_context_id = result["browserContextId"]
70
+ end
71
+
60
72
  def create_page
61
- result = @client.command("Target.createTarget", { url: "about:blank" })
73
+ result = @client.command("Target.createTarget",
74
+ { url: "about:blank", browserContextId: @browser_context_id }.compact)
62
75
  @target_id = result["targetId"]
63
76
 
64
77
  attach_result = @client.command("Target.attachToTarget", { targetId: @target_id, flatten: true })
@@ -78,6 +91,33 @@ module Capybara
78
91
  start
79
92
  end
80
93
 
94
+ # Wipe per-session state — cookies, storage, all targets — and start
95
+ # over with a fresh BrowserContext. Mirrors ferrum's Browser#reset:
96
+ # one CDP call (`Target.disposeBrowserContext`) does the work that
97
+ # would otherwise require explicit cookies.clear / storage.clear /
98
+ # close-target dance, and the browser auto-isolates state for the
99
+ # new context. Driver#reset! delegates here.
100
+ #
101
+ # Side benefit: avoids `Page.navigate("about:blank")` against a
102
+ # non-blank tab, which doesn't actually replace the document on
103
+ # current Lightpanda nightly (lightpanda-io/browser#2363). The
104
+ # context-disposal path sidesteps that bug entirely.
105
+ def reset
106
+ dispose_browser_context
107
+ @client.clear_subscriptions
108
+ @page_events_enabled = false
109
+ @modal_handler_installed = false
110
+ @modal_messages.clear
111
+ @frame_stack.clear
112
+ # Network#reset, not #clear: disposing the BrowserContext also
113
+ # destroyed the Network domain and its subscriptions, so we must
114
+ # flip @enabled back to false — otherwise the next #enable
115
+ # short-circuits and traffic tracking is silently dead.
116
+ @network&.reset
117
+ create_browser_context
118
+ create_page
119
+ end
120
+
81
121
  # Recover after a WebSocket disconnect or process crash during navigation.
82
122
  # Restarts the process if it died, then creates a fresh client and page.
83
123
  def reconnect
@@ -88,6 +128,9 @@ module Capybara
88
128
  raise DeadBrowserError, "Cannot reconnect: no WebSocket URL" unless ws_url
89
129
 
90
130
  @client = Client.new(ws_url, @options)
131
+ # Process may have died; the old browserContextId is gone with it.
132
+ @browser_context_id = nil
133
+ create_browser_context
91
134
  create_page
92
135
  @page_events_enabled = false
93
136
  end
@@ -106,6 +149,9 @@ module Capybara
106
149
  @client = nil
107
150
  @process = nil
108
151
  @started = false
152
+ @browser_context_id = nil
153
+ @target_id = nil
154
+ @session_id = nil
109
155
  @modal_handler_installed = false
110
156
  @frame_stack.clear
111
157
  end
@@ -204,7 +250,10 @@ module Capybara
204
250
  def evaluate(expression, *args)
205
251
  if args.empty?
206
252
  response = page_command("Runtime.evaluate", expression: expression, returnByValue: false, awaitPromise: true)
207
- raise JavaScriptError, response if response["exceptionDetails"]
253
+ if response["exceptionDetails"]
254
+ debug_js_failure("evaluate", expression, response)
255
+ raise JavaScriptError, response
256
+ end
208
257
 
209
258
  return unwrap_call_result(response["result"])
210
259
  end
@@ -225,6 +274,15 @@ module Capybara
225
274
  nil
226
275
  end
227
276
 
277
+ # When LIGHTPANDA_DEBUG=1 is set, log the JS expression and full CDP
278
+ # response for every JsException to STDERR. Invaluable for isolating
279
+ # which exact JS triggers an upstream Lightpanda bug.
280
+ def debug_js_failure(site, expression, response)
281
+ return unless ENV["LIGHTPANDA_DEBUG"]
282
+
283
+ warn "[lightpanda:#{site}] expression:\n#{expression}\n[lightpanda:#{site}] response:\n#{response.inspect}\n"
284
+ end
285
+
228
286
  # Evaluate async JS with a callback. The user's script receives
229
287
  # the callback as its last argument (`arguments[arguments.length - 1]`),
230
288
  # matching Capybara's evaluate_async_script contract.
@@ -249,7 +307,10 @@ module Capybara
249
307
  # Evaluate JS and return a RemoteObject reference (for DOM nodes, arrays).
250
308
  def evaluate_with_ref(expression)
251
309
  response = page_command("Runtime.evaluate", expression: expression, returnByValue: false, awaitPromise: true)
252
- raise JavaScriptError, response if response["exceptionDetails"]
310
+ if response["exceptionDetails"]
311
+ debug_js_failure("evaluate_with_ref", expression, response)
312
+ raise JavaScriptError, response
313
+ end
253
314
 
254
315
  result = response["result"]
255
316
  return nil if result["type"] == "undefined"
@@ -269,7 +330,10 @@ module Capybara
269
330
  params[:arguments] = args.map { |a| serialize_argument(a) } unless args.empty?
270
331
 
271
332
  response = page_command("Runtime.callFunctionOn", **params)
272
- raise JavaScriptError, response if response["exceptionDetails"]
333
+ if response["exceptionDetails"]
334
+ debug_js_failure("call_function_on", function_declaration, response)
335
+ raise JavaScriptError, response
336
+ end
273
337
 
274
338
  result = response["result"]
275
339
  return nil if result["type"] == "undefined"
@@ -304,6 +368,8 @@ module Capybara
304
368
  def find_within(remote_object_id, method, selector)
305
369
  result = call_function_on(remote_object_id, FIND_WITHIN_JS, method, selector, return_by_value: false)
306
370
  extract_node_object_ids(result)
371
+ rescue JavaScriptError => e
372
+ raise_invalid_selector(e, method, selector)
307
373
  end
308
374
 
309
375
  # objectId of document.activeElement, or nil if none/document detached.
@@ -528,20 +594,32 @@ module Capybara
528
594
  raise Capybara::ModalNotFound, "Unable to find modal dialog#{" with #{text}" if text}"
529
595
  end
530
596
 
597
+ # Sentinel string thrown from FIND_*_JS when querySelectorAll rejects a
598
+ # malformed selector, so the Ruby side can convert JavaScriptError into
599
+ # Capybara::Lightpanda::InvalidSelector. Cuprite uses a JS subclass for
600
+ # the same purpose; a plain prefixed string keeps our inline JS simple.
601
+ INVALID_SELECTOR_MARKER = "LIGHTPANDA_INVALID_SELECTOR:"
602
+
531
603
  # JS function for finding elements within a node.
532
- # Works in any execution context (top frame or iframe).
533
- FIND_WITHIN_JS = <<~JS
604
+ # Works in any execution context (top frame or iframe). Any throw from
605
+ # querySelectorAll means the selector is malformed (the spec only allows
606
+ # SYNTAX_ERR DOMException; Lightpanda's V8 currently throws a generic
607
+ # Error with messages like "InvalidClassSelector"). Re-throw with the
608
+ # marker prefix so Ruby converts to InvalidSelector regardless.
609
+ FIND_WITHIN_JS = <<~JS.freeze
534
610
  function(method, selector) {
535
611
  if (method === 'xpath') {
536
612
  if (typeof _lightpanda !== 'undefined') return _lightpanda.xpathFind(selector, this);
537
613
  return [];
538
614
  }
539
- try { return Array.from(this.querySelectorAll(selector)); } catch(e) { return []; }
615
+ try { return Array.from(this.querySelectorAll(selector)); }
616
+ catch(e) { throw new Error('#{INVALID_SELECTOR_MARKER}' + selector); }
540
617
  }
541
618
  JS
619
+ private_constant :FIND_WITHIN_JS
542
620
 
543
621
  # JS function for finding elements in an iframe's contentDocument.
544
- FIND_IN_FRAME_JS = <<~JS
622
+ FIND_IN_FRAME_JS = <<~JS.freeze
545
623
  function(method, selector) {
546
624
  var doc;
547
625
  try { doc = this.contentDocument || (this.contentWindow && this.contentWindow.document); } catch(e) {}
@@ -550,9 +628,11 @@ module Capybara
550
628
  if (typeof _lightpanda !== 'undefined') return _lightpanda.xpathFind(selector, doc);
551
629
  return [];
552
630
  }
553
- try { return Array.from(doc.querySelectorAll(selector)); } catch(e) { return []; }
631
+ try { return Array.from(doc.querySelectorAll(selector)); }
632
+ catch(e) { throw new Error('#{INVALID_SELECTOR_MARKER}' + selector); }
554
633
  }
555
634
  JS
635
+ private_constant :FIND_IN_FRAME_JS
556
636
 
557
637
  def find_in_document(method, selector)
558
638
  with_default_context_wait do
@@ -563,12 +643,18 @@ module Capybara
563
643
  js = if method == "xpath"
564
644
  "(typeof _lightpanda !== 'undefined') ? _lightpanda.xpathFind(#{selector_literal}, document) : []"
565
645
  else
566
- "(function() { try { return Array.from(document.querySelectorAll(#{selector_literal})); } " \
567
- "catch(e) { return []; } })()"
646
+ <<~CSS_FIND
647
+ (function() {
648
+ try { return Array.from(document.querySelectorAll(#{selector_literal})); }
649
+ catch(e) { throw new Error('#{INVALID_SELECTOR_MARKER}' + #{selector_literal}); }
650
+ })()
651
+ CSS_FIND
568
652
  end
569
653
  result = evaluate_with_ref(js)
570
654
  extract_node_object_ids(result)
571
655
  end
656
+ rescue JavaScriptError => e
657
+ raise_invalid_selector(e, method, selector)
572
658
  end
573
659
 
574
660
  def find_in_frame(method, selector)
@@ -576,6 +662,16 @@ module Capybara
576
662
  result = call_function_on(frame_node.remote_object_id, FIND_IN_FRAME_JS, method, selector,
577
663
  return_by_value: false)
578
664
  extract_node_object_ids(result)
665
+ rescue JavaScriptError => e
666
+ raise_invalid_selector(e, method, selector)
667
+ end
668
+
669
+ def raise_invalid_selector(js_error, method, selector)
670
+ if js_error.message.include?(INVALID_SELECTOR_MARKER)
671
+ raise InvalidSelector.new("Invalid #{method} selector: #{selector.inspect}", method, selector)
672
+ end
673
+
674
+ raise js_error
579
675
  end
580
676
 
581
677
  # Extract individual node objectIds from a remote array reference.
@@ -592,12 +688,13 @@ module Capybara
592
688
 
593
689
  release_object(result["objectId"])
594
690
  ids
595
- rescue StandardError
691
+ rescue Error
596
692
  []
597
693
  end
598
694
 
599
695
  def register_auto_scripts
600
696
  page_command("Page.addScriptToEvaluateOnNewDocument", source: XPathPolyfill::JS)
697
+ page_command("Page.addScriptToEvaluateOnNewDocument", source: XPathPolyfill::POLYFILLS_JS)
601
698
  end
602
699
 
603
700
  def subscribe_to_console_logs
@@ -716,7 +813,10 @@ module Capybara
716
813
  end
717
814
 
718
815
  def handle_evaluate_response(response)
719
- raise JavaScriptError, response if response["exceptionDetails"]
816
+ if response["exceptionDetails"]
817
+ debug_js_failure("handle_evaluate_response", "(unknown — already-issued call)", response)
818
+ raise JavaScriptError, response
819
+ end
720
820
 
721
821
  result = response["result"]
722
822
  return nil if result["type"] == "undefined"
@@ -738,7 +838,10 @@ module Capybara
738
838
  arguments: args.map { |a| serialize_argument(a) },
739
839
  }
740
840
  response = page_command("Runtime.callFunctionOn", **params)
741
- raise JavaScriptError, response if response["exceptionDetails"]
841
+ if response["exceptionDetails"]
842
+ debug_js_failure("call_with_args", function_declaration, response)
843
+ raise JavaScriptError, response
844
+ end
742
845
 
743
846
  return_by_value ? handle_evaluate_response(response) : unwrap_call_result(response["result"])
744
847
  end
@@ -839,6 +942,21 @@ module Capybara
839
942
  nil
840
943
  end
841
944
 
945
+ def dispose_browser_context
946
+ return unless @browser_context_id
947
+
948
+ begin
949
+ @client.command("Target.disposeBrowserContext", { browserContextId: @browser_context_id })
950
+ rescue StandardError
951
+ # Context may already be disposed or the WS may be down; we
952
+ # recreate either way.
953
+ ensure
954
+ @browser_context_id = nil
955
+ @target_id = nil
956
+ @session_id = nil
957
+ end
958
+ end
959
+
842
960
  def restart_process_if_dead
843
961
  return unless @process && !@process.alive?
844
962
 
@@ -927,7 +1045,7 @@ module Capybara
927
1045
 
928
1046
  url_changed = starting_url.nil? || state["u"] != starting_url
929
1047
  url_changed && %w[complete interactive].include?(state["r"])
930
- rescue StandardError
1048
+ rescue Error
931
1049
  false
932
1050
  end
933
1051
 
@@ -4,9 +4,18 @@ module Capybara
4
4
  module Lightpanda
5
5
  class Client
6
6
  class Subscriber
7
- def initialize
7
+ # Default error sink: write a one-line warning so a misbehaving handler
8
+ # is visible without crashing the CDP message thread. Tests can inject
9
+ # a custom proc via `on_error:` to capture failures.
10
+ DEFAULT_ON_ERROR = lambda do |event, error|
11
+ warn("[capybara-lightpanda] subscriber callback for #{event.inspect} raised " \
12
+ "#{error.class}: #{error.message}")
13
+ end
14
+
15
+ def initialize(on_error: DEFAULT_ON_ERROR)
8
16
  @subscriptions = Hash.new { |h, k| h[k] = [] }
9
17
  @mutex = Mutex.new
18
+ @on_error = on_error
10
19
  end
11
20
 
12
21
  def subscribe(event, &block)
@@ -25,10 +34,28 @@ module Capybara
25
34
  end
26
35
  end
27
36
 
37
+ # Run every callback registered for `event`. Exceptions in one
38
+ # callback must not stop the others or propagate out — the message
39
+ # thread sets `abort_on_exception = true`, so an unhandled raise
40
+ # would tear down the entire CDP connection.
41
+ #
42
+ # Two layers of rescue:
43
+ # 1. The callback itself may raise — route to @on_error.
44
+ # 2. @on_error itself may raise (custom hook, broken stderr) —
45
+ # swallow at the last level so the dispatch loop survives.
28
46
  def dispatch(event, params)
29
47
  callbacks = @mutex.synchronize { @subscriptions[event].dup }
30
48
 
31
- callbacks.each { |callback| callback.call(params) }
49
+ callbacks.each do |callback|
50
+ callback.call(params)
51
+ rescue StandardError => e
52
+ begin
53
+ @on_error.call(event, e)
54
+ rescue StandardError
55
+ # The error sink failed — nothing to do but keep going. We
56
+ # cannot log here without re-entering the broken path.
57
+ end
58
+ end
32
59
  end
33
60
 
34
61
  def subscribed?(event)
@@ -60,6 +60,13 @@ module Capybara
60
60
  @subscriber.unsubscribe(event, block)
61
61
  end
62
62
 
63
+ # Drop all event subscriptions without closing the WebSocket. Used by
64
+ # Browser#recreate_page so a fresh target's event handlers don't pile
65
+ # up on top of the previous target's subscriptions.
66
+ def clear_subscriptions
67
+ @subscriber.clear
68
+ end
69
+
63
70
  def close
64
71
  @ws&.close
65
72
  @message_thread&.join(1) || @message_thread&.kill
@@ -196,12 +196,12 @@ module Capybara
196
196
 
197
197
  # -- Lifecycle --
198
198
 
199
+ # Thin Cuprite-style wrapper. The interesting work — disposing the
200
+ # BrowserContext (cookies, storage, all targets) and starting a fresh
201
+ # one — happens in Browser#reset.
199
202
  def reset!
200
- browser.clear_frames
201
- browser.reset_modals
202
- browser.cookies.clear
203
- browser.network.clear
204
- browser.go_to("about:blank")
203
+ browser.reset
204
+ @started = false
205
205
  rescue StandardError
206
206
  @browser&.quit
207
207
  @browser = nil
@@ -46,11 +46,36 @@ module Capybara
46
46
  attr_reader :class_name, :stack_trace
47
47
 
48
48
  def initialize(response)
49
- @class_name = response.dig("exceptionDetails", "exception", "className")
50
- @stack_trace = response.dig("exceptionDetails", "stackTrace")
51
- message = response.dig("exceptionDetails", "exception", "description") ||
52
- response.dig("exceptionDetails", "text")
53
- super(message)
49
+ details = response["exceptionDetails"] || {}
50
+ exception = details["exception"] || {}
51
+ @class_name = exception["className"]
52
+ @stack_trace = details["stackTrace"]
53
+ super(build_message(details, exception))
54
+ end
55
+
56
+ private
57
+
58
+ def build_message(details, exception)
59
+ base = exception["description"] || details["text"] || "JsException"
60
+ parts = [base]
61
+ parts << "(#{@class_name})" if @class_name && !base.include?(@class_name)
62
+ if (val = exception["value"])
63
+ parts << "value=#{val.inspect}"
64
+ end
65
+ if @stack_trace && (frames = @stack_trace["callFrames"])
66
+ parts << format_stack(frames)
67
+ end
68
+ parts.join(" | ")
69
+ end
70
+
71
+ def format_stack(frames)
72
+ formatted = frames.first(5).map { |f| format_frame(f) }
73
+ "stack:\n #{formatted.join("\n ")}"
74
+ end
75
+
76
+ def format_frame(frame)
77
+ name = frame["functionName"].to_s.empty? ? "<anon>" : frame["functionName"]
78
+ "#{name} @ #{frame['url']}:#{frame['lineNumber']}:#{frame['columnNumber']}"
54
79
  end
55
80
  end
56
81
 
@@ -0,0 +1,81 @@
1
+ // Polyfills compensant des limitations du binaire Lightpanda.
2
+ // Chaque section est gardée par un test de feature : dès qu'upstream implémente
3
+ // l'API native, le polyfill devient un no-op et peut être retiré.
4
+ // Voir UPSTREAM_BUGS.md à la racine du gem pour les repros et liens d'issues.
5
+ (function () {
6
+ "use strict";
7
+
8
+ // ── Bug #4 — HTMLDialogElement.{showModal, show, close} non implémentés ──
9
+ // https://html.spec.whatwg.org/multipage/interactive-elements.html#the-dialog-element
10
+ if (typeof HTMLDialogElement !== "undefined") {
11
+ var dproto = HTMLDialogElement.prototype;
12
+ if (typeof dproto.showModal !== "function") {
13
+ dproto.showModal = function () {
14
+ if (this.hasAttribute("open")) {
15
+ throw new (window.DOMException || Error)(
16
+ "The element already has an 'open' attribute, and therefore cannot be opened modally.",
17
+ "InvalidStateError"
18
+ );
19
+ }
20
+ this.setAttribute("open", "");
21
+ };
22
+ }
23
+ if (typeof dproto.show !== "function") {
24
+ dproto.show = function () {
25
+ if (!this.hasAttribute("open")) this.setAttribute("open", "");
26
+ };
27
+ }
28
+ if (typeof dproto.close !== "function") {
29
+ dproto.close = function (returnValue) {
30
+ if (!this.hasAttribute("open")) return;
31
+ this.removeAttribute("open");
32
+ if (returnValue !== undefined) this.returnValue = String(returnValue);
33
+ this.dispatchEvent(new Event("close"));
34
+ };
35
+ }
36
+ }
37
+
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;
48
+
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;
54
+
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 */ }
62
+
63
+ var node = this.parentNode;
64
+ while (node) {
65
+ try {
66
+ Object.defineProperty(event, "currentTarget", {
67
+ value: node,
68
+ configurable: true,
69
+ });
70
+ } 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
+ }
77
+ return !event.defaultPrevented;
78
+ }
79
+ };
80
+ })();
81
+ })();
@@ -9,6 +9,8 @@ module Capybara
9
9
  @browser = browser
10
10
  @traffic = []
11
11
  @enabled = false
12
+ @request_handler = nil
13
+ @response_handler = nil
12
14
  end
13
15
 
14
16
  def enable
@@ -22,7 +24,13 @@ module Capybara
22
24
  def disable
23
25
  return unless @enabled
24
26
 
27
+ # Tell the browser to stop emitting BEFORE unsubscribing locally:
28
+ # otherwise an in-flight Network.responseReceived can race past the
29
+ # already-removed handler and leave a `response: nil` entry in
30
+ # @traffic for the matching request — which then trips
31
+ # wait_for_idle's pending count on a future call.
25
32
  browser.command("Network.disable")
33
+ unsubscribe
26
34
  @enabled = false
27
35
  end
28
36
 
@@ -34,6 +42,18 @@ module Capybara
34
42
  @traffic.clear
35
43
  end
36
44
 
45
+ # Wipe local state without sending Network.disable. Called by
46
+ # Browser#reset after Target.disposeBrowserContext, which destroys
47
+ # the subscriptions and the Network domain along with the context —
48
+ # leaving @enabled true would silently no-op the next #enable.
49
+ # Also unsubscribes locally so we don't rely on the caller having
50
+ # cleared the Subscriber first.
51
+ def reset
52
+ unsubscribe
53
+ @traffic.clear
54
+ @enabled = false
55
+ end
56
+
37
57
  def headers=(headers)
38
58
  @extra_headers = headers
39
59
  browser.page_command("Network.setExtraHTTPHeaders", headers: headers)
@@ -65,7 +85,7 @@ module Capybara
65
85
  private
66
86
 
67
87
  def subscribe
68
- browser.on("Network.requestWillBeSent") do |params|
88
+ @request_handler = lambda do |params|
69
89
  @traffic << {
70
90
  request_id: params["requestId"],
71
91
  url: params.dig("request", "url"),
@@ -75,7 +95,7 @@ module Capybara
75
95
  }
76
96
  end
77
97
 
78
- browser.on("Network.responseReceived") do |params|
98
+ @response_handler = lambda do |params|
79
99
  request = @traffic.find { |t| t[:request_id] == params["requestId"] }
80
100
 
81
101
  next unless request
@@ -86,6 +106,16 @@ module Capybara
86
106
  mime_type: params.dig("response", "mimeType"),
87
107
  }
88
108
  end
109
+
110
+ browser.on("Network.requestWillBeSent", &@request_handler)
111
+ browser.on("Network.responseReceived", &@response_handler)
112
+ end
113
+
114
+ def unsubscribe
115
+ browser.off("Network.requestWillBeSent", @request_handler) if @request_handler
116
+ browser.off("Network.responseReceived", @response_handler) if @response_handler
117
+ @request_handler = nil
118
+ @response_handler = nil
89
119
  end
90
120
  end
91
121
  end
@@ -116,6 +116,14 @@ module Capybara
116
116
  call("function() { this.dispatchEvent(new MouseEvent('mouseover', {bubbles: true, cancelable: true})) }")
117
117
  end
118
118
 
119
+ # Dispatch an arbitrary DOM event by name. Mirrors Cuprite's Node#trigger
120
+ # — picks the right Event constructor for known mouse/focus/form names
121
+ # and falls back to a generic Event for everything else (so callers can
122
+ # fire custom events like `node.trigger('lp:custom')`).
123
+ def trigger(event)
124
+ call(TRIGGER_JS, event.to_s)
125
+ end
126
+
119
127
  def set(value, **_options)
120
128
  case tag_name
121
129
  when "input"
@@ -355,7 +363,77 @@ module Capybara
355
363
  end
356
364
  end
357
365
 
358
- CLICK_JS = "function() { this.click() }"
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
+ # We dispatch a `MouseEvent` (not a generic `Event`) because Turbo's link
374
+ # and form interceptors guard with `event instanceof MouseEvent` before
375
+ # they consider intercepting — a synthetic `Event('click')` is silently
376
+ # ignored by Turbo Frame / Drive, and CLICK_JS would then fall through to
377
+ # the manual default action below, which does a full-page navigation
378
+ # instead of a frame swap.
379
+ #
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.
389
+ CLICK_JS = <<~JS
390
+ function() {
391
+ var EventCtor = (typeof MouseEvent !== 'undefined') ? MouseEvent : Event;
392
+ 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 */ }
397
+ if (!notCancelled || clickEvt.defaultPrevented) return;
398
+ 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;
407
+ }
408
+ }
409
+ JS
410
+
411
+ # Mirrors Cuprite's trigger map. Picks the right Event constructor based
412
+ # on the event name so listeners that key on `event instanceof MouseEvent`
413
+ # / `instanceof SubmitEvent` see what they expect; everything else goes
414
+ # through a generic Event so custom names ("turbo:load", "lp:custom")
415
+ # still dispatch. Each constructor is feature-detected (`typeof X !==
416
+ # 'undefined'`) before use so a missing IDL on Lightpanda falls back
417
+ # to plain Event rather than throwing.
418
+ TRIGGER_JS = <<~JS
419
+ function(name) {
420
+ var MOUSE = ['click','dblclick','mousedown','mouseenter','mouseleave',
421
+ 'mousemove','mouseover','mouseout','mouseup','contextmenu'];
422
+ var FOCUS = ['blur','focus','focusin','focusout'];
423
+ var init = { bubbles: true, cancelable: true };
424
+ var event;
425
+ if (MOUSE.indexOf(name) !== -1 && typeof MouseEvent !== 'undefined') {
426
+ event = new MouseEvent(name, init);
427
+ } else if (FOCUS.indexOf(name) !== -1 && typeof FocusEvent !== 'undefined') {
428
+ event = new FocusEvent(name, init);
429
+ } else if (name === 'submit' && typeof SubmitEvent !== 'undefined') {
430
+ event = new SubmitEvent(name, init);
431
+ } else {
432
+ event = new Event(name, init);
433
+ }
434
+ this.dispatchEvent(event);
435
+ }
436
+ JS
359
437
 
360
438
  VISIBLE_JS = "function() { return _lightpanda.isVisible(this); }"
361
439
 
@@ -382,7 +460,14 @@ module Capybara
382
460
  autofocus: 'autofocus', required: 'required' };
383
461
  var prop = BOOL_PROP[name.toLowerCase()];
384
462
  if (prop && this[prop] !== undefined) return this[prop];
385
- return this.getAttribute(name);
463
+ if (this.hasAttribute(name)) return this.getAttribute(name);
464
+ // Property-only fallback: things like `validationMessage` have no
465
+ // backing HTML attribute. Return primitives only — DOM-node properties
466
+ // (form, options, etc.) shouldn't leak through.
467
+ var live = this[name];
468
+ if (live === null || live === undefined) return null;
469
+ var t = typeof live;
470
+ return (t === 'string' || t === 'number' || t === 'boolean') ? live : null;
386
471
  }
387
472
  JS
388
473
 
@@ -464,8 +549,6 @@ module Capybara
464
549
 
465
550
  SET_CHECKBOX_JS = <<~JS
466
551
  function(value) {
467
- // Use `click()` so user-installed click/change handlers fire and
468
- // observe a real toggle. No-op if already in the requested state.
469
552
  if (this.checked !== value) this.click();
470
553
  }
471
554
  JS
@@ -22,10 +22,11 @@ module Capybara
22
22
  # promptText is null), PR #2324 (<label> click runs activation behavior
23
23
  # on labeled control), PR #2286 (HTML constraint validation API:
24
24
  # el.validity, validationMessage, checkValidity, reportValidity),
25
- # PR #2342 (<summary> click toggles parent <details>.open). Build
26
- # 6005 = main HEAD 0420802f (2026-05-04); ships in nightly published
27
- # 2026-05-04 03:44 UTC for all four platforms.
28
- MINIMUM_NIGHTLY_BUILD = Gem::Version.new("6005")
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")
29
30
 
30
31
  attr_reader :pid, :ws_url, :version, :nightly_build
31
32
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Capybara
4
4
  module Lightpanda
5
- VERSION = "0.2.0"
5
+ VERSION = "0.2.1"
6
6
  end
7
7
  end
@@ -5,6 +5,11 @@ module Capybara
5
5
  module XPathPolyfill
6
6
  JS_PATH = File.expand_path("javascripts/index.js", __dir__).freeze
7
7
  JS = File.read(JS_PATH).freeze
8
+
9
+ # Polyfills pour les APIs DOM manquantes du binaire Lightpanda.
10
+ # Voir UPSTREAM_BUGS.md à la racine du gem.
11
+ POLYFILLS_PATH = File.expand_path("javascripts/polyfills.js", __dir__).freeze
12
+ POLYFILLS_JS = File.read(POLYFILLS_PATH).freeze
8
13
  end
9
14
  end
10
15
  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.0
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Navid Emad
@@ -81,6 +81,7 @@ files:
81
81
  - lib/capybara/lightpanda/errors.rb
82
82
  - lib/capybara/lightpanda/frame.rb
83
83
  - lib/capybara/lightpanda/javascripts/index.js
84
+ - lib/capybara/lightpanda/javascripts/polyfills.js
84
85
  - lib/capybara/lightpanda/keyboard.rb
85
86
  - lib/capybara/lightpanda/logger.rb
86
87
  - lib/capybara/lightpanda/network.rb