capybara-lightpanda 0.1.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.
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "forwardable"
4
- require "uri"
5
4
  require "concurrent-ruby"
6
5
 
7
6
  module Capybara
@@ -9,7 +8,7 @@ module Capybara
9
8
  class Browser
10
9
  extend Forwardable
11
10
 
12
- 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
13
12
 
14
13
  delegate %i[on off] => :client
15
14
 
@@ -30,27 +29,19 @@ module Capybara
30
29
  @client = nil
31
30
  @target_id = nil
32
31
  @session_id = nil
32
+ @browser_context_id = nil
33
33
  @started = false
34
34
  @page_events_enabled = false
35
- @modal_responses = []
36
35
  @modal_messages = []
37
36
  @modal_handler_installed = false
38
37
  @frame_stack = []
39
38
  @frames = Concurrent::Hash.new
40
39
  @turbo_event = Utils::Event.new
41
40
  @turbo_event.set
42
- @visited_origins = Concurrent::Set.new
43
41
 
44
42
  start
45
43
  end
46
44
 
47
- # Set of `scheme://host:port` strings the browser has navigated to during
48
- # this session. Used by Cookies#clear to enumerate cookies across all
49
- # domains: Lightpanda's `Network.getCookies` (no urls param) is scoped
50
- # to the current page's origin, so without tracked origins we'd miss
51
- # cookies set on previously-visited domains.
52
- attr_reader :visited_origins
53
-
54
45
  def start
55
46
  return if @started
56
47
 
@@ -62,13 +53,25 @@ module Capybara
62
53
  @client = Client.new(@process.ws_url, @options)
63
54
  end
64
55
 
56
+ create_browser_context
65
57
  create_page
66
58
 
67
59
  @started = true
68
60
  end
69
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
+
70
72
  def create_page
71
- result = @client.command("Target.createTarget", { url: "about:blank" })
73
+ result = @client.command("Target.createTarget",
74
+ { url: "about:blank", browserContextId: @browser_context_id }.compact)
72
75
  @target_id = result["targetId"]
73
76
 
74
77
  attach_result = @client.command("Target.attachToTarget", { targetId: @target_id, flatten: true })
@@ -88,6 +91,33 @@ module Capybara
88
91
  start
89
92
  end
90
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
+
91
121
  # Recover after a WebSocket disconnect or process crash during navigation.
92
122
  # Restarts the process if it died, then creates a fresh client and page.
93
123
  def reconnect
@@ -98,6 +128,9 @@ module Capybara
98
128
  raise DeadBrowserError, "Cannot reconnect: no WebSocket URL" unless ws_url
99
129
 
100
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
101
134
  create_page
102
135
  @page_events_enabled = false
103
136
  end
@@ -116,6 +149,9 @@ module Capybara
116
149
  @client = nil
117
150
  @process = nil
118
151
  @started = false
152
+ @browser_context_id = nil
153
+ @target_id = nil
154
+ @session_id = nil
119
155
  @modal_handler_installed = false
120
156
  @frame_stack.clear
121
157
  end
@@ -150,8 +186,6 @@ module Capybara
150
186
  else
151
187
  page_command("Page.navigate", url: url)
152
188
  end
153
-
154
- record_visited_origin(url)
155
189
  end
156
190
  alias goto go_to
157
191
 
@@ -172,13 +206,14 @@ module Capybara
172
206
  # Run the block; if it raises NoExecutionContextError (the navigation
173
207
  # race window — lightpanda-io/browser#2187), wait for the next default
174
208
  # context to be signaled by Runtime.executionContextCreated, then
175
- # retry once. Replaces blind 100 ms sleep retries.
176
- def with_default_context_wait(timeout: 1.0)
177
- yield
178
- rescue NoExecutionContextError
179
- raise unless wait_for_default_context(timeout)
180
-
181
- yield
209
+ # retry. Up to `attempts` total tries; defaults to 3, can be bumped
210
+ # for stubborn flakes. Each retry blocks up to `timeout` seconds for
211
+ # the executionContextCreated signal — no blind sleeps.
212
+ def with_default_context_wait(timeout: 1.0, attempts: 3)
213
+ Utils::Attempt.with_retry(errors: NoExecutionContextError, max: attempts, wait: 0) do
214
+ wait_for_default_context(timeout)
215
+ yield
216
+ end
182
217
  end
183
218
 
184
219
  def back
@@ -215,7 +250,10 @@ module Capybara
215
250
  def evaluate(expression, *args)
216
251
  if args.empty?
217
252
  response = page_command("Runtime.evaluate", expression: expression, returnByValue: false, awaitPromise: true)
