capybara-lightpanda 0.2.0 → 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: 6ffb56a5986bbbe2ecfd36662d9e8bfed778046344f013ec028121100f9f87a5
4
- data.tar.gz: c9a86b39d5ab675b6e00dfeb22b26a8aaef10b3a4d88e7455ce517452ec1c317
3
+ metadata.gz: 1cb36e68fb7bcc35abcbae5a3417091eb54444f7b57e161cb44cb6436d243684
4
+ data.tar.gz: 9eff2c5cd278d16ed8ad55d106dca14dab3597a87c3315c46371b98672a35163
5
5
  SHA512:
6
- metadata.gz: 883cc590c12520bf5be6c617c106ddf9b5e859384115b768ea2c1b434b4e62ee848354cf171facac43092577aec7ed8aef7dbd43871dc5b5c1415363d26e80e8
7
- data.tar.gz: fbd160fa34522967f09275f5f9fd5da225f9f2ec365e233576a495be7e29873a221c1d42edf1cef8d585b7972c0813e6b15cb876f6eb1b6879836248144907ab
6
+ metadata.gz: 156126b7f57785869aca23b79fe02bc3fe7370cf5b2826999715d5e69794a6b62f8c69bb64601e5e67f935d946e2348274c8ec76d79b674930b239d6b2383677
7
+ data.tar.gz: c73dab9284d0d8e0091794e2f88674951bafc64af2fa2e87200926a60d7d86767f31cd5114d5348af51013fe3b4846f1066d0bdca886a5a1051432673b76ef52
data/CHANGELOG.md CHANGED
@@ -1,5 +1,48 @@
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
+
35
+ ## [0.2.1] - 2026-05-05
36
+
37
+ ### Fixed
38
+
39
+ - 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>`.
40
+ - 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.
41
+
42
+ ### Internal
43
+
44
+ - Local test suite migrated from RSpec to Minitest::Spec. The Capybara shared-spec battery (`spec/features/session_spec.rb`) still runs on RSpec.
45
+
3
46
  ## [0.2.0] - 2026-05-04
4
47
 
5
48
  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
@@ -192,7 +238,11 @@ module Capybara
192
238
  end
193
239
 
194
240
  def body
195
- 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) || ''")
196
246
  end
197
247
  alias html body
198
248
 
@@ -201,10 +251,29 @@ module Capybara
201
251
  # and dispatch via Runtime.callFunctionOn so `arguments[i]` is bound.
202
252
  # Both paths use `returnByValue: false` and unwrap so DOM-node returns
203
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.
204
269
  def evaluate(expression, *args)
205
270
  if args.empty?
206
- response = page_command("Runtime.evaluate", expression: expression, returnByValue: false, awaitPromise: true)
207
- raise JavaScriptError, response if response["exceptionDetails"]
271
+ wrapped = "(function(){return eval(#{expression.to_json})})()"
272
+ response = page_command("Runtime.evaluate", expression: wrapped, returnByValue: false, awaitPromise: true)
273
+ if response["exceptionDetails"]
274
+ debug_js_failure("evaluate", expression, response)
275
+ raise JavaScriptError, response
276
+ end
208
277
 
209
278
  return unwrap_call_result(response["result"])
210
279
  end
@@ -214,9 +283,20 @@ module Capybara
214
283
  end
215
284
 
216
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).
217
292
  def execute(expression, *args)
218
293
  if args.empty?
219
- 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
220
300
  return nil
221
301
  end
222
302
 
@@ -225,6 +305,15 @@ module Capybara
225
305
  nil
226
306
  end
227
307
 
308
+ # When LIGHTPANDA_DEBUG=1 is set, log the JS expression and full CDP
309
+ # response for every JsException to STDERR. Invaluable for isolating
310
+ # which exact JS triggers an upstream Lightpanda bug.
311
+ def debug_js_failure(site, expression, response)
312
+ return unless ENV["LIGHTPANDA_DEBUG"]
313
+
314
+ warn "[lightpanda:#{site}] expression:\n#{expression}\n[lightpanda:#{site}] response:\n#{response.inspect}\n"
315
+ end
316
+
228
317
  # Evaluate async JS with a callback. The user's script receives
229
318
  # the callback as its last argument (`arguments[arguments.length - 1]`),
230
319
  # matching Capybara's evaluate_async_script contract.
@@ -249,7 +338,10 @@ module Capybara
249
338
  # Evaluate JS and return a RemoteObject reference (for DOM nodes, arrays).
250
339
  def evaluate_with_ref(expression)
251
340
  response = page_command("Runtime.evaluate", expression: expression, returnByValue: false, awaitPromise: true)
252
- raise JavaScriptError, response if response["exceptionDetails"]
341
+ if response["exceptionDetails"]
342
+ debug_js_failure("evaluate_with_ref", expression, response)
343
+ raise JavaScriptError, response
344
+ end
253
345
 
254
346
  result = response["result"]
255
347
  return nil if result["type"] == "undefined"
@@ -269,7 +361,10 @@ module Capybara
269
361
  params[:arguments] = args.map { |a| serialize_argument(a) } unless args.empty?
270
362
 
271
363
  response = page_command("Runtime.callFunctionOn", **params)
272
- raise JavaScriptError, response if response["exceptionDetails"]
364
+ if response["exceptionDetails"]
365
+ debug_js_failure("call_function_on", function_declaration, response)
366
+ raise JavaScriptError, response
367
+ end
273
368
 
274
369
  result = response["result"]
275
370
  return nil if result["type"] == "undefined"
@@ -304,6 +399,8 @@ module Capybara
304
399
  def find_within(remote_object_id, method, selector)
305
400
  result = call_function_on(remote_object_id, FIND_WITHIN_JS, method, selector, return_by_value: false)
306
401
  extract_node_object_ids(result)
402
+ rescue JavaScriptError => e
403
+ raise_invalid_selector(e, method, selector)
307
404
  end
308
405
 
309
406
  # objectId of document.activeElement, or nil if none/document detached.
@@ -528,20 +625,32 @@ module Capybara
528
625
  raise Capybara::ModalNotFound, "Unable to find modal dialog#{" with #{text}" if text}"
529
626
  end
530
627
 
628
+ # Sentinel string thrown from FIND_*_JS when querySelectorAll rejects a
629
+ # malformed selector, so the Ruby side can convert JavaScriptError into
630
+ # Capybara::Lightpanda::InvalidSelector. Cuprite uses a JS subclass for
631
+ # the same purpose; a plain prefixed string keeps our inline JS simple.
632
+ INVALID_SELECTOR_MARKER = "LIGHTPANDA_INVALID_SELECTOR:"
633
+
531
634
  # JS function for finding elements within a node.
532
- # Works in any execution context (top frame or iframe).
533
- FIND_WITHIN_JS = <<~JS
635
+ # Works in any execution context (top frame or iframe). Any throw from
636
+ # querySelectorAll means the selector is malformed (the spec only allows
637
+ # SYNTAX_ERR DOMException; Lightpanda's V8 currently throws a generic
638
+ # Error with messages like "InvalidClassSelector"). Re-throw with the
639
+ # marker prefix so Ruby converts to InvalidSelector regardless.
640
+ FIND_WITHIN_JS = <<~JS.freeze
534
641
  function(method, selector) {
535
642
  if (method === 'xpath') {
536
643
  if (typeof _lightpanda !== 'undefined') return _lightpanda.xpathFind(selector, this);
537
644
  return [];
538
645
  }
539
- try { return Array.from(this.querySelectorAll(selector)); } catch(e) { return []; }
646
+ try { return Array.from(this.querySelectorAll(selector)); }
647
+ catch(e) { throw new Error('#{INVALID_SELECTOR_MARKER}' + selector); }
540
648
  }
541
649
  JS
650
+ private_constant :FIND_WITHIN_JS
542
651
 
543
652
  # JS function for finding elements in an iframe's contentDocument.
544
- FIND_IN_FRAME_JS = <<~JS
653
+ FIND_IN_FRAME_JS = <<~JS.freeze
545
654
  function(method, selector) {
546
655
  var doc;
547
656
  try { doc = this.contentDocument || (this.contentWindow && this.contentWindow.document); } catch(e) {}
@@ -550,9 +659,11 @@ module Capybara
550
659
  if (typeof _lightpanda !== 'undefined') return _lightpanda.xpathFind(selector, doc);
551
660
  return [];
552
661
  }
553
- try { return Array.from(doc.querySelectorAll(selector)); } catch(e) { return []; }
662
+ try { return Array.from(doc.querySelectorAll(selector)); }
663
+ catch(e) { throw new Error('#{INVALID_SELECTOR_MARKER}' + selector); }
554
664
  }
555
665
  JS
666
+ private_constant :FIND_IN_FRAME_JS
556
667
 
557
668
  def find_in_document(method, selector)
558
669
  with_default_context_wait do
@@ -563,12 +674,18 @@ module Capybara
563
674
  js = if method == "xpath"
564
675
  "(typeof _lightpanda !== 'undefined') ? _lightpanda.xpathFind(#{selector_literal}, document) : []"
565
676
  else
566
- "(function() { try { return Array.from(document.querySelectorAll(#{selector_literal})); } " \
567
- "catch(e) { return []; } })()"
677
+ <<~CSS_FIND
678
+ (function() {
679
+ try { return Array.from(document.querySelectorAll(#{selector_literal})); }
680
+ catch(e) { throw new Error('#{INVALID_SELECTOR_MARKER}' + #{selector_literal}); }
681
+ })()
682
+ CSS_FIND
568
683
  end
569
684
  result = evaluate_with_ref(js)
570
685
  extract_node_object_ids(result)
571
686
  end
687
+ rescue JavaScriptError => e
688
+ raise_invalid_selector(e, method, selector)
572
689
  end
573
690
 
574
691
  def find_in_frame(method, selector)
@@ -576,6 +693,16 @@ module Capybara
576
693
  result = call_function_on(frame_node.remote_object_id, FIND_IN_FRAME_JS, method, selector,
577
694
  return_by_value: false)
578
695
  extract_node_object_ids(result)
696
+ rescue JavaScriptError => e
697
+ raise_invalid_selector(e, method, selector)
698
+ end
699
+
700
+ def raise_invalid_selector(js_error, method, selector)
701
+ if js_error.message.include?(INVALID_SELECTOR_MARKER)
702
+ raise InvalidSelector.new("Invalid #{method} selector: #{selector.inspect}", method, selector)
703
+ end
704
+
705
+ raise js_error
579
706
  end
580
707
 
581
708
  # Extract individual node objectIds from a remote array reference.
@@ -592,12 +719,13 @@ module Capybara
592
719
 
593
720
  release_object(result["objectId"])
594
721
  ids
595
- rescue StandardError
722
+ rescue Error
596
723
  []
597
724
  end
598
725
 
599
726
  def register_auto_scripts
600
727
  page_command("Page.addScriptToEvaluateOnNewDocument", source: XPathPolyfill::JS)
728
+ page_command("Page.addScriptToEvaluateOnNewDocument", source: XPathPolyfill::POLYFILLS_JS)
601
729
  end
602
730
 
603
731
  def subscribe_to_console_logs
@@ -716,7 +844,10 @@ module Capybara
716
844
  end
717
845
 
718
846
  def handle_evaluate_response(response)
719
- raise JavaScriptError, response if response["exceptionDetails"]
847
+ if response["exceptionDetails"]
848
+ debug_js_failure("handle_evaluate_response", "(unknown — already-issued call)", response)
849
+ raise JavaScriptError, response
850
+ end
720
851
 
721
852
  result = response["result"]
722
853
  return nil if result["type"] == "undefined"
@@ -738,7 +869,10 @@ module Capybara
738
869
  arguments: args.map { |a| serialize_argument(a) },
739
870
  }
740
871
  response = page_command("Runtime.callFunctionOn", **params)
741
- raise JavaScriptError, response if response["exceptionDetails"]
872
+ if response["exceptionDetails"]
873
+ debug_js_failure("call_with_args", function_declaration, response)
874
+ raise JavaScriptError, response
875
+ end
742
876
 
743
877
  return_by_value ? handle_evaluate_response(response) : unwrap_call_result(response["result"])
744
878
  end
@@ -839,6 +973,21 @@ module Capybara
839
973
  nil
840
974
  end
841
975
 
976
+ def dispose_browser_context
977
+ return unless @browser_context_id
978
+
979
+ begin
980
+ @client.command("Target.disposeBrowserContext", { browserContextId: @browser_context_id })
981
+ rescue StandardError
982
+ # Context may already be disposed or the WS may be down; we
983
+ # recreate either way.
984
+ ensure
985
+ @browser_context_id = nil
986
+ @target_id = nil
987
+ @session_id = nil
988
+ end
989
+ end
990
+
842
991
  def restart_process_if_dead
843
992
  return unless @process && !@process.alive?
844
993
 
@@ -927,7 +1076,7 @@ module Capybara
927
1076
 
928
1077
  url_changed = starting_url.nil? || state["u"] != starting_url
929
1078
  url_changed && %w[complete interactive].include?(state["r"])
930
- rescue StandardError
1079
+ rescue Error
931
1080
  false
932
1081
  end
933
1082
 
@@ -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,212 @@
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 #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
+
80
+ // ── Bug #4 — HTMLDialogElement.{showModal, show, close} non implémentés ──
81
+ // https://html.spec.whatwg.org/multipage/interactive-elements.html#the-dialog-element
82
+ if (typeof HTMLDialogElement !== "undefined") {
83
+ var dproto = HTMLDialogElement.prototype;
84
+ if (typeof dproto.showModal !== "function") {
85
+ dproto.showModal = function () {
86
+ if (this.hasAttribute("open")) {
87
+ throw new (window.DOMException || Error)(
88
+ "The element already has an 'open' attribute, and therefore cannot be opened modally.",
89
+ "InvalidStateError"
90
+ );
91
+ }
92
+ this.setAttribute("open", "");
93
+ };
94
+ }
95
+ if (typeof dproto.show !== "function") {
96
+ dproto.show = function () {
97
+ if (!this.hasAttribute("open")) this.setAttribute("open", "");
98
+ };
99
+ }
100
+ if (typeof dproto.close !== "function") {
101
+ dproto.close = function (returnValue) {
102
+ if (!this.hasAttribute("open")) return;
103
+ this.removeAttribute("open");
104
+ if (returnValue !== undefined) this.returnValue = String(returnValue);
105
+ this.dispatchEvent(new Event("close"));
106
+ };
107
+ }
108
+ }
109
+
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
+ }
154
+
155
+ function inScope(type, capture) {
156
+ return type === "submit" && capture === true;
157
+ }
158
+
159
+ var pending = []; // [{ target, type, fn, capture, cancelled }]
160
+ var flushScheduled = false;
161
+
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;
172
+ try {
173
+ origRemove.call(r.target, r.type, r.fn, r.capture);
174
+ } catch (_) {}
175
+ }
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
+ }
208
+ }
209
+ return origAdd.call(this, type, fn, opts);
210
+ };
211
+ })();
212
+ })();
@@ -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"
@@ -229,6 +237,8 @@ module Capybara
229
237
 
230
238
  def backend_node_id
231
239
  @backend_node_id ||= driver.browser.backend_node_id(@remote_object_id)
240
+ rescue BrowserError
241
+ nil
232
242
  end
233
243
 
234
244
  private
@@ -355,7 +365,93 @@ module Capybara
355
365
  end
356
366
  end
357
367
 
358
- CLICK_JS = "function() { this.click() }"
368
+ # We dispatch a `MouseEvent` (not a generic `Event`) because Turbo's link
369
+ # and form interceptors guard with `event instanceof MouseEvent` before
370
+ # they consider intercepting — a synthetic `Event('click')` is silently
371
+ # ignored by Turbo Frame / Drive, and CLICK_JS would then fall through to
372
+ # the manual default action below, which does a full-page navigation
373
+ # instead of a frame swap.
374
+ #
375
+ # For submit buttons (`<button type=submit>`, `<input type=submit>`,
376
+ # `<input type=image>`): route through `form.requestSubmit(this)` so the
377
+ # browser dispatches a real `SubmitEvent` with submitter set, honors the
378
+ # submitter's `formaction` / `formmethod` / `formenctype`, and includes
379
+ # the submitter's name/value in the form data. A manual
380
+ # `dispatchEvent(new Event('submit'))` + `form.submit()` would lose all of
381
+ # that and break Turbo Drive / Hotwire form handling. We can't rely on
382
+ # the synthetic click's default action because synthetic events don't
383
+ # trigger the implicit form-submission default action per DOM spec.
384
+ CLICK_JS = <<~JS
385
+ function() {
386
+ var EventCtor = (typeof MouseEvent !== 'undefined') ? MouseEvent : Event;
387
+ var clickEvt = new EventCtor('click', { bubbles: true, cancelable: true });
388
+ var notCancelled = this.dispatchEvent(clickEvt);
389
+ if (!notCancelled || clickEvt.defaultPrevented) return;
390
+ var tag = this.tagName;
391
+ var type = (this.type || '').toLowerCase();
392
+ var isSubmitButton =
393
+ (tag === 'BUTTON' && (type === 'submit' || type === '')) ||
394
+ (tag === 'INPUT' && (type === 'submit' || type === 'image'));
395
+ if (isSubmitButton && this.form) {
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
+ }
408
+ } else if (tag === 'A' && this.href && this.target !== '_blank') {
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
+ }
425
+ }
426
+ }
427
+ JS
428
+
429
+ # Mirrors Cuprite's trigger map. Picks the right Event constructor based
430
+ # on the event name so listeners that key on `event instanceof MouseEvent`
431
+ # / `instanceof SubmitEvent` see what they expect; everything else goes
432
+ # through a generic Event so custom names ("turbo:load", "lp:custom")
433
+ # still dispatch. Each constructor is feature-detected (`typeof X !==
434
+ # 'undefined'`) before use so a missing IDL on Lightpanda falls back
435
+ # to plain Event rather than throwing.
436
+ TRIGGER_JS = <<~JS
437
+ function(name) {
438
+ var MOUSE = ['click','dblclick','mousedown','mouseenter','mouseleave',
439
+ 'mousemove','mouseover','mouseout','mouseup','contextmenu'];
440
+ var FOCUS = ['blur','focus','focusin','focusout'];
441
+ var init = { bubbles: true, cancelable: true };
442
+ var event;
443
+ if (MOUSE.indexOf(name) !== -1 && typeof MouseEvent !== 'undefined') {
444
+ event = new MouseEvent(name, init);
445
+ } else if (FOCUS.indexOf(name) !== -1 && typeof FocusEvent !== 'undefined') {
446
+ event = new FocusEvent(name, init);
447
+ } else if (name === 'submit' && typeof SubmitEvent !== 'undefined') {
448
+ event = new SubmitEvent(name, init);
449
+ } else {
450
+ event = new Event(name, init);
451
+ }
452
+ this.dispatchEvent(event);
453
+ }
454
+ JS
359
455
 
