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.
Files changed (32) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +62 -0
  3. data/README.md +14 -1
  4. data/lib/capybara/lightpanda/auto_scripts.rb +30 -2
  5. data/lib/capybara/lightpanda/binary.rb +72 -54
  6. data/lib/capybara/lightpanda/browser/console.rb +190 -0
  7. data/lib/capybara/lightpanda/browser/finder.rb +196 -0
  8. data/lib/capybara/lightpanda/browser/modals.rb +150 -0
  9. data/lib/capybara/lightpanda/browser/navigation.rb +186 -0
  10. data/lib/capybara/lightpanda/browser/runtime.rb +258 -0
  11. data/lib/capybara/lightpanda/browser/selenium_compat.rb +124 -0
  12. data/lib/capybara/lightpanda/browser.rb +158 -813
  13. data/lib/capybara/lightpanda/client/subscriber.rb +2 -0
  14. data/lib/capybara/lightpanda/client/web_socket.rb +19 -21
  15. data/lib/capybara/lightpanda/client.rb +5 -4
  16. data/lib/capybara/lightpanda/downloads.rb +176 -0
  17. data/lib/capybara/lightpanda/driver.rb +124 -32
  18. data/lib/capybara/lightpanda/errors.rb +24 -10
  19. data/lib/capybara/lightpanda/javascripts/attach.js +16 -0
  20. data/lib/capybara/lightpanda/javascripts/banner.js +15 -0
  21. data/lib/capybara/lightpanda/javascripts/errors.js +74 -0
  22. data/lib/capybara/lightpanda/javascripts/predicates.js +116 -0
  23. data/lib/capybara/lightpanda/javascripts/turbo.js +67 -0
  24. data/lib/capybara/lightpanda/keyboard.rb +23 -19
  25. data/lib/capybara/lightpanda/network.rb +103 -18
  26. data/lib/capybara/lightpanda/node.rb +200 -95
  27. data/lib/capybara/lightpanda/options.rb +44 -3
  28. data/lib/capybara/lightpanda/process.rb +154 -29
  29. data/lib/capybara/lightpanda/version.rb +1 -1
  30. data/lib/capybara-lightpanda.rb +1 -0
  31. metadata +14 -3
  32. 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