218
- raise JavaScriptError, response if response["exceptionDetails"]
253
+ if response["exceptionDetails"]
254
+ debug_js_failure("evaluate", expression, response)
255
+ raise JavaScriptError, response
256
+ end
219
257
 
220
258
  return unwrap_call_result(response["result"])
221
259
  end
@@ -236,6 +274,15 @@ module Capybara
236
274
  nil
237
275
  end
238
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
+
239
286
  # Evaluate async JS with a callback. The user's script receives
240
287
  # the callback as its last argument (`arguments[arguments.length - 1]`),
241
288
  # matching Capybara's evaluate_async_script contract.
@@ -260,7 +307,10 @@ module Capybara
260
307
  # Evaluate JS and return a RemoteObject reference (for DOM nodes, arrays).
261
308
  def evaluate_with_ref(expression)
262
309
  response = page_command("Runtime.evaluate", expression: expression, returnByValue: false, awaitPromise: true)
263
- 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
264
314
 
265
315
  result = response["result"]
266
316
  return nil if result["type"] == "undefined"
@@ -280,7 +330,10 @@ module Capybara
280
330
  params[:arguments] = args.map { |a| serialize_argument(a) } unless args.empty?
281
331
 
282
332
  response = page_command("Runtime.callFunctionOn", **params)
283
- 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
284
337
 
285
338
  result = response["result"]
286
339
  return nil if result["type"] == "undefined"
@@ -296,7 +349,7 @@ module Capybara
296
349
  # Release a remote object reference to free V8 memory.
297
350
  def release_object(remote_object_id)
298
351
  page_command("Runtime.releaseObject", objectId: remote_object_id)
299
- rescue BrowserError, NoExecutionContextError
352
+ rescue BrowserError
300
353
  # Object may already be released or context destroyed
301
354
  end
302
355
 
@@ -315,6 +368,8 @@ module Capybara
315
368
  def find_within(remote_object_id, method, selector)
316
369
  result = call_function_on(remote_object_id, FIND_WITHIN_JS, method, selector, return_by_value: false)
317
370
  extract_node_object_ids(result)
371
+ rescue JavaScriptError => e
372
+ raise_invalid_selector(e, method, selector)
318
373
  end
319
374
 
320
375
  # objectId of document.activeElement, or nil if none/document detached.
@@ -472,14 +527,14 @@ module Capybara
472
527
  end
473
528
 
474
529
  # -- Modal/Dialog Support --
475
- # Lightpanda auto-dismisses dialogs in headless mode: alert→OK,
476
- # confirm→false, prompt→null. Page.javascriptDialogOpening fires
477
- # (since 2026-04-03), so we capture messages for find_modal, but
478
- # Page.handleJavaScriptDialog always errors with "No dialog is showing"
479
- # and we never call it (the dispatch thread cannot make synchronous
480
- # CDP calls without deadlocking). @modal_responses is retained so
481
- # accept_modal/dismiss_modal preserve their API contract; the
482
- # accept/dismiss choice is informational only.
530
+ # Lightpanda's JS dialogs (alert/confirm/prompt) are driven via the
531
+ # `LP.handleJavaScriptDialog` pre-arm model (PR #2261, nightly ≥5900):
532
+ # the client sends `LP.handleJavaScriptDialog {accept, promptText}`
533
+ # BEFORE the action that triggers the dialog, and the response is
534
+ # consumed when the dialog opens. `Page.javascriptDialogOpening` still
535
+ # fires, so we capture the message text for `find_modal`. Single-shot:
536
+ # `pending_dialog_response` is one slot, so a second pre-arm before
537
+ # the first dialog opens overwrites the first.
483
538
 
484
539
  def prepare_modals
485
540
  return if @modal_handler_installed
@@ -488,20 +543,21 @@ module Capybara
488
543
 
489
544
  on("Page.javascriptDialogOpening") do |params|
490
545
  @modal_messages << { type: params["type"], message: params["message"] }
491
- @modal_responses.shift
492
546
  end
493
547
 
494
548
  @modal_handler_installed = true
495
549
  end
496
550
 
497
- def accept_modal(type, text: nil)
551
+ def accept_modal(_type, text: nil)
498
552
  prepare_modals
499
- @modal_responses << { accept: true, text: text, type: type.to_s }
553
+ params = { accept: true }
554
+ params[:promptText] = text if text
555
+ page_command("LP.handleJavaScriptDialog", **params)
500
556
  end
501
557
 
502
- def dismiss_modal(type)
558
+ def dismiss_modal(_type)
503
559
  prepare_modals
504
- @modal_responses << { accept: false, type: type.to_s }
560
+ page_command("LP.handleJavaScriptDialog", accept: false)
505
561
  end
