capybara-lightpanda 0.8.0 → 0.10.0
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 +62 -0
- data/README.md +14 -1
- data/lib/capybara/lightpanda/auto_scripts.rb +30 -2
- data/lib/capybara/lightpanda/binary.rb +72 -54
- data/lib/capybara/lightpanda/browser/console.rb +190 -0
- data/lib/capybara/lightpanda/browser/finder.rb +196 -0
- data/lib/capybara/lightpanda/browser/modals.rb +150 -0
- data/lib/capybara/lightpanda/browser/navigation.rb +186 -0
- data/lib/capybara/lightpanda/browser/runtime.rb +258 -0
- data/lib/capybara/lightpanda/browser/selenium_compat.rb +124 -0
- data/lib/capybara/lightpanda/browser.rb +158 -813
- data/lib/capybara/lightpanda/client/subscriber.rb +2 -0
- data/lib/capybara/lightpanda/client/web_socket.rb +19 -21
- data/lib/capybara/lightpanda/client.rb +5 -4
- data/lib/capybara/lightpanda/downloads.rb +176 -0
- data/lib/capybara/lightpanda/driver.rb +124 -32
- data/lib/capybara/lightpanda/errors.rb +24 -10
- data/lib/capybara/lightpanda/javascripts/attach.js +16 -0
- data/lib/capybara/lightpanda/javascripts/banner.js +15 -0
- data/lib/capybara/lightpanda/javascripts/errors.js +74 -0
- data/lib/capybara/lightpanda/javascripts/predicates.js +116 -0
- data/lib/capybara/lightpanda/javascripts/turbo.js +67 -0
- data/lib/capybara/lightpanda/keyboard.rb +23 -19
- data/lib/capybara/lightpanda/network.rb +103 -18
- data/lib/capybara/lightpanda/node.rb +200 -95
- data/lib/capybara/lightpanda/options.rb +44 -3
- data/lib/capybara/lightpanda/process.rb +154 -29
- data/lib/capybara/lightpanda/version.rb +1 -1
- data/lib/capybara-lightpanda.rb +1 -0
- metadata +14 -3
- data/lib/capybara/lightpanda/javascripts/index.js +0 -226
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Capybara
|
|
4
|
+
module Lightpanda
|
|
5
|
+
class Browser
|
|
6
|
+
# Element finding in the three dispatch contexts (document, node-
|
|
7
|
+
# scoped, iframe) plus the shared XPath/CSS find fragments and
|
|
8
|
+
# InvalidSelector translation.
|
|
9
|
+
module Finder
|
|
10
|
+
# Find elements in the current context (top frame or active frame).
|
|
11
|
+
# Returns an array of remote object ID strings.
|
|
12
|
+
def find(method, selector)
|
|
13
|
+
if @frame_stack.empty?
|
|
14
|
+
find_in_document(method, selector)
|
|
15
|
+
else
|
|
16
|
+
find_in_frame(method, selector)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Find child elements within a specific node.
|
|
21
|
+
# Returns an array of remote object ID strings.
|
|
22
|
+
#
|
|
23
|
+
# Wrapped in `with_default_context_wait` so a click that triggered a
|
|
24
|
+
# navigation immediately before the find (e.g. a fill_in following a
|
|
25
|
+
# link that mutated the DOM) doesn't race against
|
|
26
|
+
# `Runtime.executionContextCreated` and surface as
|
|
27
|
+
# `NoExecutionContextError`. `find_in_document` and `find_in_frame`
|
|
28
|
+
# already use the same wrapper; `find_within` was the odd one out.
|
|
29
|
+
def find_within(remote_object_id, method, selector)
|
|
30
|
+
with_default_context_wait do
|
|
31
|
+
result = call_function_on(remote_object_id, FIND_WITHIN_JS, method, selector, return_by_value: false)
|
|
32
|
+
extract_node_object_ids(result)
|
|
33
|
+
end
|
|
34
|
+
rescue JavaScriptError => e
|
|
35
|
+
raise_invalid_selector(e, method, selector)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Ancestor chain of `remote_object_id` from parentNode up to (but
|
|
39
|
+
# excluding) `document`, returned as an array of remote object IDs.
|
|
40
|
+
# Mirrors Cuprite's JS `parents` helper. Same `with_default_context_wait`
|
|
41
|
+
# wrapping as `find_within` — same race window applies.
|
|
42
|
+
def parents_of(remote_object_id)
|
|
43
|
+
with_default_context_wait do
|
|
44
|
+
result = call_function_on(remote_object_id, PARENTS_JS, return_by_value: false)
|
|
45
|
+
extract_node_object_ids(result)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
# Sentinel string thrown from FIND_*_JS when querySelectorAll rejects a
|
|
52
|
+
# malformed selector, so the Ruby side can convert JavaScriptError into
|
|
53
|
+
# Capybara::Lightpanda::InvalidSelector. Cuprite uses a JS subclass for
|
|
54
|
+
# the same purpose; a plain prefixed string keeps our inline JS simple.
|
|
55
|
+
INVALID_SELECTOR_MARKER = "LIGHTPANDA_INVALID_SELECTOR:"
|
|
56
|
+
|
|
57
|
+
# The find algorithms exist in three dispatch contexts — element-scoped
|
|
58
|
+
# (FIND_WITHIN_JS), iframe-scoped (FIND_IN_FRAME_JS), and document-scoped
|
|
59
|
+
# (find_in_document's Runtime.evaluate fast path) — that differ only in
|
|
60
|
+
# how the document/root/selector expressions are derived. Each algorithm
|
|
61
|
+
# is defined ONCE here and instantiated per context via format(), so a
|
|
62
|
+
# fix (e.g. a new XPath error case) can't silently miss a copy.
|
|
63
|
+
#
|
|
64
|
+
# XPath routes through native `Document.evaluate` + `XPathResult`
|
|
65
|
+
# (Lightpanda PR #2305, in nightly >=6109); on parse error we return
|
|
66
|
+
# [] silently to match Capybara's internal XPath generator, which
|
|
67
|
+
# sometimes produces selectors with empty trailing predicates like
|
|
68
|
+
# `(...)[]` that native rejects but `has_element?` expects to behave
|
|
69
|
+
# as "not found" rather than raise InvalidSelector.
|
|
70
|
+
# `XPathResult.ORDERED_NODE_SNAPSHOT_TYPE` is `7` in the spec — inlined
|
|
71
|
+
# so the JS doesn't depend on the enum being defined as a constant.
|
|
72
|
+
XPATH_FIND_FRAGMENT = <<~JS
|
|
73
|
+
try {
|
|
74
|
+
var r = %<doc>s.evaluate(%<selector>s, %<root>s, null, 7, null);
|
|
75
|
+
var nodes = [];
|
|
76
|
+
for (var i = 0; i < r.snapshotLength; i++) nodes.push(r.snapshotItem(i));
|
|
77
|
+
return nodes;
|
|
78
|
+
} catch(e) { return []; }
|
|
79
|
+
JS
|
|
80
|
+
|
|
81
|
+
# For CSS, any throw from querySelectorAll means the selector is
|
|
82
|
+
# malformed — re-throw with the marker prefix so Ruby converts to
|
|
83
|
+
# InvalidSelector.
|
|
84
|
+
CSS_FIND_FRAGMENT = <<~JS.freeze
|
|
85
|
+
try { return Array.from(%<target>s.querySelectorAll(%<selector>s)); }
|
|
86
|
+
catch(e) { throw new Error('#{INVALID_SELECTOR_MARKER}' + %<selector>s); }
|
|
87
|
+
JS
|
|
88
|
+
private_constant :XPATH_FIND_FRAGMENT, :CSS_FIND_FRAGMENT
|
|
89
|
+
|
|
90
|
+
# JS function for finding elements within a node.
|
|
91
|
+
# Works in any execution context (top frame or iframe).
|
|
92
|
+
FIND_WITHIN_JS = <<~JS.freeze
|
|
93
|
+
function(method, selector) {
|
|
94
|
+
if (method === 'xpath') {
|
|
95
|
+
#{format(XPATH_FIND_FRAGMENT, doc: 'this.ownerDocument', root: 'this', selector: 'selector')}
|
|
96
|
+
}
|
|
97
|
+
#{format(CSS_FIND_FRAGMENT, target: 'this', selector: 'selector')}
|
|
98
|
+
}
|
|
99
|
+
JS
|
|
100
|
+
private_constant :FIND_WITHIN_JS
|
|
101
|
+
|
|
102
|
+
# JS function for finding elements in an iframe's contentDocument.
|
|
103
|
+
FIND_IN_FRAME_JS = <<~JS.freeze
|
|
104
|
+
function(method, selector) {
|
|
105
|
+
var doc;
|
|
106
|
+
try { doc = this.contentDocument || (this.contentWindow && this.contentWindow.document); } catch(e) {}
|
|
107
|
+
if (!doc) return [];
|
|
108
|
+
if (method === 'xpath') {
|
|
109
|
+
#{format(XPATH_FIND_FRAGMENT, doc: 'doc', root: 'doc', selector: 'selector')}
|
|
110
|
+
}
|
|
111
|
+
#{format(CSS_FIND_FRAGMENT, target: 'doc', selector: 'selector')}
|
|
112
|
+
}
|
|
113
|
+
JS
|
|
114
|
+
private_constant :FIND_IN_FRAME_JS
|
|
115
|
+
|
|
116
|
+
# Walks `parentNode` from `this` up to (but excluding) `document`,
|
|
117
|
+
# returning the chain as a JS array. Each entry is an element node so
|
|
118
|
+
# `extract_node_object_ids` can wrap them as Lightpanda::Nodes.
|
|
119
|
+
PARENTS_JS = <<~JS
|
|
120
|
+
function() {
|
|
121
|
+
var nodes = [];
|
|
122
|
+
var p = this.parentNode;
|
|
123
|
+
while (p && p !== this.ownerDocument) {
|
|
124
|
+
nodes.push(p);
|
|
125
|
+
p = p.parentNode;
|
|
126
|
+
}
|
|
127
|
+
return nodes;
|
|
128
|
+
}
|
|
129
|
+
JS
|
|
130
|
+
private_constant :PARENTS_JS
|
|
131
|
+
|
|
132
|
+
def find_in_document(method, selector)
|
|
133
|
+
with_default_context_wait do
|
|
134
|
+
# Coerce Symbol selectors (e.g. Capybara warning path lets `have_css(:p)`
|
|
135
|
+
# through) to a string before quoting. Symbol#inspect returns `:p`,
|
|
136
|
+
# which would inject a bare token into the JS source.
|
|
137
|
+
selector_literal = selector.to_s.inspect
|
|
138
|
+
# Same fragments as FIND_WITHIN_JS/FIND_IN_FRAME_JS, instantiated
|
|
139
|
+
# with the selector embedded as a literal: this hot path keeps its
|
|
140
|
+
# single Runtime.evaluate round-trip (no document-handle resolution).
|
|
141
|
+
fragment = if method == "xpath"
|
|
142
|
+
format(XPATH_FIND_FRAGMENT, doc: "document", root: "document", selector: selector_literal)
|
|
143
|
+
else
|
|
144
|
+
format(CSS_FIND_FRAGMENT, target: "document", selector: selector_literal)
|
|
145
|
+
end
|
|
146
|
+
result = evaluate_with_ref("(function() { #{fragment} })()")
|
|
147
|
+
extract_node_object_ids(result)
|
|
148
|
+
end
|
|
149
|
+
rescue JavaScriptError => e
|
|
150
|
+
raise_invalid_selector(e, method, selector)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def find_in_frame(method, selector)
|
|
154
|
+
with_default_context_wait do
|
|
155
|
+
frame_node = @frame_stack.last
|
|
156
|
+
result = call_function_on(frame_node.remote_object_id, FIND_IN_FRAME_JS, method, selector,
|
|
157
|
+
return_by_value: false)
|
|
158
|
+
extract_node_object_ids(result)
|
|
159
|
+
end
|
|
160
|
+
rescue JavaScriptError => e
|
|
161
|
+
raise_invalid_selector(e, method, selector)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def raise_invalid_selector(js_error, method, selector)
|
|
165
|
+
if js_error.message.include?(INVALID_SELECTOR_MARKER)
|
|
166
|
+
raise InvalidSelector.new("Invalid #{method} selector: #{selector.inspect}", method, selector)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
raise js_error
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Extract individual node objectIds from a remote array reference.
|
|
173
|
+
# `ensure release_object` so the outer array handle is freed even when
|
|
174
|
+
# property walking raises — without this, a transient CDP error during
|
|
175
|
+
# property enumeration leaks one V8 handle per failed find call.
|
|
176
|
+
def extract_node_object_ids(result)
|
|
177
|
+
return [] unless result && result["objectId"]
|
|
178
|
+
|
|
179
|
+
outer_id = result["objectId"]
|
|
180
|
+
begin
|
|
181
|
+
props = get_object_properties(outer_id)
|
|
182
|
+
properties = props["result"] || []
|
|
183
|
+
properties
|
|
184
|
+
.select { |p| p["name"] =~ /\A\d+\z/ }
|
|
185
|
+
.sort_by { |p| p["name"].to_i }
|
|
186
|
+
.filter_map { |p| p.dig("value", "objectId") }
|
|
187
|
+
rescue Error
|
|
188
|
+
[]
|
|
189
|
+
ensure
|
|
190
|
+
release_object(outer_id)
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Capybara
|
|
4
|
+
module Lightpanda
|
|
5
|
+
class Browser
|
|
6
|
+
# JS dialog handling via Lightpanda's LP.handleJavaScriptDialog
|
|
7
|
+
# pre-arm model (PR #2261): accept/dismiss are sent BEFORE the
|
|
8
|
+
# triggering action; Page.javascriptDialogOpening supplies the text.
|
|
9
|
+
module Modals
|
|
10
|
+
# -- Modal/Dialog Support --
|
|
11
|
+
# Lightpanda's JS dialogs (alert/confirm/prompt) are driven via the
|
|
12
|
+
# `LP.handleJavaScriptDialog` pre-arm model (PR #2261, nightly ≥5900):
|
|
13
|
+
# the client sends `LP.handleJavaScriptDialog {accept, promptText}`
|
|
14
|
+
# BEFORE the action that triggers the dialog, and the response is
|
|
15
|
+
# consumed when the dialog opens. `Page.javascriptDialogOpening` still
|
|
16
|
+
# fires, so we capture the message text for `find_modal`. Single-shot:
|
|
17
|
+
# `pending_dialog_response` is one slot, so a second pre-arm before
|
|
18
|
+
# the first dialog opens overwrites the first.
|
|
19
|
+
|
|
20
|
+
def prepare_modals
|
|
21
|
+
return if @modal_handler_installed
|
|
22
|
+
|
|
23
|
+
enable_page_events
|
|
24
|
+
|
|
25
|
+
on("Page.javascriptDialogOpening") do |params|
|
|
26
|
+
entry = { type: params["type"], message: params["message"] }
|
|
27
|
+
@modal_messages_mutex.synchronize do
|
|
28
|
+
@modal_messages << entry
|
|
29
|
+
# The pre-arm slot is single-shot on both sides: Lightpanda
|
|
30
|
+
# consumes its stashed response on this dialog, so we consume
|
|
31
|
+
# ours. No pre-arm in flight means Lightpanda just applied its
|
|
32
|
+
# silent default — remember it for the main thread; raising here
|
|
33
|
+
# would only die inside the subscriber.
|
|
34
|
+
if @modal_armed
|
|
35
|
+
@modal_armed = false
|
|
36
|
+
else
|
|
37
|
+
@unhandled_modal ||= entry
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
@modal_handler_installed = true
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def accept_modal(_type, text: nil)
|
|
46
|
+
prepare_modals
|
|
47
|
+
params = { accept: true }
|
|
48
|
+
params[:promptText] = text if text
|
|
49
|
+
arm_modal { page_command("LP.handleJavaScriptDialog", **params) }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def dismiss_modal(_type)
|
|
53
|
+
prepare_modals
|
|
54
|
+
arm_modal { page_command("LP.handleJavaScriptDialog", accept: false) }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Surface a dialog that opened with no pre-arm. Called from the main
|
|
58
|
+
# thread after every action that can open one (Browser#wait_for_idle,
|
|
59
|
+
# #go_to). Warns by default; raises when the driver was built with
|
|
60
|
+
# `raise_on_unhandled_modal: true` (Cuprite's option name). Either way
|
|
61
|
+
# the dialog is already gone — Lightpanda's default resolved it.
|
|
62
|
+
def check_unhandled_modal!
|
|
63
|
+
entry = @modal_messages_mutex.synchronize do
|
|
64
|
+
e = @unhandled_modal
|
|
65
|
+
@unhandled_modal = nil
|
|
66
|
+
e
|
|
67
|
+
end
|
|
68
|
+
return unless entry
|
|
69
|
+
|
|
70
|
+
message = unhandled_modal_message(entry)
|
|
71
|
+
raise UnhandledModalError, message if @options.raise_on_unhandled_modal
|
|
72
|
+
|
|
73
|
+
warn "[capybara-lightpanda] #{message}"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# `type` is accepted for the error message only: like Selenium (where
|
|
77
|
+
# alert/confirm are indistinguishable) and Cuprite (whose dialog handler
|
|
78
|
+
# accepts whatever fires), we deliberately do NOT reject a dialog whose
|
|
79
|
+
# reported type differs from the one Capybara asked for. Real suites
|
|
80
|
+
# wrap `data-confirm` deletes in `accept_alert` (e.g. solidus admin) and
|
|
81
|
+
# expect it to work; only the message text is matched.
|
|
82
|
+
def find_modal(type, text: nil, wait: options.timeout)
|
|
83
|
+
regexp = text.is_a?(Regexp) ? text : (text && Regexp.new(Regexp.escape(text.to_s)))
|
|
84
|
+
last_seen_message = nil
|
|
85
|
+
claimed = nil
|
|
86
|
+
Utils::Wait.until(timeout: wait, interval: 0.05) do
|
|
87
|
+
claimed = pop_modal_message(regexp)
|
|
88
|
+
next true if claimed
|
|
89
|
+
|
|
90
|
+
last_seen_message = peek_last_modal_message || last_seen_message
|
|
91
|
+
false
|
|
92
|
+
end
|
|
93
|
+
claimed[:message]
|
|
94
|
+
rescue TimeoutError
|
|
95
|
+
raise_modal_not_found(type, text, last_seen_message)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
def arm_modal
|
|
101
|
+
@modal_messages_mutex.synchronize { @modal_armed = true }
|
|
102
|
+
yield
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
LIGHTPANDA_DIALOG_DEFAULTS = {
|
|
106
|
+
"confirm" => "cancelled it",
|
|
107
|
+
"prompt" => "answered null",
|
|
108
|
+
"beforeunload" => "cancelled it",
|
|
109
|
+
}.freeze
|
|
110
|
+
private_constant :LIGHTPANDA_DIALOG_DEFAULTS
|
|
111
|
+
|
|
112
|
+
def unhandled_modal_message(entry)
|
|
113
|
+
with_text = entry[:message] ? " with text `#{entry[:message]}`" : ""
|
|
114
|
+
default = LIGHTPANDA_DIALOG_DEFAULTS.fetch(entry[:type].to_s, "dismissed it")
|
|
115
|
+
"A #{entry[:type]} dialog#{with_text} opened, but the action was not wrapped in " \
|
|
116
|
+
"accept_alert / accept_confirm / dismiss_confirm / accept_prompt / dismiss_prompt " \
|
|
117
|
+
"— Lightpanda #{default} by default"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Pop the first queued dialog whose message matches the requested
|
|
121
|
+
# pattern (any dialog when `regexp` is nil). Returns the entry or nil.
|
|
122
|
+
# Serialized with the message-thread writer.
|
|
123
|
+
def pop_modal_message(regexp)
|
|
124
|
+
@modal_messages_mutex.synchronize do
|
|
125
|
+
match = @modal_messages.find do |m|
|
|
126
|
+
regexp.nil? || m[:message].to_s.match?(regexp)
|
|
127
|
+
end
|
|
128
|
+
@modal_messages.delete(match) if match
|
|
129
|
+
match
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Most recent dialog message of any type, for diagnostics.
|
|
134
|
+
def peek_last_modal_message
|
|
135
|
+
@modal_messages_mutex.synchronize { @modal_messages.last&.dig(:message) }
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def raise_modal_not_found(type, text, last_seen_message)
|
|
139
|
+
if last_seen_message
|
|
140
|
+
raise Capybara::ModalNotFound,
|
|
141
|
+
"Unable to find #{type} modal with #{text} - found '#{last_seen_message}' instead."
|
|
142
|
+
end
|
|
143
|
+
raise Capybara::ModalNotFound, "Unable to find modal dialog#{" with #{text}" if text}"
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
private :prepare_modals
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Capybara
|
|
4
|
+
module Lightpanda
|
|
5
|
+
class Browser
|
|
6
|
+
# Navigation lifecycle: go_to / back / forward / refresh and the
|
|
7
|
+
# Page.loadEventFired + readyState-polling machinery behind them
|
|
8
|
+
# (the polling fallback is load-bearing — see CLAUDE.md).
|
|
9
|
+
module Navigation
|
|
10
|
+
# Navigation with readyState fallback.
|
|
11
|
+
#
|
|
12
|
+
# Lightpanda may never fire Page.loadEventFired on complex JS pages
|
|
13
|
+
# (lightpanda-io/browser#1801, #1832). When the event times out,
|
|
14
|
+
# we poll document.readyState as a fallback.
|
|
15
|
+
#
|
|
16
|
+
# Page.navigate is sent asynchronously because Lightpanda may not
|
|
17
|
+
# return the command result until the page is fully loaded (unlike
|
|
18
|
+
# Chrome which returns immediately with frameId/loaderId). If we
|
|
19
|
+
# waited synchronously, the readyState fallback would never be
|
|
20
|
+
# reached on pages that fail to fully load.
|
|
21
|
+
#
|
|
22
|
+
# Uses a single shared deadline so the worst-case wait is 1x timeout,
|
|
23
|
+
# not 2x (lightpanda-io/browser#1849).
|
|
24
|
+
def go_to(url, wait: true)
|
|
25
|
+
enable_page_events
|
|
26
|
+
|
|
27
|
+
if wait
|
|
28
|
+
wait_for_page_load(url)
|
|
29
|
+
else
|
|
30
|
+
page_command("Page.navigate", url: url)
|
|
31
|
+
end
|
|
32
|
+
check_unhandled_modal!
|
|
33
|
+
end
|
|
34
|
+
alias goto go_to
|
|
35
|
+
|
|
36
|
+
def back
|
|
37
|
+
wait_for_navigation { navigate_history(-1) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def forward
|
|
41
|
+
wait_for_navigation { navigate_history(+1) }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def refresh
|
|
45
|
+
wait_for_navigation { page_command("Page.reload") }
|
|
46
|
+
end
|
|
47
|
+
alias reload refresh
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def wait_for_page_load(url, retried: false)
|
|
52
|
+
deadline = await_navigation do
|
|
53
|
+
@client.command("Page.navigate", { url: url }, async: true, session_id: @session_id)
|
|
54
|
+
end
|
|
55
|
+
handle_navigation_crash(url, deadline, retried: retried)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Lightpanda may kill the WebSocket or crash during complex page
|
|
59
|
+
# navigation (lightpanda-io/browser#1849, #1854). Reconnect and
|
|
60
|
+
# retry once. If the retry also crashes, raise a clear error
|
|
61
|
+
# instead of leaving the client in a dead state.
|
|
62
|
+
def handle_navigation_crash(url, deadline, retried:)
|
|
63
|
+
if @client.closed? && !retried
|
|
64
|
+
begin
|
|
65
|
+
reconnect
|
|
66
|
+
remaining = deadline - monotonic_time
|
|
67
|
+
if remaining.positive?
|
|
68
|
+
# Equivalent of re-entering go_to without leaking the retry
|
|
69
|
+
# bookkeeping into its public signature. enable_page_events is
|
|
70
|
+
# needed again: reconnect's clear_session_state reset the flag.
|
|
71
|
+
enable_page_events
|
|
72
|
+
wait_for_page_load(url, retried: true)
|
|
73
|
+
end
|
|
74
|
+
rescue DeadBrowserError
|
|
75
|
+
raise
|
|
76
|
+
rescue StandardError
|
|
77
|
+
# reconnect itself failed (process won't restart, port stuck, etc.).
|
|
78
|
+
# Fall through to the raise below — a second immediate reconnect
|
|
79
|
+
# attempt would just duplicate the failure we already swallowed.
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
return unless @client.closed?
|
|
84
|
+
|
|
85
|
+
raise DeadBrowserError, "Lightpanda crashed navigating to #{url}"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def safe_current_url
|
|
89
|
+
current_url
|
|
90
|
+
rescue StandardError
|
|
91
|
+
nil
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Wait for a navigation triggered by the given block.
|
|
95
|
+
# Uses the same loadEventFired + readyState fallback as go_to.
|
|
96
|
+
def wait_for_navigation(&)
|
|
97
|
+
enable_page_events
|
|
98
|
+
await_navigation(&)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Step the session history by `offset` (-1 = back, +1 = forward) using
|
|
102
|
+
# native CDP. `Page.getNavigationHistory` returns the entry list and
|
|
103
|
+
# `currentIndex`; `Page.navigateToHistoryEntry` jumps to the chosen
|
|
104
|
+
# entry's `id`. No-op when the offset would step past either end so
|
|
105
|
+
# the behavior matches `history.back()` / `history.forward()` on a
|
|
106
|
+
# bounded session history.
|
|
107
|
+
def navigate_history(offset)
|
|
108
|
+
history = page_command("Page.getNavigationHistory")
|
|
109
|
+
target_index = history["currentIndex"] + offset
|
|
110
|
+
entries = history["entries"]
|
|
111
|
+
return if target_index.negative? || target_index >= entries.length
|
|
112
|
+
|
|
113
|
+
page_command("Page.navigateToHistoryEntry", entryId: entries[target_index]["id"])
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Common navigation lifecycle shared by `wait_for_page_load` (fresh
|
|
117
|
+
# `Page.navigate`) and `wait_for_navigation` (back / forward / reload).
|
|
118
|
+
# Subscribes to Page.loadEventFired, runs the trigger, waits briefly for
|
|
119
|
+
# the event, falls back to readyState polling for the remaining budget.
|
|
120
|
+
# The handler is unsubscribed via `ensure` so a raising trigger doesn't
|
|
121
|
+
# leak a subscription onto the next navigation. Returns the deadline so
|
|
122
|
+
# the caller can decide whether to attempt crash recovery.
|
|
123
|
+
def await_navigation
|
|
124
|
+
starting_url = safe_current_url
|
|
125
|
+
deadline = monotonic_time + @options.timeout
|
|
126
|
+
loaded = Utils::Event.new
|
|
127
|
+
handler = proc { loaded.set }
|
|
128
|
+
@client.on("Page.loadEventFired", &handler)
|
|
129
|
+
|
|
130
|
+
begin
|
|
131
|
+
yield
|
|
132
|
+
|
|
133
|
+
unless loaded.wait([2, @options.timeout].min)
|
|
134
|
+
remaining = deadline - monotonic_time
|
|
135
|
+
poll_ready_state(remaining, loaded_event: loaded, starting_url: starting_url) if remaining.positive?
|
|
136
|
+
end
|
|
137
|
+
ensure
|
|
138
|
+
@client.off("Page.loadEventFired", handler)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
deadline
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Poll document.readyState as a fallback when Page.loadEventFired
|
|
145
|
+
# doesn't fire (CLAUDE.md rules call this out as load-bearing — do
|
|
146
|
+
# not remove). When starting_url is provided, the poll ignores
|
|
147
|
+
# readyState values from the old page (e.g. about:blank reports
|
|
148
|
+
# "complete" while the new page is still loading in the background).
|
|
149
|
+
def poll_ready_state(timeout, loaded_event: nil, starting_url: nil)
|
|
150
|
+
# Use a short per-evaluation timeout because Lightpanda may block
|
|
151
|
+
# all commands while navigating. Without this, a single evaluate()
|
|
152
|
+
# call would consume the entire @options.timeout, making the poll
|
|
153
|
+
# loop effectively a single attempt.
|
|
154
|
+
poll_cmd_timeout = [timeout / 5.0, 2].max
|
|
155
|
+
|
|
156
|
+
Utils::Wait.until(timeout: timeout, interval: 0.1) do
|
|
157
|
+
loaded_event&.set? || @client.closed? || page_ready?(poll_cmd_timeout, starting_url)
|
|
158
|
+
end
|
|
159
|
+
rescue TimeoutError
|
|
160
|
+
# Expected — readyState fallback exhausted its budget. The caller
|
|
161
|
+
# (await_navigation) keeps going and lets handle_navigation_crash
|
|
162
|
+
# decide whether the session is recoverable.
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
POLL_STATE_JS = "(function(){return{r:document.readyState,u:location.href}})()"
|
|
166
|
+
private_constant :POLL_STATE_JS
|
|
167
|
+
|
|
168
|
+
def page_ready?(cmd_timeout, starting_url)
|
|
169
|
+
response = @client.command(
|
|
170
|
+
"Runtime.evaluate",
|
|
171
|
+
{ expression: POLL_STATE_JS, returnByValue: true, awaitPromise: true },
|
|
172
|
+
session_id: @session_id,
|
|
173
|
+
timeout: cmd_timeout
|
|
174
|
+
)
|
|
175
|
+
state = response.dig("result", "value")
|
|
176
|
+
return false unless state
|
|
177
|
+
|
|
178
|
+
url_changed = starting_url.nil? || state["u"] != starting_url
|
|
179
|
+
url_changed && %w[complete interactive].include?(state["r"])
|
|
180
|
+
rescue Error
|
|
181
|
+
false
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|