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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +45 -0
- data/README.md +22 -179
- data/lib/capybara/lightpanda/binary.rb +1 -1
- data/lib/capybara/lightpanda/browser.rb +179 -85
- data/lib/capybara/lightpanda/client/subscriber.rb +29 -2
- data/lib/capybara/lightpanda/client/web_socket.rb +4 -2
- data/lib/capybara/lightpanda/client.rb +7 -0
- data/lib/capybara/lightpanda/cookies.rb +14 -53
- data/lib/capybara/lightpanda/driver.rb +43 -6
- data/lib/capybara/lightpanda/errors.rb +58 -15
- data/lib/capybara/lightpanda/javascripts/index.js +33 -143
- data/lib/capybara/lightpanda/javascripts/polyfills.js +81 -0
- data/lib/capybara/lightpanda/keyboard.rb +45 -4
- data/lib/capybara/lightpanda/network.rb +32 -2
- data/lib/capybara/lightpanda/node.rb +97 -209
- data/lib/capybara/lightpanda/options.rb +9 -1
- data/lib/capybara/lightpanda/process.rb +53 -13
- data/lib/capybara/lightpanda/utils/attempt.rb +30 -0
- data/lib/capybara/lightpanda/version.rb +1 -1
- data/lib/capybara/lightpanda/xpath_polyfill.rb +5 -0
- data/lib/capybara-lightpanda.rb +1 -0
- metadata +3 -1
|
@@ -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",
|
|
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
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
476
|
-
#
|
|
477
|
-
#
|
|
478
|
-
#
|
|
479
|
-
#
|
|
480
|
-
#
|
|
481
|
-
#
|
|
482
|
-
#
|
|
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(
|
|
551
|
+
def accept_modal(_type, text: nil)
|
|
498
552
|
prepare_modals
|
|
499
|
-
|
|
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(
|
|
558
|
+
def dismiss_modal(_type)
|
|
503
559
|
prepare_modals
|
|
504
|
-
|
|
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
|
-
|
|
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)); }
|
|
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)); }
|
|
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
|
-
|
|
578
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
815
|
-
|
|
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
|
-
|
|
998
|
+
begin
|
|
999
|
+
yield
|
|
896
1000
|
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|