506
562
 
507
563
  def find_modal(type, text: nil, wait: options.timeout)
@@ -525,7 +581,6 @@ module Capybara
525
581
  end
526
582
 
527
583
  def reset_modals
528
- @modal_responses.clear
529
584
  @modal_messages.clear
530
585
  end
531
586
 
@@ -539,20 +594,32 @@ module Capybara
539
594
  raise Capybara::ModalNotFound, "Unable to find modal dialog#{" with #{text}" if text}"
540
595
  end
541
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
+
542
603
  # JS function for finding elements within a node.
543
- # Works in any execution context (top frame or iframe).
544
- 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
545
610
  function(method, selector) {
546
611
  if (method === 'xpath') {
547
612
  if (typeof _lightpanda !== 'undefined') return _lightpanda.xpathFind(selector, this);
548
613
  return [];
549
614
  }
550
- 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); }
551
617
  }
552
618
  JS
619
+ private_constant :FIND_WITHIN_JS
553
620
 
554
621
  # JS function for finding elements in an iframe's contentDocument.
555
- FIND_IN_FRAME_JS = <<~JS
622
+ FIND_IN_FRAME_JS = <<~JS.freeze
556
623
  function(method, selector) {
557
624
  var doc;
558
625
  try { doc = this.contentDocument || (this.contentWindow && this.contentWindow.document); } catch(e) {}
@@ -561,9 +628,11 @@ module Capybara
561
628
  if (typeof _lightpanda !== 'undefined') return _lightpanda.xpathFind(selector, doc);
562
629
  return [];
563
630
  }
564
- 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); }
565
633
  }
566
634
  JS
635
+ private_constant :FIND_IN_FRAME_JS
567
636
 
568
637
  def find_in_document(method, selector)
569
638
  with_default_context_wait do
@@ -574,12 +643,18 @@ module Capybara
574
643
  js = if method == "xpath"
575
644
  "(typeof _lightpanda !== 'undefined') ? _lightpanda.xpathFind(#{selector_literal}, document) : []"
576
645
  else
577
- "(function() { try { return Array.from(document.querySelectorAll(#{selector_literal})); } " \
578
- "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
579
652
  end
580
653
  result = evaluate_with_ref(js)
581
654
  extract_node_object_ids(result)
582
655
  end
656
+ rescue JavaScriptError => e
657
+ raise_invalid_selector(e, method, selector)
583
658
  end
584
659
 
585
660
  def find_in_frame(method, selector)
@@ -587,6 +662,16 @@ module Capybara
587
662
  result = call_function_on(frame_node.remote_object_id, FIND_IN_FRAME_JS, method, selector,
588
663
  return_by_value: false)
589
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
590
675
  end
591
676
 
592
677
  # Extract individual node objectIds from a remote array reference.
@@ -603,12 +688,13 @@ module Capybara
603
688
 
604
689
  release_object(result["objectId"])
605
690
  ids
606
- rescue StandardError
691
+ rescue Error
607
692
  []
608
693
  end
609
694
 
610
695
  def register_auto_scripts
611
696
  page_command("Page.addScriptToEvaluateOnNewDocument", source: XPathPolyfill::JS)
697
+ page_command("Page.addScriptToEvaluateOnNewDocument", source: XPathPolyfill::POLYFILLS_JS)
612
698
  end
613
699
 
614
700
  def subscribe_to_console_logs
@@ -727,7 +813,10 @@ module Capybara
727
813
  end
728
814
 
729
815
  def handle_evaluate_response(response)
730
- 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
731
820
 
732
821
  result = response["result"]
733
822
  return nil if result["type"] == "undefined"
@@ -749,7 +838,10 @@ module Capybara
749
838
  arguments: args.map { |a| serialize_argument(a) },
750
839
  }
751
840
  response = page_command("Runtime.callFunctionOn", **params)
752
- 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
753
845
 
754
846
  return_by_value ? handle_evaluate_response(response) : unwrap_call_result(response["result"])
755
847
  end
@@ -811,23 +903,9 @@ module Capybara
811
903
  end
812
904
 
813
905
  def wait_for_page_load(url, retried:)
814
- starting_url = safe_current_url
815
- deadline = monotonic_time + @options.timeout
816
- loaded = Utils::Event.new
817
-
818
- handler = proc { loaded.set }
819
- @client.on("Page.loadEventFired", &handler)
820
-
821
- @client.command("Page.navigate", { url: url }, async: true, session_id: @session_id)
822
-
823
- # Give loadEventFired a brief window (fast path), then fall back
824
- # to readyState polling with the remaining budget.
825
- unless loaded.wait([2, @options.timeout].min)
826
- remaining = deadline - monotonic_time
827
- poll_ready_state(remaining, loaded_event: loaded, starting_url: starting_url) if remaining.positive?
906
+ deadline = await_navigation do
907
+ @client.command("Page.navigate", { url: url }, async: true, session_id: @session_id)
828
908
  end