360
456
  VISIBLE_JS = "function() { return _lightpanda.isVisible(this); }"
361
457
 
@@ -382,7 +478,14 @@ module Capybara
382
478
  autofocus: 'autofocus', required: 'required' };
383
479
  var prop = BOOL_PROP[name.toLowerCase()];
384
480
  if (prop && this[prop] !== undefined) return this[prop];
385
- return this.getAttribute(name);
481
+ if (this.hasAttribute(name)) return this.getAttribute(name);
482
+ // Property-only fallback: things like `validationMessage` have no
483
+ // backing HTML attribute. Return primitives only — DOM-node properties
484
+ // (form, options, etc.) shouldn't leak through.
485
+ var live = this[name];
486
+ if (live === null || live === undefined) return null;
487
+ var t = typeof live;
488
+ return (t === 'string' || t === 'number' || t === 'boolean') ? live : null;
386
489
  }
387
490
  JS
388
491
 
@@ -464,8 +567,6 @@ module Capybara
464
567
 
465
568
  SET_CHECKBOX_JS = <<~JS
466
569
  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
570
  if (this.checked !== value) this.click();
470
571
  }
471
572
  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),
@@ -22,10 +22,13 @@ 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
+ # 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")
29
32
 
30
33
  attr_reader :pid, :ws_url, :version, :nightly_build
31
34
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Capybara
4
4
  module Lightpanda
5
- VERSION = "0.2.0"
5
+ VERSION = "0.2.2"
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.2
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