capybara-lightpanda 0.2.2 → 0.4.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 +54 -0
- data/README.md +3 -0
- data/lib/capybara/lightpanda/auto_scripts.rb +10 -0
- data/lib/capybara/lightpanda/binary.rb +111 -49
- data/lib/capybara/lightpanda/browser.rb +268 -177
- data/lib/capybara/lightpanda/client/web_socket.rb +24 -4
- data/lib/capybara/lightpanda/client.rb +13 -0
- data/lib/capybara/lightpanda/cookies.rb +8 -2
- data/lib/capybara/lightpanda/driver.rb +26 -4
- data/lib/capybara/lightpanda/element_extension.rb +21 -0
- data/lib/capybara/lightpanda/headers.rb +15 -0
- data/lib/capybara/lightpanda/javascripts/index.js +30 -802
- data/lib/capybara/lightpanda/keyboard.rb +18 -1
- data/lib/capybara/lightpanda/network.rb +50 -21
- data/lib/capybara/lightpanda/node.rb +72 -51
- data/lib/capybara/lightpanda/process.rb +68 -15
- data/lib/capybara/lightpanda/tasks/binary.rake +35 -0
- data/lib/capybara/lightpanda/utils/wait.rb +48 -0
- data/lib/capybara/lightpanda/version.rb +1 -1
- data/lib/capybara-lightpanda.rb +4 -2
- metadata +7 -5
- data/lib/capybara/lightpanda/frame.rb +0 -33
- data/lib/capybara/lightpanda/javascripts/polyfills.js +0 -212
- data/lib/capybara/lightpanda/xpath_polyfill.rb +0 -15
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "forwardable"
|
|
4
|
-
require "concurrent-ruby"
|
|
5
4
|
|
|
6
5
|
module Capybara
|
|
7
6
|
module Lightpanda
|
|
@@ -33,11 +32,13 @@ module Capybara
|
|
|
33
32
|
@started = false
|
|
34
33
|
@page_events_enabled = false
|
|
35
34
|
@modal_messages = []
|
|
35
|
+
@modal_messages_mutex = Mutex.new
|
|
36
36
|
@modal_handler_installed = false
|
|
37
37
|
@frame_stack = []
|
|
38
|
-
@frames = Concurrent::Hash.new
|
|
39
38
|
@turbo_event = Utils::Event.new
|
|
40
39
|
@turbo_event.set
|
|
40
|
+
@last_navigation_response = nil
|
|
41
|
+
@document_request_id = nil
|
|
41
42
|
|
|
42
43
|
start
|
|
43
44
|
end
|
|
@@ -77,20 +78,14 @@ module Capybara
|
|
|
77
78
|
attach_result = @client.command("Target.attachToTarget", { targetId: @target_id, flatten: true })
|
|
78
79
|
@session_id = attach_result["sessionId"]
|
|
79
80
|
|
|
80
|
-
@frames.clear
|
|
81
81
|
@turbo_event.set
|
|
82
82
|
subscribe_to_console_logs
|
|
83
83
|
subscribe_to_execution_context
|
|
84
|
-
subscribe_to_frame_events
|
|
85
84
|
subscribe_to_turbo_signals
|
|
85
|
+
subscribe_to_navigation_response
|
|
86
86
|
register_auto_scripts
|
|
87
87
|
end
|
|
88
88
|
|
|
89
|
-
def restart
|
|
90
|
-
quit
|
|
91
|
-
start
|
|
92
|
-
end
|
|
93
|
-
|
|
94
89
|
# Wipe per-session state — cookies, storage, all targets — and start
|
|
95
90
|
# over with a fresh BrowserContext. Mirrors ferrum's Browser#reset:
|
|
96
91
|
# one CDP call (`Target.disposeBrowserContext`) does the work that
|
|
@@ -105,15 +100,7 @@ module Capybara
|
|
|
105
100
|
def reset
|
|
106
101
|
dispose_browser_context
|
|
107
102
|
@client.clear_subscriptions
|
|
108
|
-
|
|
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
|
|
103
|
+
clear_session_state
|
|
117
104
|
create_browser_context
|
|
118
105
|
create_page
|
|
119
106
|
end
|
|
@@ -130,9 +117,30 @@ module Capybara
|
|
|
130
117
|
@client = Client.new(ws_url, @options)
|
|
131
118
|
# Process may have died; the old browserContextId is gone with it.
|
|
132
119
|
@browser_context_id = nil
|
|
120
|
+
clear_session_state
|
|
133
121
|
create_browser_context
|
|
134
122
|
create_page
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Per-session in-memory state that must be wiped whenever the underlying
|
|
126
|
+
# CDP connection is replaced (#reset disposes the BrowserContext, #reconnect
|
|
127
|
+
# builds a fresh Client). Without this, a mid-test process crash leaves
|
|
128
|
+
# stale frame_stack Nodes (whose objectIds belong to the dead V8 context)
|
|
129
|
+
# and a `@modal_handler_installed = true` flag that makes prepare_modals
|
|
130
|
+
# short-circuit on the new client, so find_modal silently sees no
|
|
131
|
+
# javascriptDialogOpening events.
|
|
132
|
+
def clear_session_state
|
|
135
133
|
@page_events_enabled = false
|
|
134
|
+
@modal_handler_installed = false
|
|
135
|
+
@modal_messages_mutex.synchronize { @modal_messages.clear }
|
|
136
|
+
@last_navigation_response = nil
|
|
137
|
+
@document_request_id = nil
|
|
138
|
+
clear_frames
|
|
139
|
+
# Network#reset, not #clear: disposing the BrowserContext also
|
|
140
|
+
# destroyed the Network domain and its subscriptions, so we must
|
|
141
|
+
# flip @enabled back to false — otherwise the next #enable
|
|
142
|
+
# short-circuits and traffic tracking is silently dead.
|
|
143
|
+
@network&.reset
|
|
136
144
|
end
|
|
137
145
|
|
|
138
146
|
def quit
|
|
@@ -153,7 +161,7 @@ module Capybara
|
|
|
153
161
|
@target_id = nil
|
|
154
162
|
@session_id = nil
|
|
155
163
|
@modal_handler_installed = false
|
|
156
|
-
|
|
164
|
+
clear_frames
|
|
157
165
|
end
|
|
158
166
|
|
|
159
167
|
def command(method, **params)
|
|
@@ -217,11 +225,11 @@ module Capybara
|
|
|
217
225
|
end
|
|
218
226
|
|
|
219
227
|
def back
|
|
220
|
-
wait_for_navigation {
|
|
228
|
+
wait_for_navigation { navigate_history(-1) }
|
|
221
229
|
end
|
|
222
230
|
|
|
223
231
|
def forward
|
|
224
|
-
wait_for_navigation {
|
|
232
|
+
wait_for_navigation { navigate_history(+1) }
|
|
225
233
|
end
|
|
226
234
|
|
|
227
235
|
def refresh
|
|
@@ -246,6 +254,21 @@ module Capybara
|
|
|
246
254
|
end
|
|
247
255
|
alias html body
|
|
248
256
|
|
|
257
|
+
# HTTP status of the last document navigation; nil before the first
|
|
258
|
+
# navigation completes. Driven by the Network.responseReceived
|
|
259
|
+
# subscription installed in create_page.
|
|
260
|
+
def status_code
|
|
261
|
+
@last_navigation_response&.dig(:status)
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# Response headers of the last document navigation, wrapped in a Headers
|
|
265
|
+
# instance so `["Content-Type"]` works despite CDP lowercasing keys.
|
|
266
|
+
# Returns an empty Headers (not nil) so callers can chain `[]` safely.
|
|
267
|
+
def response_headers
|
|
268
|
+
raw = @last_navigation_response&.dig(:headers) || {}
|
|
269
|
+
Headers.new.tap { |h| raw.each { |k, v| h[k.to_s.downcase] = v } }
|
|
270
|
+
end
|
|
271
|
+
|
|
249
272
|
# Evaluate JS and return a serialized value.
|
|
250
273
|
# No-args fast path uses Runtime.evaluate; with args we wrap as a function
|
|
251
274
|
# and dispatch via Runtime.callFunctionOn so `arguments[i]` is bound.
|
|
@@ -377,11 +400,15 @@ module Capybara
|
|
|
377
400
|
page_command("Runtime.getProperties", objectId: remote_object_id, ownProperties: true)
|
|
378
401
|
end
|
|
379
402
|
|
|
380
|
-
# Release a remote object reference to free V8 memory.
|
|
403
|
+
# Release a remote object reference to free V8 memory. Cleanup is
|
|
404
|
+
# best-effort: callers wrap their work in `ensure release_object(...)`,
|
|
405
|
+
# so a TimeoutError or transport hiccup here must not propagate out of
|
|
406
|
+
# the ensure block and bury the original failure.
|
|
381
407
|
def release_object(remote_object_id)
|
|
382
408
|
page_command("Runtime.releaseObject", objectId: remote_object_id)
|
|
383
|
-
rescue
|
|
384
|
-
# Object may already be released or
|
|
409
|
+
rescue Error
|
|
410
|
+
# Object may already be released, context destroyed, or the CDP call
|
|
411
|
+
# itself timed out / failed in transport.
|
|
385
412
|
end
|
|
386
413
|
|
|
387
414
|
# Find elements in the current context (top frame or active frame).
|
|
@@ -396,13 +423,33 @@ module Capybara
|
|
|
396
423
|
|
|
397
424
|
# Find child elements within a specific node.
|
|
398
425
|
# Returns an array of remote object ID strings.
|
|
426
|
+
#
|
|
427
|
+
# Wrapped in `with_default_context_wait` so a click that triggered a
|
|
428
|
+
# navigation immediately before the find (e.g. a fill_in following a
|
|
429
|
+
# link that mutated the DOM) doesn't race against
|
|
430
|
+
# `Runtime.executionContextCreated` and surface as
|
|
431
|
+
# `NoExecutionContextError`. `find_in_document` and `find_in_frame`
|
|
432
|
+
# already use the same wrapper; `find_within` was the odd one out.
|
|
399
433
|
def find_within(remote_object_id, method, selector)
|
|
400
|
-
|
|
401
|
-
|
|
434
|
+
with_default_context_wait do
|
|
435
|
+
result = call_function_on(remote_object_id, FIND_WITHIN_JS, method, selector, return_by_value: false)
|
|
436
|
+
extract_node_object_ids(result)
|
|
437
|
+
end
|
|
402
438
|
rescue JavaScriptError => e
|
|
403
439
|
raise_invalid_selector(e, method, selector)
|
|
404
440
|
end
|
|
405
441
|
|
|
442
|
+
# Ancestor chain of `remote_object_id` from parentNode up to (but
|
|
443
|
+
# excluding) `document`, returned as an array of remote object IDs.
|
|
444
|
+
# Mirrors Cuprite's JS `parents` helper. Same `with_default_context_wait`
|
|
445
|
+
# wrapping as `find_within` — same race window applies.
|
|
446
|
+
def parents_of(remote_object_id)
|
|
447
|
+
with_default_context_wait do
|
|
448
|
+
result = call_function_on(remote_object_id, PARENTS_JS, return_by_value: false)
|
|
449
|
+
extract_node_object_ids(result)
|
|
450
|
+
end
|
|
451
|
+
end
|
|
452
|
+
|
|
406
453
|
# objectId of document.activeElement, or nil if none/document detached.
|
|
407
454
|
def active_element
|
|
408
455
|
result = evaluate_with_ref("document.activeElement")
|
|
@@ -416,17 +463,6 @@ module Capybara
|
|
|
416
463
|
page_command("DOM.describeNode", objectId: remote_object_id).dig("node", "backendNodeId")
|
|
417
464
|
end
|
|
418
465
|
|
|
419
|
-
def css(selector)
|
|
420
|
-
node_ids = page_command("DOM.querySelectorAll", nodeId: document_node_id, selector: selector)
|
|
421
|
-
node_ids["nodeIds"] || []
|
|
422
|
-
end
|
|
423
|
-
|
|
424
|
-
def at_css(selector)
|
|
425
|
-
result = page_command("DOM.querySelector", nodeId: document_node_id, selector: selector)
|
|
426
|
-
|
|
427
|
-
result["nodeId"]
|
|
428
|
-
end
|
|
429
|
-
|
|
430
466
|
def screenshot(path: nil, format: :png, quality: nil, full_page: false, encoding: :binary)
|
|
431
467
|
params = { format: format.to_s }
|
|
432
468
|
params[:quality] = quality if quality && format == :jpeg
|
|
@@ -461,18 +497,6 @@ module Capybara
|
|
|
461
497
|
end
|
|
462
498
|
end
|
|
463
499
|
|
|
464
|
-
# Wait for any pending Turbo operations to complete. Event-driven: the
|
|
465
|
-
# injected JS in index.js calls `console.debug('__lightpanda_turbo_busy')`
|
|
466
|
-
# when the pending-ops counter rises above 0 and `_idle` when it returns
|
|
467
|
-
# to 0. We toggle @turbo_event accordingly (see subscribe_to_turbo_signals).
|
|
468
|
-
#
|
|
469
|
-
# Pages without Turbo never trigger _turboStart, so no sentinels fire and
|
|
470
|
-
# @turbo_event stays set (initial state) — wait returns immediately. Same
|
|
471
|
-
# for Turbo-loaded pages that have no pending work.
|
|
472
|
-
def wait_for_turbo
|
|
473
|
-
@turbo_event.wait(@options.timeout)
|
|
474
|
-
end
|
|
475
|
-
|
|
476
500
|
# Wait for the page to settle after an action that may have kicked off
|
|
477
501
|
# a Turbo fetch OR a full-page navigation. Used by Node#click and
|
|
478
502
|
# Node#implicit_submit so callers can immediately read updated state
|
|
@@ -514,17 +538,9 @@ module Capybara
|
|
|
514
538
|
end
|
|
515
539
|
|
|
516
540
|
# -- Frame Support --
|
|
517
|
-
#
|
|
518
|
-
#
|
|
519
|
-
#
|
|
520
|
-
# drives where `find` resolves selectors. Stored as Nodes so
|
|
521
|
-
# callFunctionOn can scope to the iframe's contentDocument.
|
|
522
|
-
#
|
|
523
|
-
# * `@frames` (Concurrent::Hash<String, Frame>) — metadata view
|
|
524
|
-
# populated from Page.frame{Attached,Navigated,Detached,...} events.
|
|
525
|
-
# Used for diagnostics / introspection (frames, main_frame, frame_by).
|
|
526
|
-
# Lightpanda's frame events are not reliable enough to drive
|
|
527
|
-
# navigation waits, so this is read-only metadata.
|
|
541
|
+
# `frame_stack` (Array<Node>) is the Capybara `switch_to_frame` stack;
|
|
542
|
+
# it drives where `find` resolves selectors. Stored as Nodes so
|
|
543
|
+
# callFunctionOn can scope to the iframe's contentDocument.
|
|
528
544
|
|
|
529
545
|
def push_frame(node)
|
|
530
546
|
@frame_stack.push(node)
|
|
@@ -538,25 +554,6 @@ module Capybara
|
|
|
538
554
|
@frame_stack.clear
|
|
539
555
|
end
|
|
540
556
|
|
|
541
|
-
# All frames currently attached to the page (main frame + iframes).
|
|
542
|
-
def frames
|
|
543
|
-
@frames.values
|
|
544
|
-
end
|
|
545
|
-
|
|
546
|
-
# The top-level frame, or nil if it hasn't been registered yet (events
|
|
547
|
-
# arrive asynchronously after Page.enable).
|
|
548
|
-
def main_frame
|
|
549
|
-
@frames.each_value.find(&:main?)
|
|
550
|
-
end
|
|
551
|
-
|
|
552
|
-
def frame_by(id: nil, name: nil)
|
|
553
|
-
if id
|
|
554
|
-
@frames[id]
|
|
555
|
-
elsif name
|
|
556
|
-
@frames.each_value.find { |f| f.name == name }
|
|
557
|
-
end
|
|
558
|
-
end
|
|
559
|
-
|
|
560
557
|
# -- Modal/Dialog Support --
|
|
561
558
|
# Lightpanda's JS dialogs (alert/confirm/prompt) are driven via the
|
|
562
559
|
# `LP.handleJavaScriptDialog` pre-arm model (PR #2261, nightly ≥5900):
|
|
@@ -573,7 +570,8 @@ module Capybara
|
|
|
573
570
|
enable_page_events
|
|
574
571
|
|
|
575
572
|
on("Page.javascriptDialogOpening") do |params|
|
|
576
|
-
|
|
573
|
+
entry = { type: params["type"], message: params["message"] }
|
|
574
|
+
@modal_messages_mutex.synchronize { @modal_messages << entry }
|
|
577
575
|
end
|
|
578
576
|
|
|
579
577
|
@modal_handler_installed = true
|
|
@@ -593,34 +591,59 @@ module Capybara
|
|
|
593
591
|
|
|
594
592
|
def find_modal(type, text: nil, wait: options.timeout)
|
|
595
593
|
regexp = text.is_a?(Regexp) ? text : (text && Regexp.new(Regexp.escape(text.to_s)))
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
break if monotonic_time > deadline
|
|
608
|
-
|
|
609
|
-
sleep 0.05
|
|
594
|
+
last_matching_type_message = nil
|
|
595
|
+
last_seen_message = nil
|
|
596
|
+
claimed = nil
|
|
597
|
+
Utils::Wait.until(timeout: wait, interval: 0.05) do
|
|
598
|
+
claimed = pop_modal_message(type.to_s, regexp)
|
|
599
|
+
next true if claimed
|
|
600
|
+
|
|
601
|
+
last = peek_last_modal_message(type.to_s)
|
|
602
|
+
last_matching_type_message = last[:matching_type] || last_matching_type_message
|
|
603
|
+
last_seen_message = last[:any] || last_seen_message
|
|
604
|
+
false
|
|
610
605
|
end
|
|
611
|
-
|
|
606
|
+
claimed[:message]
|
|
607
|
+
rescue TimeoutError
|
|
608
|
+
raise_modal_not_found(type, text, last_matching_type_message, last_seen_message)
|
|
612
609
|
end
|
|
613
610
|
|
|
614
|
-
|
|
615
|
-
|
|
611
|
+
private
|
|
612
|
+
|
|
613
|
+
# Pop the first queued dialog whose type matches and (when `regexp` is
|
|
614
|
+
# non-nil) whose message matches the requested pattern. Returns the
|
|
615
|
+
# entry or nil. Serialized with the message-thread writer.
|
|
616
|
+
def pop_modal_message(type, regexp)
|
|
617
|
+
@modal_messages_mutex.synchronize do
|
|
618
|
+
match = @modal_messages.find do |m|
|
|
619
|
+
m[:type] == type && (regexp.nil? || m[:message].to_s.match?(regexp))
|
|
620
|
+
end
|
|
621
|
+
@modal_messages.delete(match) if match
|
|
622
|
+
match
|
|
623
|
+
end
|
|
616
624
|
end
|
|
617
625
|
|
|
618
|
-
|
|
626
|
+
# Inspect the queue for diagnostics. Returns the most recent message
|
|
627
|
+
# of the requested type (if any) AND the most recent message of any
|
|
628
|
+
# type so the failure message can hint at a type mismatch.
|
|
629
|
+
def peek_last_modal_message(type)
|
|
630
|
+
@modal_messages_mutex.synchronize do
|
|
631
|
+
{
|
|
632
|
+
matching_type: @modal_messages.reverse.find { |m| m[:type] == type }&.dig(:message),
|
|
633
|
+
any: @modal_messages.last&.dig(:message),
|
|
634
|
+
}
|
|
635
|
+
end
|
|
636
|
+
end
|
|
619
637
|
|
|
620
|
-
def raise_modal_not_found(text,
|
|
621
|
-
if
|
|
638
|
+
def raise_modal_not_found(type, text, matching_type_message, any_message)
|
|
639
|
+
if matching_type_message
|
|
622
640
|
raise Capybara::ModalNotFound,
|
|
623
|
-
"Unable to find modal dialog with #{text} - found '#{
|
|
641
|
+
"Unable to find modal dialog with #{text} - found '#{matching_type_message}' instead."
|
|
642
|
+
end
|
|
643
|
+
if any_message
|
|
644
|
+
raise Capybara::ModalNotFound,
|
|
645
|
+
"Unable to find #{type} modal#{" with #{text}" if text} - " \
|
|
646
|
+
"a different dialog fired with message '#{any_message}'."
|
|
624
647
|
end
|
|
625
648
|
raise Capybara::ModalNotFound, "Unable to find modal dialog#{" with #{text}" if text}"
|
|
626
649
|
end
|
|
@@ -632,16 +655,26 @@ module Capybara
|
|
|
632
655
|
INVALID_SELECTOR_MARKER = "LIGHTPANDA_INVALID_SELECTOR:"
|
|
633
656
|
|
|
634
657
|
# JS function for finding elements within a node.
|
|
635
|
-
# Works in any execution context (top frame or iframe).
|
|
636
|
-
# querySelectorAll means the selector is malformed
|
|
637
|
-
#
|
|
638
|
-
#
|
|
639
|
-
#
|
|
658
|
+
# Works in any execution context (top frame or iframe). For CSS, any
|
|
659
|
+
# throw from querySelectorAll means the selector is malformed
|
|
660
|
+
# (re-throw with the marker prefix so Ruby converts to InvalidSelector).
|
|
661
|
+
# XPath routes through native `Document.evaluate` + `XPathResult`
|
|
662
|
+
# (Lightpanda PR #2305, in nightly >=6109); on parse error we return
|
|
663
|
+
# [] silently to match Capybara's internal XPath generator, which
|
|
664
|
+
# sometimes produces selectors with empty trailing predicates like
|
|
665
|
+
# `(...)[]` that native rejects but `has_element?` expects to behave
|
|
666
|
+
# as "not found" rather than raise InvalidSelector.
|
|
667
|
+
# `XPathResult.ORDERED_NODE_SNAPSHOT_TYPE` is `7` in the spec — inlined
|
|
668
|
+
# so the JS doesn't depend on the enum being defined as a constant.
|
|
640
669
|
FIND_WITHIN_JS = <<~JS.freeze
|
|
641
670
|
function(method, selector) {
|
|
642
671
|
if (method === 'xpath') {
|
|
643
|
-
|
|
644
|
-
|
|
672
|
+
try {
|
|
673
|
+
var r = this.ownerDocument.evaluate(selector, this, null, 7, null);
|
|
674
|
+
var nodes = [];
|
|
675
|
+
for (var i = 0; i < r.snapshotLength; i++) nodes.push(r.snapshotItem(i));
|
|
676
|
+
return nodes;
|
|
677
|
+
} catch(e) { return []; }
|
|
645
678
|
}
|
|
646
679
|
try { return Array.from(this.querySelectorAll(selector)); }
|
|
647
680
|
catch(e) { throw new Error('#{INVALID_SELECTOR_MARKER}' + selector); }
|
|
@@ -656,8 +689,12 @@ module Capybara
|
|
|
656
689
|
try { doc = this.contentDocument || (this.contentWindow && this.contentWindow.document); } catch(e) {}
|
|
657
690
|
if (!doc) return [];
|
|
658
691
|
if (method === 'xpath') {
|
|
659
|
-
|
|
660
|
-
|
|
692
|
+
try {
|
|
693
|
+
var r = doc.evaluate(selector, doc, null, 7, null);
|
|
694
|
+
var nodes = [];
|
|
695
|
+
for (var i = 0; i < r.snapshotLength; i++) nodes.push(r.snapshotItem(i));
|
|
696
|
+
return nodes;
|
|
697
|
+
} catch(e) { return []; }
|
|
661
698
|
}
|
|
662
699
|
try { return Array.from(doc.querySelectorAll(selector)); }
|
|
663
700
|
catch(e) { throw new Error('#{INVALID_SELECTOR_MARKER}' + selector); }
|
|
@@ -665,14 +702,41 @@ module Capybara
|
|
|
665
702
|
JS
|
|
666
703
|
private_constant :FIND_IN_FRAME_JS
|
|
667
704
|
|
|
705
|
+
# Walks `parentNode` from `this` up to (but excluding) `document`,
|
|
706
|
+
# returning the chain as a JS array. Each entry is an element node so
|
|
707
|
+
# `extract_node_object_ids` can wrap them as Lightpanda::Nodes.
|
|
708
|
+
PARENTS_JS = <<~JS
|
|
709
|
+
function() {
|
|
710
|
+
var nodes = [];
|
|
711
|
+
var p = this.parentNode;
|
|
712
|
+
while (p && p !== this.ownerDocument) {
|
|
713
|
+
nodes.push(p);
|
|
714
|
+
p = p.parentNode;
|
|
715
|
+
}
|
|
716
|
+
return nodes;
|
|
717
|
+
}
|
|
718
|
+
JS
|
|
719
|
+
private_constant :PARENTS_JS
|
|
720
|
+
|
|
668
721
|
def find_in_document(method, selector)
|
|
669
722
|
with_default_context_wait do
|
|
670
723
|
# Coerce Symbol selectors (e.g. Capybara warning path lets `have_css(:p)`
|
|
671
724
|
# through) to a string before quoting. Symbol#inspect returns `:p`,
|
|
672
725
|
# which would inject a bare token into the JS source.
|
|
673
726
|
selector_literal = selector.to_s.inspect
|
|
727
|
+
# XPath parse errors return [] silently to match Capybara's expected
|
|
728
|
+
# "not found" behavior (see FIND_WITHIN_JS comment above for why).
|
|
674
729
|
js = if method == "xpath"
|
|
675
|
-
|
|
730
|
+
<<~XPATH_FIND
|
|
731
|
+
(function() {
|
|
732
|
+
try {
|
|
733
|
+
var r = document.evaluate(#{selector_literal}, document, null, 7, null);
|
|
734
|
+
var nodes = [];
|
|
735
|
+
for (var i = 0; i < r.snapshotLength; i++) nodes.push(r.snapshotItem(i));
|
|
736
|
+
return nodes;
|
|
737
|
+
} catch(e) { return []; }
|
|
738
|
+
})()
|
|
739
|
+
XPATH_FIND
|
|
676
740
|
else
|
|
677
741
|
<<~CSS_FIND
|
|
678
742
|
(function() {
|
|
@@ -689,10 +753,12 @@ module Capybara
|
|
|
689
753
|
end
|
|
690
754
|
|
|
691
755
|
def find_in_frame(method, selector)
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
756
|
+
with_default_context_wait do
|
|
757
|
+
frame_node = @frame_stack.last
|
|
758
|
+
result = call_function_on(frame_node.remote_object_id, FIND_IN_FRAME_JS, method, selector,
|
|
759
|
+
return_by_value: false)
|
|
760
|
+
extract_node_object_ids(result)
|
|
761
|
+
end
|
|
696
762
|
rescue JavaScriptError => e
|
|
697
763
|
raise_invalid_selector(e, method, selector)
|
|
698
764
|
end
|
|
@@ -706,26 +772,29 @@ module Capybara
|
|
|
706
772
|
end
|
|
707
773
|
|
|
708
774
|
# Extract individual node objectIds from a remote array reference.
|
|
775
|
+
# `ensure release_object` so the outer array handle is freed even when
|
|
776
|
+
# property walking raises — without this, a transient CDP error during
|
|
777
|
+
# property enumeration leaks one V8 handle per failed find call.
|
|
709
778
|
def extract_node_object_ids(result)
|
|
710
779
|
return [] unless result && result["objectId"]
|
|
711
780
|
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
781
|
+
outer_id = result["objectId"]
|
|
782
|
+
begin
|
|
783
|
+
props = get_object_properties(outer_id)
|
|
784
|
+
properties = props["result"] || []
|
|
785
|
+
properties
|
|
786
|
+
.select { |p| p["name"] =~ /\A\d+\z/ }
|
|
787
|
+
.sort_by { |p| p["name"].to_i }
|
|
788
|
+
.filter_map { |p| p.dig("value", "objectId") }
|
|
789
|
+
rescue Error
|
|
790
|
+
[]
|
|
791
|
+
ensure
|
|
792
|
+
release_object(outer_id)
|
|
793
|
+
end
|
|
724
794
|
end
|
|
725
795
|
|
|
726
796
|
def register_auto_scripts
|
|
727
|
-
page_command("Page.addScriptToEvaluateOnNewDocument", source:
|
|
728
|
-
page_command("Page.addScriptToEvaluateOnNewDocument", source: XPathPolyfill::POLYFILLS_JS)
|
|
797
|
+
page_command("Page.addScriptToEvaluateOnNewDocument", source: AutoScripts::JS)
|
|
729
798
|
end
|
|
730
799
|
|
|
731
800
|
def subscribe_to_console_logs
|
|
@@ -770,43 +839,41 @@ module Capybara
|
|
|
770
839
|
on("Runtime.executionContextsCleared") { @turbo_event.set }
|
|
771
840
|
end
|
|
772
841
|
|
|
773
|
-
#
|
|
774
|
-
#
|
|
775
|
-
#
|
|
776
|
-
#
|
|
777
|
-
#
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
842
|
+
# Remember the latest top-level navigation response so
|
|
843
|
+
# `Driver#status_code` / `#response_headers` can answer it. Mirrors the
|
|
844
|
+
# capybara-playwright-driver page hook that captures
|
|
845
|
+
# `request.navigation_request?` (lib/capybara/playwright/page.rb#L33-L37);
|
|
846
|
+
# CDP normally signals "this is the main-document response" via
|
|
847
|
+
# `Network.responseReceived.type`, but Lightpanda omits that field on
|
|
848
|
+
# responses (only emits `type` on `Network.requestWillBeSent`). So we
|
|
849
|
+
# do the matching the long way: capture the document requestId from
|
|
850
|
+
# `requestWillBeSent {type: "Document"}`, then store the response whose
|
|
851
|
+
# `requestId` equals it. Re-installed per `create_page` so the new
|
|
852
|
+
# BrowserContext after `Driver#reset!` starts with a fresh slot.
|
|
853
|
+
#
|
|
854
|
+
# Caveat: sending `Network.disable` (e.g. through `driver.network.disable`)
|
|
855
|
+
# also silences this handler — they share the same CDP toggle.
|
|
856
|
+
def subscribe_to_navigation_response
|
|
857
|
+
@last_navigation_response = nil
|
|
858
|
+
@document_request_id = nil
|
|
785
859
|
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
@frames[frame_id] ||= Frame.new(frame_id, parent_id)
|
|
789
|
-
end
|
|
860
|
+
on("Network.requestWillBeSent") do |params|
|
|
861
|
+
next unless params["type"] == "Document"
|
|
790
862
|
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
return unless frame_id
|
|
863
|
+
@document_request_id = params["requestId"]
|
|
864
|
+
@last_navigation_response = nil
|
|
865
|
+
end
|
|
795
866
|
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
frame.url = frame_data["url"]
|
|
799
|
-
frame.state = :navigated
|
|
800
|
-
end
|
|
867
|
+
on("Network.responseReceived") do |params|
|
|
868
|
+
next unless params["requestId"] == @document_request_id
|
|
801
869
|
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
870
|
+
@last_navigation_response = {
|
|
871
|
+
status: params.dig("response", "status"),
|
|
872
|
+
headers: params.dig("response", "headers") || {},
|
|
873
|
+
}
|
|
874
|
+
end
|
|
806
875
|
|
|
807
|
-
|
|
808
|
-
frame = @frames[frame_id]
|
|
809
|
-
frame.state = state if frame
|
|
876
|
+
command("Network.enable")
|
|
810
877
|
end
|
|
811
878
|
|
|
812
879
|
# Track default-execution-context availability via Runtime events.
|
|
@@ -861,8 +928,12 @@ module Capybara
|
|
|
861
928
|
# `unwrap_call_result` so that DOM nodes come back as `{ "__lightpanda_node__" => ... }`
|
|
862
929
|
# hashes the Driver can wrap as Capybara nodes.
|
|
863
930
|
def call_with_args(function_declaration, args, return_by_value: false)
|
|
931
|
+
# document_object_id returns a fresh RemoteObject handle every call.
|
|
932
|
+
# Release it on the way out so long-running shared-spec sessions don't
|
|
933
|
+
# accumulate orphaned V8 handles between resets.
|
|
934
|
+
doc_oid = document_object_id
|
|
864
935
|
params = {
|
|
865
|
-
objectId:
|
|
936
|
+
objectId: doc_oid,
|
|
866
937
|
functionDeclaration: function_declaration,
|
|
867
938
|
returnByValue: return_by_value,
|
|
868
939
|
awaitPromise: true,
|
|
@@ -875,12 +946,18 @@ module Capybara
|
|
|
875
946
|
end
|
|
876
947
|
|
|
877
948
|
return_by_value ? handle_evaluate_response(response) : unwrap_call_result(response["result"])
|
|
949
|
+
ensure
|
|
950
|
+
release_object(doc_oid) if doc_oid
|
|
878
951
|
end
|
|
879
952
|
|
|
880
953
|
# Translate a non-by-value Runtime result into a plain Ruby value, surfacing
|
|
881
954
|
# DOM nodes as `{ "__lightpanda_node__" => "..." }` so the Driver can wrap
|
|
882
955
|
# them. The sentinel key (rather than a plain "objectId") prevents
|
|
883
956
|
# misclassifying user JS that legitimately returns `{ objectId: "x" }`.
|
|
957
|
+
#
|
|
958
|
+
# When the result carries an objectId we can't unwrap (function, regexp,
|
|
959
|
+
# date, …), release the handle before falling back to `result["value"]`
|
|
960
|
+
# so V8 doesn't accumulate orphaned references across long sessions.
|
|
884
961
|
def unwrap_call_result(result)
|
|
885
962
|
return nil if result["type"] == "undefined"
|
|
886
963
|
return nil if result["subtype"] == "null"
|
|
@@ -890,6 +967,8 @@ module Capybara
|
|
|
890
967
|
return { "__lightpanda_node__" => object_id } if result["subtype"] == "node"
|
|
891
968
|
return serialize_remote_array(object_id) if result["subtype"] == "array"
|
|
892
969
|
return serialize_remote_object(object_id) if result["type"] == "object"
|
|
970
|
+
|
|
971
|
+
release_object(object_id)
|
|
893
972
|
end
|
|
894
973
|
|
|
895
974
|
result["value"]
|
|
@@ -953,17 +1032,14 @@ module Capybara
|
|
|
953
1032
|
rescue DeadBrowserError
|
|
954
1033
|
raise
|
|
955
1034
|
rescue StandardError
|
|
956
|
-
# reconnect itself failed (process won't restart, port stuck, etc.)
|
|
1035
|
+
# reconnect itself failed (process won't restart, port stuck, etc.).
|
|
1036
|
+
# Fall through to the raise below — a second immediate reconnect
|
|
1037
|
+
# attempt would just duplicate the failure we already swallowed.
|
|
957
1038
|
end
|
|
958
1039
|
end
|
|
959
1040
|
|
|
960
1041
|
return unless @client.closed?
|
|
961
1042
|
|
|
962
|
-
begin
|
|
963
|
-
reconnect
|
|
964
|
-
rescue StandardError
|
|
965
|
-
nil
|
|
966
|
-
end
|
|
967
1043
|
raise DeadBrowserError, "Lightpanda crashed navigating to #{url}"
|
|
968
1044
|
end
|
|
969
1045
|
|
|
@@ -1012,6 +1088,21 @@ module Capybara
|
|
|
1012
1088
|
await_navigation(&)
|
|
1013
1089
|
end
|
|
1014
1090
|
|
|
1091
|
+
# Step the session history by `offset` (-1 = back, +1 = forward) using
|
|
1092
|
+
# native CDP. `Page.getNavigationHistory` returns the entry list and
|
|
1093
|
+
# `currentIndex`; `Page.navigateToHistoryEntry` jumps to the chosen
|
|
1094
|
+
# entry's `id`. No-op when the offset would step past either end so
|
|
1095
|
+
# the behavior matches `history.back()` / `history.forward()` on a
|
|
1096
|
+
# bounded session history.
|
|
1097
|
+
def navigate_history(offset)
|
|
1098
|
+
history = page_command("Page.getNavigationHistory")
|
|
1099
|
+
target_index = history["currentIndex"] + offset
|
|
1100
|
+
entries = history["entries"]
|
|
1101
|
+
return if target_index.negative? || target_index >= entries.length
|
|
1102
|
+
|
|
1103
|
+
page_command("Page.navigateToHistoryEntry", entryId: entries[target_index]["id"])
|
|
1104
|
+
end
|
|
1105
|
+
|
|
1015
1106
|
# Common navigation lifecycle shared by `wait_for_page_load` (fresh
|
|
1016
1107
|
# `Page.navigate`) and `wait_for_navigation` (back / forward / reload).
|
|
1017
1108
|
# Subscribes to Page.loadEventFired, runs the trigger, waits briefly for
|
|
@@ -1041,28 +1132,28 @@ module Capybara
|
|
|
1041
1132
|
end
|
|
1042
1133
|
|
|
1043
1134
|
# Poll document.readyState as a fallback when Page.loadEventFired
|
|
1044
|
-
# doesn't fire.
|
|
1135
|
+
# doesn't fire (CLAUDE.md rules call this out as load-bearing — do
|
|
1136
|
+
# not remove). When starting_url is provided, the poll ignores
|
|
1045
1137
|
# readyState values from the old page (e.g. about:blank reports
|
|
1046
1138
|
# "complete" while the new page is still loading in the background).
|
|
1047
1139
|
def poll_ready_state(timeout, loaded_event: nil, starting_url: nil)
|
|
1048
|
-
deadline = monotonic_time + timeout
|
|
1049
1140
|
# Use a short per-evaluation timeout because Lightpanda may block
|
|
1050
1141
|
# all commands while navigating. Without this, a single evaluate()
|
|
1051
1142
|
# call would consume the entire @options.timeout, making the poll
|
|
1052
1143
|
# loop effectively a single attempt.
|
|
1053
1144
|
poll_cmd_timeout = [timeout / 5.0, 2].max
|
|
1054
1145
|
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
break if @client.closed?
|
|
1058
|
-
break if page_ready?(poll_cmd_timeout, starting_url)
|
|
1059
|
-
break if monotonic_time > deadline
|
|
1060
|
-
|
|
1061
|
-
sleep 0.1
|
|
1146
|
+
Utils::Wait.until(timeout: timeout, interval: 0.1) do
|
|
1147
|
+
loaded_event&.set? || @client.closed? || page_ready?(poll_cmd_timeout, starting_url)
|
|
1062
1148
|
end
|
|
1149
|
+
rescue TimeoutError
|
|
1150
|
+
# Expected — readyState fallback exhausted its budget. The caller
|
|
1151
|
+
# (await_navigation) keeps going and lets handle_navigation_crash
|
|
1152
|
+
# decide whether the session is recoverable.
|
|
1063
1153
|
end
|
|
1064
1154
|
|
|
1065
1155
|
POLL_STATE_JS = "(function(){return{r:document.readyState,u:location.href}})()"
|
|
1156
|
+
private_constant :POLL_STATE_JS
|
|
1066
1157
|
|
|
1067
1158
|
def page_ready?(cmd_timeout, starting_url)
|
|
1068
1159
|
response = @client.command(
|