829
-
830
- @client.off("Page.loadEventFired", handler)
831
909
  handle_navigation_crash(url, deadline, retried: retried)
832
910
  end
833
911
 
@@ -864,6 +942,21 @@ module Capybara
864
942
  nil
865
943
  end
866
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
+
867
960
  def restart_process_if_dead
868
961
  return unless @process && !@process.alive?
869
962
 
@@ -883,23 +976,37 @@ module Capybara
883
976
 
884
977
  # Wait for a navigation triggered by the given block.
885
978
  # Uses the same loadEventFired + readyState fallback as go_to.
886
- def wait_for_navigation
979
+ def wait_for_navigation(&)
887
980
  enable_page_events
981
+ await_navigation(&)
982
+ end
888
983
 
984
+ # Common navigation lifecycle shared by `wait_for_page_load` (fresh
985
+ # `Page.navigate`) and `wait_for_navigation` (back / forward / reload).
986
+ # Subscribes to Page.loadEventFired, runs the trigger, waits briefly for
987
+ # the event, falls back to readyState polling for the remaining budget.
988
+ # The handler is unsubscribed via `ensure` so a raising trigger doesn't
989
+ # leak a subscription onto the next navigation. Returns the deadline so
990
+ # the caller can decide whether to attempt crash recovery.
991
+ def await_navigation
889
992
  starting_url = safe_current_url
890
993
  deadline = monotonic_time + @options.timeout
891
994
  loaded = Utils::Event.new
892
995
  handler = proc { loaded.set }
893
996
  @client.on("Page.loadEventFired", &handler)
894
997
 
895
- yield
998
+ begin
999
+ yield
896
1000
 
897
- unless loaded.wait([2, @options.timeout].min)
898
- remaining = deadline - monotonic_time
899
- poll_ready_state(remaining, loaded_event: loaded, starting_url: starting_url) if remaining.positive?
1001
+ unless loaded.wait([2, @options.timeout].min)
1002
+ remaining = deadline - monotonic_time
1003
+ poll_ready_state(remaining, loaded_event: loaded, starting_url: starting_url) if remaining.positive?
1004
+ end
1005
+ ensure
1006
+ @client.off("Page.loadEventFired", handler)
900
1007
  end
901
1008
 
902
- @client.off("Page.loadEventFired", handler)
1009
+ deadline
903
1010
  end
904
1011
 
905
1012
  # Poll document.readyState as a fallback when Page.loadEventFired
@@ -938,26 +1045,13 @@ module Capybara
938
1045
 
939
1046
  url_changed = starting_url.nil? || state["u"] != starting_url
940
1047
  url_changed && %w[complete interactive].include?(state["r"])
941
- rescue StandardError
1048
+ rescue Error
942
1049
  false
943
1050
  end
944
1051
 
945
1052
  def monotonic_time
946
1053
  ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
947
1054
  end
948
-
949
- # Capture `scheme://host:port` from a navigated URL so Cookies#clear can
950
- # enumerate cookies across all visited domains. Skips opaque URLs
951
- # (about:blank, data:, etc.) and any URI parser failure.
952
- def record_visited_origin(url)
953
- uri = URI.parse(url)
954
- return unless uri.scheme && uri.host
955
-
956
- port = uri.port || (uri.scheme == "https" ? 443 : 80)
957
- @visited_origins << "#{uri.scheme}://#{uri.host}:#{port}"
958
- rescue URI::InvalidURIError, NoMethodError
959
- nil
960
- end
961
1055
  end
962
1056
  end
963
1057
  end
@@ -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)
@@ -133,7 +133,7 @@ module Capybara
133
133
  def read_handshake_response
134
134
  started_at = Time.now
135
135
 
136
- while @status != :open && Time.now - started_at < @options.timeout
136
+ while @status != :open && Time.now - started_at < @options.handshake_timeout
137
137
  next unless @socket.wait_readable(0.1)
138
138
 
139
139
  begin
@@ -144,7 +144,9 @@ module Capybara
144
144
  end
145
145
  end
146
146
 
147
- raise TimeoutError, "WebSocket connection timeout" unless @status == :open
147
+ return if @status == :open
148
+
149
+ raise TimeoutError, "WebSocket handshake timed out after #{@options.handshake_timeout}s"
148
150
  end
149
151
 
150
152
  def parse_message(data)
@@ -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