capybara-lightpanda 0.3.0 → 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 +40 -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 +209 -308
- 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 -43
- data/lib/capybara/lightpanda/keyboard.rb +18 -1
- data/lib/capybara/lightpanda/network.rb +50 -21
- data/lib/capybara/lightpanda/node.rb +63 -24
- data/lib/capybara/lightpanda/process.rb +63 -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 +6 -4
- 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,12 +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
|
-
@frame_locators = []
|
|
39
|
-
@frames = Concurrent::Hash.new
|
|
40
38
|
@turbo_event = Utils::Event.new
|
|
41
39
|
@turbo_event.set
|
|
40
|
+
@last_navigation_response = nil
|
|
41
|
+
@document_request_id = nil
|
|
42
42
|
|
|
43
43
|
start
|
|
44
44
|
end
|
|
@@ -78,20 +78,14 @@ module Capybara
|
|
|
78
78
|
attach_result = @client.command("Target.attachToTarget", { targetId: @target_id, flatten: true })
|
|
79
79
|
@session_id = attach_result["sessionId"]
|
|
80
80
|
|
|
81
|
-
@frames.clear
|
|
82
81
|
@turbo_event.set
|
|
83
82
|
subscribe_to_console_logs
|
|
84
83
|
subscribe_to_execution_context
|
|
85
|
-
subscribe_to_frame_events
|
|
86
84
|
subscribe_to_turbo_signals
|
|
85
|
+
subscribe_to_navigation_response
|
|
87
86
|
register_auto_scripts
|
|
88
87
|
end
|
|
89
88
|
|
|
90
|
-
def restart
|
|
91
|
-
quit
|
|
92
|
-
start
|
|
93
|
-
end
|
|
94
|
-
|
|
95
89
|
# Wipe per-session state — cookies, storage, all targets — and start
|
|
96
90
|
# over with a fresh BrowserContext. Mirrors ferrum's Browser#reset:
|
|
97
91
|
# one CDP call (`Target.disposeBrowserContext`) does the work that
|
|
@@ -106,15 +100,7 @@ module Capybara
|
|
|
106
100
|
def reset
|
|
107
101
|
dispose_browser_context
|
|
108
102
|
@client.clear_subscriptions
|
|
109
|
-
|
|
110
|
-
@modal_handler_installed = false
|
|
111
|
-
@modal_messages.clear
|
|
112
|
-
clear_frames
|
|
113
|
-
# Network#reset, not #clear: disposing the BrowserContext also
|
|
114
|
-
# destroyed the Network domain and its subscriptions, so we must
|
|
115
|
-
# flip @enabled back to false — otherwise the next #enable
|
|
116
|
-
# short-circuits and traffic tracking is silently dead.
|
|
117
|
-
@network&.reset
|
|
103
|
+
clear_session_state
|
|
118
104
|
create_browser_context
|
|
119
105
|
create_page
|
|
120
106
|
end
|
|
@@ -131,9 +117,30 @@ module Capybara
|
|
|
131
117
|
@client = Client.new(ws_url, @options)
|
|
132
118
|
# Process may have died; the old browserContextId is gone with it.
|
|
133
119
|
@browser_context_id = nil
|
|
120
|
+
clear_session_state
|
|
134
121
|
create_browser_context
|
|
135
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
|
|
136
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
|
|
137
144
|
end
|
|
138
145
|
|
|
139
146
|
def quit
|
|
@@ -247,6 +254,21 @@ module Capybara
|
|
|
247
254
|
end
|
|
248
255
|
alias html body
|
|
249
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
|
+
|
|
250
272
|
# Evaluate JS and return a serialized value.
|
|
251
273
|
# No-args fast path uses Runtime.evaluate; with args we wrap as a function
|
|
252
274
|
# and dispatch via Runtime.callFunctionOn so `arguments[i]` is bound.
|
|
@@ -378,11 +400,15 @@ module Capybara
|
|
|
378
400
|
page_command("Runtime.getProperties", objectId: remote_object_id, ownProperties: true)
|
|
379
401
|
end
|
|
380
402
|
|
|
381
|
-
# 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.
|
|
382
407
|
def release_object(remote_object_id)
|
|
383
408
|
page_command("Runtime.releaseObject", objectId: remote_object_id)
|
|
384
|
-
rescue
|
|
385
|
-
# 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.
|
|
386
412
|
end
|
|
387
413
|
|
|
388
414
|
# Find elements in the current context (top frame or active frame).
|
|
@@ -397,13 +423,33 @@ module Capybara
|
|
|
397
423
|
|
|
398
424
|
# Find child elements within a specific node.
|
|
399
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.
|
|
400
433
|
def find_within(remote_object_id, method, selector)
|
|
401
|
-
|
|
402
|
-
|
|
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
|
|
403
438
|
rescue JavaScriptError => e
|
|
404
439
|
raise_invalid_selector(e, method, selector)
|
|
405
440
|
end
|
|
406
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
|
+
|
|
407
453
|
# objectId of document.activeElement, or nil if none/document detached.
|
|
408
454
|
def active_element
|
|
409
455
|
result = evaluate_with_ref("document.activeElement")
|
|
@@ -417,17 +463,6 @@ module Capybara
|
|
|
417
463
|
page_command("DOM.describeNode", objectId: remote_object_id).dig("node", "backendNodeId")
|
|
418
464
|
end
|
|
419
465
|
|
|
420
|
-
def css(selector)
|
|
421
|
-
node_ids = page_command("DOM.querySelectorAll", nodeId: document_node_id, selector: selector)
|
|
422
|
-
node_ids["nodeIds"] || []
|
|
423
|
-
end
|
|
424
|
-
|
|
425
|
-
def at_css(selector)
|
|
426
|
-
result = page_command("DOM.querySelector", nodeId: document_node_id, selector: selector)
|
|
427
|
-
|
|
428
|
-
result["nodeId"]
|
|
429
|
-
end
|
|
430
|
-
|
|
431
466
|
def screenshot(path: nil, format: :png, quality: nil, full_page: false, encoding: :binary)
|
|
432
467
|
params = { format: format.to_s }
|
|
433
468
|
params[:quality] = quality if quality && format == :jpeg
|
|
@@ -462,18 +497,6 @@ module Capybara
|
|
|
462
497
|
end
|
|
463
498
|
end
|
|
464
499
|
|
|
465
|
-
# Wait for any pending Turbo operations to complete. Event-driven: the
|
|
466
|
-
# injected JS in index.js calls `console.debug('__lightpanda_turbo_busy')`
|
|
467
|
-
# when the pending-ops counter rises above 0 and `_idle` when it returns
|
|
468
|
-
# to 0. We toggle @turbo_event accordingly (see subscribe_to_turbo_signals).
|
|
469
|
-
#
|
|
470
|
-
# Pages without Turbo never trigger _turboStart, so no sentinels fire and
|
|
471
|
-
# @turbo_event stays set (initial state) — wait returns immediately. Same
|
|
472
|
-
# for Turbo-loaded pages that have no pending work.
|
|
473
|
-
def wait_for_turbo
|
|
474
|
-
@turbo_event.wait(@options.timeout)
|
|
475
|
-
end
|
|
476
|
-
|
|
477
500
|
# Wait for the page to settle after an action that may have kicked off
|
|
478
501
|
# a Turbo fetch OR a full-page navigation. Used by Node#click and
|
|
479
502
|
# Node#implicit_submit so callers can immediately read updated state
|
|
@@ -515,52 +538,22 @@ module Capybara
|
|
|
515
538
|
end
|
|
516
539
|
|
|
517
540
|
# -- Frame Support --
|
|
518
|
-
#
|
|
519
|
-
#
|
|
520
|
-
#
|
|
521
|
-
# drives where `find` resolves selectors. Stored as Nodes so
|
|
522
|
-
# callFunctionOn can scope to the iframe's contentDocument.
|
|
523
|
-
#
|
|
524
|
-
# * `@frames` (Concurrent::Hash<String, Frame>) — metadata view
|
|
525
|
-
# populated from Page.frame{Attached,Navigated,Detached,...} events.
|
|
526
|
-
# Used for diagnostics / introspection (frames, main_frame, frame_by).
|
|
527
|
-
# Lightpanda's frame events are not reliable enough to drive
|
|
528
|
-
# 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.
|
|
529
544
|
|
|
530
545
|
def push_frame(node)
|
|
531
546
|
@frame_stack.push(node)
|
|
532
|
-
@frame_locators.push(capture_frame_locator(node))
|
|
533
547
|
end
|
|
534
548
|
|
|
535
549
|
def pop_frame
|
|
536
|
-
@frame_locators.pop
|
|
537
550
|
@frame_stack.pop
|
|
538
551
|
end
|
|
539
552
|
|
|
540
553
|
def clear_frames
|
|
541
|
-
@frame_locators.clear
|
|
542
554
|
@frame_stack.clear
|
|
543
555
|
end
|
|
544
556
|
|
|
545
|
-
# All frames currently attached to the page (main frame + iframes).
|
|
546
|
-
def frames
|
|
547
|
-
@frames.values
|
|
548
|
-
end
|
|
549
|
-
|
|
550
|
-
# The top-level frame, or nil if it hasn't been registered yet (events
|
|
551
|
-
# arrive asynchronously after Page.enable).
|
|
552
|
-
def main_frame
|
|
553
|
-
@frames.each_value.find(&:main?)
|
|
554
|
-
end
|
|
555
|
-
|
|
556
|
-
def frame_by(id: nil, name: nil)
|
|
557
|
-
if id
|
|
558
|
-
@frames[id]
|
|
559
|
-
elsif name
|
|
560
|
-
@frames.each_value.find { |f| f.name == name }
|
|
561
|
-
end
|
|
562
|
-
end
|
|
563
|
-
|
|
564
557
|
# -- Modal/Dialog Support --
|
|
565
558
|
# Lightpanda's JS dialogs (alert/confirm/prompt) are driven via the
|
|
566
559
|
# `LP.handleJavaScriptDialog` pre-arm model (PR #2261, nightly ≥5900):
|
|
@@ -577,7 +570,8 @@ module Capybara
|
|
|
577
570
|
enable_page_events
|
|
578
571
|
|
|
579
572
|
on("Page.javascriptDialogOpening") do |params|
|
|
580
|
-
|
|
573
|
+
entry = { type: params["type"], message: params["message"] }
|
|
574
|
+
@modal_messages_mutex.synchronize { @modal_messages << entry }
|
|
581
575
|
end
|
|
582
576
|
|
|
583
577
|
@modal_handler_installed = true
|
|
@@ -597,34 +591,59 @@ module Capybara
|
|
|
597
591
|
|
|
598
592
|
def find_modal(type, text: nil, wait: options.timeout)
|
|
599
593
|
regexp = text.is_a?(Regexp) ? text : (text && Regexp.new(Regexp.escape(text.to_s)))
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
break if monotonic_time > deadline
|
|
612
|
-
|
|
613
|
-
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
|
|
614
605
|
end
|
|
615
|
-
|
|
606
|
+
claimed[:message]
|
|
607
|
+
rescue TimeoutError
|
|
608
|
+
raise_modal_not_found(type, text, last_matching_type_message, last_seen_message)
|
|
616
609
|
end
|
|
617
610
|
|
|
618
|
-
|
|
619
|
-
|
|
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
|
|
620
624
|
end
|
|
621
625
|
|
|
622
|
-
|
|
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
|
|
623
637
|
|
|
624
|
-
def raise_modal_not_found(text,
|
|
625
|
-
if
|
|
638
|
+
def raise_modal_not_found(type, text, matching_type_message, any_message)
|
|
639
|
+
if matching_type_message
|
|
640
|
+
raise Capybara::ModalNotFound,
|
|
641
|
+
"Unable to find modal dialog with #{text} - found '#{matching_type_message}' instead."
|
|
642
|
+
end
|
|
643
|
+
if any_message
|
|
626
644
|
raise Capybara::ModalNotFound,
|
|
627
|
-
"Unable to find modal
|
|
645
|
+
"Unable to find #{type} modal#{" with #{text}" if text} - " \
|
|
646
|
+
"a different dialog fired with message '#{any_message}'."
|
|
628
647
|
end
|
|
629
648
|
raise Capybara::ModalNotFound, "Unable to find modal dialog#{" with #{text}" if text}"
|
|
630
649
|
end
|
|
@@ -683,77 +702,21 @@ module Capybara
|
|
|
683
702
|
JS
|
|
684
703
|
private_constant :FIND_IN_FRAME_JS
|
|
685
704
|
|
|
686
|
-
#
|
|
687
|
-
#
|
|
688
|
-
#
|
|
689
|
-
|
|
690
|
-
# fallback when none of the attributes is present.
|
|
691
|
-
CAPTURE_FRAME_LOCATOR_JS = <<~JS
|
|
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
|
|
692
709
|
function() {
|
|
693
|
-
var
|
|
694
|
-
var
|
|
695
|
-
|
|
696
|
-
|
|
710
|
+
var nodes = [];
|
|
711
|
+
var p = this.parentNode;
|
|
712
|
+
while (p && p !== this.ownerDocument) {
|
|
713
|
+
nodes.push(p);
|
|
714
|
+
p = p.parentNode;
|
|
697
715
|
}
|
|
698
|
-
return
|
|
699
|
-
id: this.getAttribute('id'),
|
|
700
|
-
name: this.getAttribute('name'),
|
|
701
|
-
src: this.getAttribute('src'),
|
|
702
|
-
index: index,
|
|
703
|
-
};
|
|
716
|
+
return nodes;
|
|
704
717
|
}
|
|
705
718
|
JS
|
|
706
|
-
private_constant :
|
|
707
|
-
|
|
708
|
-
# Picks the iframe inside `this.contentDocument` matching a locator
|
|
709
|
-
# produced by CAPTURE_FRAME_LOCATOR_JS. Used to re-resolve a nested
|
|
710
|
-
# iframe element when its parent iframe has just been refreshed.
|
|
711
|
-
RESOLVE_NESTED_IFRAME_JS = <<~JS
|
|
712
|
-
function(locator) {
|
|
713
|
-
var doc;
|
|
714
|
-
try { doc = this.contentDocument || (this.contentWindow && this.contentWindow.document); } catch(e) {}
|
|
715
|
-
if (!doc) return null;
|
|
716
|
-
var iframes = doc.querySelectorAll('iframe');
|
|
717
|
-
for (var i = 0; i < iframes.length; i++) {
|
|
718
|
-
var f = iframes[i];
|
|
719
|
-
if (locator.id && f.getAttribute('id') === locator.id) return f;
|
|
720
|
-
if (locator.name && f.getAttribute('name') === locator.name) return f;
|
|
721
|
-
if (locator.src && f.getAttribute('src') === locator.src) return f;
|
|
722
|
-
}
|
|
723
|
-
if (typeof locator.index === 'number' && locator.index >= 0 && locator.index < iframes.length) {
|
|
724
|
-
return iframes[locator.index];
|
|
725
|
-
}
|
|
726
|
-
return null;
|
|
727
|
-
}
|
|
728
|
-
JS
|
|
729
|
-
private_constant :RESOLVE_NESTED_IFRAME_JS
|
|
730
|
-
|
|
731
|
-
# Same shape as RESOLVE_NESTED_IFRAME_JS but reads `document` directly
|
|
732
|
-
# for the top-level case where `this` is unbound (Runtime.evaluate).
|
|
733
|
-
RESOLVE_ROOT_IFRAME_JS = <<~JS
|
|
734
|
-
(function(locator) {
|
|
735
|
-
var iframes = document.querySelectorAll('iframe');
|
|
736
|
-
for (var i = 0; i < iframes.length; i++) {
|
|
737
|
-
var f = iframes[i];
|
|
738
|
-
if (locator.id && f.getAttribute('id') === locator.id) return f;
|
|
739
|
-
if (locator.name && f.getAttribute('name') === locator.name) return f;
|
|
740
|
-
if (locator.src && f.getAttribute('src') === locator.src) return f;
|
|
741
|
-
}
|
|
742
|
-
if (typeof locator.index === 'number' && locator.index >= 0 && locator.index < iframes.length) {
|
|
743
|
-
return iframes[locator.index];
|
|
744
|
-
}
|
|
745
|
-
return null;
|
|
746
|
-
})
|
|
747
|
-
JS
|
|
748
|
-
private_constant :RESOLVE_ROOT_IFRAME_JS
|
|
749
|
-
|
|
750
|
-
# Lightweight stand-in for a Node when refresh_frame_stack! re-resolves
|
|
751
|
-
# an iframe. The only field anyone reads off frame_stack entries is
|
|
752
|
-
# remote_object_id (browser.rb find_in_frame, driver.rb frame_url /
|
|
753
|
-
# frame_title), so a Struct with that one field is sufficient and
|
|
754
|
-
# avoids constructing real Node instances (which need a Driver ref).
|
|
755
|
-
FrameRef = Struct.new(:remote_object_id)
|
|
756
|
-
private_constant :FrameRef
|
|
719
|
+
private_constant :PARENTS_JS
|
|
757
720
|
|
|
758
721
|
def find_in_document(method, selector)
|
|
759
722
|
with_default_context_wait do
|
|
@@ -790,26 +753,11 @@ module Capybara
|
|
|
790
753
|
end
|
|
791
754
|
|
|
792
755
|
def find_in_frame(method, selector)
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
return_by_value: false)
|
|
799
|
-
extract_node_object_ids(result)
|
|
800
|
-
end
|
|
801
|
-
rescue NoExecutionContextError
|
|
802
|
-
# Issue #2400: a child iframe navigation re-emits executionContextCreated
|
|
803
|
-
# for the main-frame V8 context under the child's frameId, churning the
|
|
804
|
-
# iframe's executionContextId. Our stored remote_object_id is bound to
|
|
805
|
-
# the old contextId, so callFunctionOn raises "Cannot find context with
|
|
806
|
-
# specified id". with_default_context_wait can't help — the default
|
|
807
|
-
# context is fine; only the iframe handle is stale. Re-resolve the
|
|
808
|
-
# stack from locators captured at push_frame time and retry once.
|
|
809
|
-
raise if refreshed || !refresh_frame_stack!
|
|
810
|
-
|
|
811
|
-
refreshed = true
|
|
812
|
-
retry
|
|
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)
|
|
813
761
|
end
|
|
814
762
|
rescue JavaScriptError => e
|
|
815
763
|
raise_invalid_selector(e, method, selector)
|
|
@@ -823,84 +771,30 @@ module Capybara
|
|
|
823
771
|
raise js_error
|
|
824
772
|
end
|
|
825
773
|
|
|
826
|
-
# Snapshot iframe identifying attributes at push_frame time so we can
|
|
827
|
-
# re-find the element after lightpanda-io/browser#2400 churns its
|
|
828
|
-
# contextId. Returns a Hash on success or nil if the call fails — the
|
|
829
|
-
# latter blocks refresh attempts for this stack entry but leaves the
|
|
830
|
-
# original behaviour intact (caller falls through to the upstream
|
|
831
|
-
# error). Best-effort: never raises.
|
|
832
|
-
def capture_frame_locator(node)
|
|
833
|
-
call_function_on(node.remote_object_id, CAPTURE_FRAME_LOCATOR_JS)
|
|
834
|
-
rescue BrowserError
|
|
835
|
-
nil
|
|
836
|
-
end
|
|
837
|
-
|
|
838
|
-
# Re-resolve every level of @frame_stack from locators captured at
|
|
839
|
-
# push_frame time. Replaces stack entries in-place with FrameRef
|
|
840
|
-
# holders whose objectIds are bound to the current contextId.
|
|
841
|
-
# Returns true on success, false if any level can't be resolved
|
|
842
|
-
# (missing locator, iframe removed from DOM, fresh CDP error).
|
|
843
|
-
# Called from find_in_frame on NoExecutionContextError; failure is
|
|
844
|
-
# not fatal — the caller re-raises the original error.
|
|
845
|
-
def refresh_frame_stack!
|
|
846
|
-
return false if @frame_stack.empty?
|
|
847
|
-
return false if @frame_locators.any?(&:nil?)
|
|
848
|
-
|
|
849
|
-
fresh = []
|
|
850
|
-
@frame_locators.each_with_index do |locator, level|
|
|
851
|
-
node = level.zero? ? resolve_root_iframe(locator) : resolve_nested_iframe(fresh.last, locator)
|
|
852
|
-
return false unless node
|
|
853
|
-
|
|
854
|
-
fresh << node
|
|
855
|
-
end
|
|
856
|
-
|
|
857
|
-
@frame_stack.replace(fresh)
|
|
858
|
-
true
|
|
859
|
-
rescue BrowserError
|
|
860
|
-
false
|
|
861
|
-
end
|
|
862
|
-
|
|
863
|
-
def resolve_root_iframe(locator)
|
|
864
|
-
# Runtime.evaluate doesn't accept call arguments, so inline the
|
|
865
|
-
# locator as a JSON literal into the invocation expression. The
|
|
866
|
-
# locator was captured from the browser's own getAttribute reads
|
|
867
|
-
# at push_frame time, so JSON.generate is safe.
|
|
868
|
-
expression = "(#{RESOLVE_ROOT_IFRAME_JS})(#{locator.to_json})"
|
|
869
|
-
result = evaluate_with_ref(expression)
|
|
870
|
-
return nil unless result && result["objectId"]
|
|
871
|
-
|
|
872
|
-
FrameRef.new(result["objectId"])
|
|
873
|
-
end
|
|
874
|
-
|
|
875
|
-
def resolve_nested_iframe(parent, locator)
|
|
876
|
-
result = call_function_on(parent.remote_object_id, RESOLVE_NESTED_IFRAME_JS, locator,
|
|
877
|
-
return_by_value: false)
|
|
878
|
-
return nil unless result && result["objectId"]
|
|
879
|
-
|
|
880
|
-
FrameRef.new(result["objectId"])
|
|
881
|
-
end
|
|
882
|
-
|
|
883
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.
|
|
884
778
|
def extract_node_object_ids(result)
|
|
885
779
|
return [] unless result && result["objectId"]
|
|
886
780
|
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
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
|
|
899
794
|
end
|
|
900
795
|
|
|
901
796
|
def register_auto_scripts
|
|
902
|
-
page_command("Page.addScriptToEvaluateOnNewDocument", source:
|
|
903
|
-
page_command("Page.addScriptToEvaluateOnNewDocument", source: XPathPolyfill::POLYFILLS_JS)
|
|
797
|
+
page_command("Page.addScriptToEvaluateOnNewDocument", source: AutoScripts::JS)
|
|
904
798
|
end
|
|
905
799
|
|
|
906
800
|
def subscribe_to_console_logs
|
|
@@ -945,43 +839,41 @@ module Capybara
|
|
|
945
839
|
on("Runtime.executionContextsCleared") { @turbo_event.set }
|
|
946
840
|
end
|
|
947
841
|
|
|
948
|
-
#
|
|
949
|
-
#
|
|
950
|
-
#
|
|
951
|
-
#
|
|
952
|
-
#
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
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
|
|
960
859
|
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
@frames[frame_id] ||= Frame.new(frame_id, parent_id)
|
|
964
|
-
end
|
|
860
|
+
on("Network.requestWillBeSent") do |params|
|
|
861
|
+
next unless params["type"] == "Document"
|
|
965
862
|
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
return unless frame_id
|
|
863
|
+
@document_request_id = params["requestId"]
|
|
864
|
+
@last_navigation_response = nil
|
|
865
|
+
end
|
|
970
866
|
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
frame.url = frame_data["url"]
|
|
974
|
-
frame.state = :navigated
|
|
975
|
-
end
|
|
867
|
+
on("Network.responseReceived") do |params|
|
|
868
|
+
next unless params["requestId"] == @document_request_id
|
|
976
869
|
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
870
|
+
@last_navigation_response = {
|
|
871
|
+
status: params.dig("response", "status"),
|
|
872
|
+
headers: params.dig("response", "headers") || {},
|
|
873
|
+
}
|
|
874
|
+
end
|
|
981
875
|
|
|
982
|
-
|
|
983
|
-
frame = @frames[frame_id]
|
|
984
|
-
frame.state = state if frame
|
|
876
|
+
command("Network.enable")
|
|
985
877
|
end
|
|
986
878
|
|
|
987
879
|
# Track default-execution-context availability via Runtime events.
|
|
@@ -1036,8 +928,12 @@ module Capybara
|
|
|
1036
928
|
# `unwrap_call_result` so that DOM nodes come back as `{ "__lightpanda_node__" => ... }`
|
|
1037
929
|
# hashes the Driver can wrap as Capybara nodes.
|
|
1038
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
|
|
1039
935
|
params = {
|
|
1040
|
-
objectId:
|
|
936
|
+
objectId: doc_oid,
|
|
1041
937
|
functionDeclaration: function_declaration,
|
|
1042
938
|
returnByValue: return_by_value,
|
|
1043
939
|
awaitPromise: true,
|
|
@@ -1050,12 +946,18 @@ module Capybara
|
|
|
1050
946
|
end
|
|
1051
947
|
|
|
1052
948
|
return_by_value ? handle_evaluate_response(response) : unwrap_call_result(response["result"])
|
|
949
|
+
ensure
|
|
950
|
+
release_object(doc_oid) if doc_oid
|
|
1053
951
|
end
|
|
1054
952
|
|
|
1055
953
|
# Translate a non-by-value Runtime result into a plain Ruby value, surfacing
|
|
1056
954
|
# DOM nodes as `{ "__lightpanda_node__" => "..." }` so the Driver can wrap
|
|
1057
955
|
# them. The sentinel key (rather than a plain "objectId") prevents
|
|
1058
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.
|
|
1059
961
|
def unwrap_call_result(result)
|
|
1060
962
|
return nil if result["type"] == "undefined"
|
|
1061
963
|
return nil if result["subtype"] == "null"
|
|
@@ -1065,6 +967,8 @@ module Capybara
|
|
|
1065
967
|
return { "__lightpanda_node__" => object_id } if result["subtype"] == "node"
|
|
1066
968
|
return serialize_remote_array(object_id) if result["subtype"] == "array"
|
|
1067
969
|
return serialize_remote_object(object_id) if result["type"] == "object"
|
|
970
|
+
|
|
971
|
+
release_object(object_id)
|
|
1068
972
|
end
|
|
1069
973
|
|
|
1070
974
|
result["value"]
|
|
@@ -1128,17 +1032,14 @@ module Capybara
|
|
|
1128
1032
|
rescue DeadBrowserError
|
|
1129
1033
|
raise
|
|
1130
1034
|
rescue StandardError
|
|
1131
|
-
# 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.
|
|
1132
1038
|
end
|
|
1133
1039
|
end
|
|
1134
1040
|
|
|
1135
1041
|
return unless @client.closed?
|
|
1136
1042
|
|
|
1137
|
-
begin
|
|
1138
|
-
reconnect
|
|
1139
|
-
rescue StandardError
|
|
1140
|
-
nil
|
|
1141
|
-
end
|
|
1142
1043
|
raise DeadBrowserError, "Lightpanda crashed navigating to #{url}"
|
|
1143
1044
|
end
|
|
1144
1045
|
|
|
@@ -1231,28 +1132,28 @@ module Capybara
|
|
|
1231
1132
|
end
|
|
1232
1133
|
|
|
1233
1134
|
# Poll document.readyState as a fallback when Page.loadEventFired
|
|
1234
|
-
# 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
|
|
1235
1137
|
# readyState values from the old page (e.g. about:blank reports
|
|
1236
1138
|
# "complete" while the new page is still loading in the background).
|
|
1237
1139
|
def poll_ready_state(timeout, loaded_event: nil, starting_url: nil)
|
|
1238
|
-
deadline = monotonic_time + timeout
|
|
1239
1140
|
# Use a short per-evaluation timeout because Lightpanda may block
|
|
1240
1141
|
# all commands while navigating. Without this, a single evaluate()
|
|
1241
1142
|
# call would consume the entire @options.timeout, making the poll
|
|
1242
1143
|
# loop effectively a single attempt.
|
|
1243
1144
|
poll_cmd_timeout = [timeout / 5.0, 2].max
|
|
1244
1145
|
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
break if @client.closed?
|
|
1248
|
-
break if page_ready?(poll_cmd_timeout, starting_url)
|
|
1249
|
-
break if monotonic_time > deadline
|
|
1250
|
-
|
|
1251
|
-
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)
|
|
1252
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.
|
|
1253
1153
|
end
|
|
1254
1154
|
|
|
1255
1155
|
POLL_STATE_JS = "(function(){return{r:document.readyState,u:location.href}})()"
|
|
1156
|
+
private_constant :POLL_STATE_JS
|
|
1256
1157
|
|
|
1257
1158
|
def page_ready?(cmd_timeout, starting_url)
|
|
1258
1159
|
response = @client.command(
|