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
|
@@ -43,7 +43,7 @@ module Capybara
|
|
|
43
43
|
end
|
|
44
44
|
|
|
45
45
|
def closed?
|
|
46
|
-
@status == :closed
|
|
46
|
+
@status == :closed || @status == :error
|
|
47
47
|
end
|
|
48
48
|
|
|
49
49
|
def open?
|
|
@@ -102,10 +102,15 @@ module Capybara
|
|
|
102
102
|
end
|
|
103
103
|
|
|
104
104
|
@driver.on(:error) do |event|
|
|
105
|
+
# Do NOT raise here. This callback fires synchronously from
|
|
106
|
+
# @driver.parse(data) inside the reader thread, which sets
|
|
107
|
+
# abort_on_exception = true. Raising DeadBrowserError escapes
|
|
108
|
+
# the reader's narrow IO rescue and aborts the entire Ruby
|
|
109
|
+
# process. Mark the connection dead and let Client#command
|
|
110
|
+
# surface DeadBrowserError on its next dispatch via closed?.
|
|
111
|
+
@logger&.puts("✗ WebSocket error: #{event.message}")
|
|
105
112
|
@status = :error
|
|
106
113
|
@messages.close
|
|
107
|
-
|
|
108
|
-
raise DeadBrowserError, "WebSocket error: #{event.message}"
|
|
109
114
|
end
|
|
110
115
|
end
|
|
111
116
|
|
|
@@ -152,10 +157,25 @@ module Capybara
|
|
|
152
157
|
def parse_message(data)
|
|
153
158
|
JSON.parse(data, max_nesting: false)
|
|
154
159
|
rescue JSON::ParserError => e
|
|
155
|
-
|
|
160
|
+
warn_parse_failure(e.message)
|
|
156
161
|
|
|
157
162
|
nil
|
|
158
163
|
end
|
|
164
|
+
|
|
165
|
+
# Dedupe identical parse-failure warnings per WebSocket instance.
|
|
166
|
+
# Lightpanda occasionally emits CDP frames that embed a bare
|
|
167
|
+
# `undefined` token (invalid JSON — see upstream-wishlist.md A41)
|
|
168
|
+
# and a complex page reproduces the same frame on every load,
|
|
169
|
+
# which previously flooded test output with one warn per frame.
|
|
170
|
+
# Surface the first occurrence per unique error so the upstream
|
|
171
|
+
# regression stays visible, then suppress repeats.
|
|
172
|
+
def warn_parse_failure(message)
|
|
173
|
+
@parse_warnings ||= {}
|
|
174
|
+
return if @parse_warnings[message]
|
|
175
|
+
|
|
176
|
+
@parse_warnings[message] = true
|
|
177
|
+
warn "Failed to parse WebSocket message: #{message}"
|
|
178
|
+
end
|
|
159
179
|
end
|
|
160
180
|
end
|
|
161
181
|
end
|
|
@@ -71,6 +71,11 @@ module Capybara
|
|
|
71
71
|
@ws&.close
|
|
72
72
|
@message_thread&.join(1) || @message_thread&.kill
|
|
73
73
|
@subscriber.clear
|
|
74
|
+
# Wake any in-flight callers blocked on `pending.value!(timeout)` so
|
|
75
|
+
# they raise DeadBrowserError immediately (via the `@ws.closed?` check
|
|
76
|
+
# in #command) instead of stalling for the full @options.timeout — a
|
|
77
|
+
# 30s freeze per pending command on a dying browser.
|
|
78
|
+
fail_pending_commands
|
|
74
79
|
@pendings.clear
|
|
75
80
|
end
|
|
76
81
|
|
|
@@ -92,6 +97,14 @@ module Capybara
|
|
|
92
97
|
@mutex.synchronize { @command_id += 1 }
|
|
93
98
|
end
|
|
94
99
|
|
|
100
|
+
# Resolve every pending IVar with nil so blocked callers fall through
|
|
101
|
+
# `pending.value!(timeout)` immediately. `try_set` is a no-op if the
|
|
102
|
+
# IVar already carries a real response (race-safe against an in-flight
|
|
103
|
+
# handle_message that landed just before close).
|
|
104
|
+
def fail_pending_commands
|
|
105
|
+
@pendings.each_value { |ivar| ivar.try_set(nil) }
|
|
106
|
+
end
|
|
107
|
+
|
|
95
108
|
def start_message_thread
|
|
96
109
|
@message_thread = Thread.new do
|
|
97
110
|
Thread.current.abort_on_exception = true
|
|
@@ -77,7 +77,8 @@ module Capybara
|
|
|
77
77
|
find { |cookie| cookie.name == name }
|
|
78
78
|
end
|
|
79
79
|
|
|
80
|
-
def set(name:, value:, domain: nil, path: "/", secure: false, http_only: false,
|
|
80
|
+
def set(name:, value:, domain: nil, path: "/", secure: false, http_only: false, # rubocop:disable Metrics/ParameterLists
|
|
81
|
+
same_site: nil, expires: nil)
|
|
81
82
|
params = {
|
|
82
83
|
name: name,
|
|
83
84
|
value: value,
|
|
@@ -87,6 +88,10 @@ module Capybara
|
|
|
87
88
|
}
|
|
88
89
|
|
|
89
90
|
params[:domain] = domain if domain
|
|
91
|
+
# CDP rejects unknown SameSite values; pass through only the canonical
|
|
92
|
+
# spec strings ("Strict" / "Lax" / "None") so YAML noise from a hand-
|
|
93
|
+
# edited file doesn't reach the browser.
|
|
94
|
+
params[:sameSite] = same_site if %w[Strict Lax None].include?(same_site)
|
|
90
95
|
params[:expires] = expires.to_i if expires
|
|
91
96
|
|
|
92
97
|
browser.command("Network.setCookie", **params)
|
|
@@ -123,7 +128,7 @@ module Capybara
|
|
|
123
128
|
|
|
124
129
|
# set() takes keyword args, but YAML round-trips give us a hash with the
|
|
125
130
|
# raw CDP keys (camelCase). Normalize and forward.
|
|
126
|
-
def restore_cookie(hash)
|
|
131
|
+
def restore_cookie(hash) # rubocop:disable Metrics/PerceivedComplexity
|
|
127
132
|
attrs = hash.transform_keys(&:to_s)
|
|
128
133
|
params = {
|
|
129
134
|
name: attrs["name"],
|
|
@@ -133,6 +138,7 @@ module Capybara
|
|
|
133
138
|
http_only: attrs["httpOnly"] || false,
|
|
134
139
|
}
|
|
135
140
|
params[:domain] = attrs["domain"] if attrs["domain"]
|
|
141
|
+
params[:same_site] = attrs["sameSite"] if attrs["sameSite"]
|
|
136
142
|
exp = attrs["expires"]
|
|
137
143
|
params[:expires] = Time.at(exp) if exp.is_a?(Numeric) && exp.positive?
|
|
138
144
|
set(**params)
|
|
@@ -10,7 +10,7 @@ module Capybara
|
|
|
10
10
|
|
|
11
11
|
attr_reader :app, :options
|
|
12
12
|
|
|
13
|
-
delegate %i[current_url title] => :browser
|
|
13
|
+
delegate %i[current_url title status_code response_headers] => :browser
|
|
14
14
|
|
|
15
15
|
def initialize(app, options = {})
|
|
16
16
|
super()
|
|
@@ -31,6 +31,22 @@ module Capybara
|
|
|
31
31
|
false
|
|
32
32
|
end
|
|
33
33
|
|
|
34
|
+
# Escape hatch to the underlying Browser for callers that need raw CDP
|
|
35
|
+
# access — e.g. Lightpanda's `LP.*` extensions (`getMarkdown`,
|
|
36
|
+
# `getSemanticTree`, `detectForms`, …) that aren't worth exposing through
|
|
37
|
+
# the Capybara DSL. Mirrors `capybara-playwright-driver`'s
|
|
38
|
+
# `with_playwright_page`. Yields the Browser; returns whatever the block
|
|
39
|
+
# returns.
|
|
40
|
+
#
|
|
41
|
+
# driver.with_lightpanda_browser do |browser|
|
|
42
|
+
# browser.page_command("LP.getMarkdown")
|
|
43
|
+
# end
|
|
44
|
+
def with_lightpanda_browser(&block)
|
|
45
|
+
raise ArgumentError, "block must be given" unless block
|
|
46
|
+
|
|
47
|
+
block.call(browser)
|
|
48
|
+
end
|
|
49
|
+
|
|
34
50
|
def visit(url)
|
|
35
51
|
@started = true
|
|
36
52
|
browser.go_to(url)
|
|
@@ -189,8 +205,11 @@ module Capybara
|
|
|
189
205
|
|
|
190
206
|
def save_screenshot(path, **_options)
|
|
191
207
|
browser.screenshot(path: path)
|
|
192
|
-
rescue BinaryError, BinaryNotFoundError
|
|
193
|
-
# Browser can't start (
|
|
208
|
+
rescue BinaryError, BinaryNotFoundError, BrowserError, TimeoutError
|
|
209
|
+
# Browser can't start (version too old), is already dead (DeadBrowserError),
|
|
210
|
+
# the CDP call timed out, or returned any other CDP-level error. Teardown
|
|
211
|
+
# screenshots are best-effort — swallow so the real test failure surfaces
|
|
212
|
+
# instead of a "browser already gone" stack trace.
|
|
194
213
|
nil
|
|
195
214
|
end
|
|
196
215
|
|
|
@@ -221,12 +240,15 @@ module Capybara
|
|
|
221
240
|
end
|
|
222
241
|
|
|
223
242
|
# Expanded error list for Capybara retry logic (Cuprite pattern).
|
|
243
|
+
# MouseEventFailed is in Cuprite's list, but Lightpanda has no
|
|
244
|
+
# rendering engine and the gem dispatches clicks through JS — the
|
|
245
|
+
# underlying CDP Input.dispatchMouseEvent path doesn't run, so
|
|
246
|
+
# MouseEventFailed is never raised.
|
|
224
247
|
def invalid_element_errors
|
|
225
248
|
[
|
|
226
249
|
NodeNotFoundError,
|
|
227
250
|
NoExecutionContextError,
|
|
228
251
|
ObsoleteNode,
|
|
229
|
-
MouseEventFailed,
|
|
230
252
|
]
|
|
231
253
|
end
|
|
232
254
|
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Escape hatch onto Capybara::Node::Element so users can reach our driver-level
|
|
4
|
+
# Node (and its remote_object_id, `call`, etc.) without going through
|
|
5
|
+
# `element.base`. Mirrors capybara-playwright-driver's
|
|
6
|
+
# `with_playwright_element_handle` (lib/capybara/playwright/node.rb). The is_a?
|
|
7
|
+
# guard keeps the patch safe when both this gem and another Capybara driver
|
|
8
|
+
# are loaded in the same process.
|
|
9
|
+
module Capybara
|
|
10
|
+
module Lightpanda
|
|
11
|
+
module ElementExtension
|
|
12
|
+
def with_lightpanda_node(&block)
|
|
13
|
+
raise ArgumentError, "block must be given" unless block
|
|
14
|
+
raise "#{base.inspect} is not a Capybara::Lightpanda::Node" unless base.is_a?(Capybara::Lightpanda::Node)
|
|
15
|
+
|
|
16
|
+
block.call(base)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
Capybara::Node::Element.prepend(Capybara::Lightpanda::ElementExtension)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Capybara
|
|
4
|
+
module Lightpanda
|
|
5
|
+
# Hash subclass that downcases the lookup key. CDP returns response headers
|
|
6
|
+
# with lowercased names ("content-type"), but Capybara callers reach for
|
|
7
|
+
# the canonical casing ("Content-Type"). Mirrors capybara-playwright-driver's
|
|
8
|
+
# Headers class (lib/capybara/playwright/page.rb).
|
|
9
|
+
class Headers < Hash
|
|
10
|
+
def [](key)
|
|
11
|
+
super(key.to_s.downcase)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -84,56 +84,44 @@
|
|
|
84
84
|
// Page.addScriptToEvaluateOnNewDocument.
|
|
85
85
|
|
|
86
86
|
// Returns true if `el` is visible per Capybara's semantics. Lightpanda's
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
// `[hidden]
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
// and offsetParent stay explicit because they aren't covered by display.
|
|
93
|
-
// `checkVisibility()` does the parent walk for us when available.
|
|
87
|
+
// `checkVisibility()` walks ancestors and rejects `display:none` from
|
|
88
|
+
// inline styles, stylesheets, and the UA sheet (PR #2294 — covers
|
|
89
|
+
// HEAD/SCRIPT/STYLE/NOSCRIPT/TEMPLATE/TITLE, `[hidden]`, `[type=hidden]`,
|
|
90
|
+
// closed-<details> children). `visibility:hidden|collapse` is handled
|
|
91
|
+
// separately because checkVisibility's defaults (per spec) ignore it.
|
|
94
92
|
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
|
|
93
|
+
// Intentional upstream out-of-scope (upstream-wishlist.md C10):
|
|
94
|
+
// Lightpanda is a headless agentic browser and deliberately doesn't
|
|
95
|
+
// load external `<link rel=stylesheet>` or apply `@media` rules to
|
|
96
|
+
// the cascade, and `matchMedia()` returns false for every query.
|
|
97
|
+
// Responsive patterns that hide one of two mobile/desktop CTA
|
|
98
|
+
// duplicates via `@media (min-width: …) { display: none }` leak
|
|
99
|
+
// both variants past this check — Capybara then raises
|
|
100
|
+
// `Ambiguous: found 2 elements`. There is no in-gem fix: rebuilding
|
|
101
|
+
// the CSS cascade in JS would need a CSS parser, sync access to
|
|
102
|
+
// remote stylesheets, and a real media-query evaluator. Run
|
|
103
|
+
// cuprite for responsive-UI assertions.
|
|
104
|
+
isVisible: function(el) {
|
|
100
105
|
if (!el || el.nodeType !== 1) return false;
|
|
101
|
-
var checkOffsetParent = !opts || opts.checkOffsetParent !== false;
|
|
102
|
-
var TAG = (el.tagName || '').toUpperCase();
|
|
103
|
-
|
|
104
106
|
var win = el.ownerDocument.defaultView || window;
|
|
105
107
|
var style = win.getComputedStyle(el);
|
|
106
108
|
if (style.visibility === 'hidden' || style.visibility === 'collapse') return false;
|
|
107
|
-
|
|
108
|
-
if (!el.checkVisibility()) return false;
|
|
109
|
-
} else {
|
|
110
|
-
var node = el;
|
|
111
|
-
while (node && node.nodeType === 1) {
|
|
112
|
-
if (win.getComputedStyle(node).display === 'none') return false;
|
|
113
|
-
node = node.parentElement;
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
if (checkOffsetParent && el.offsetParent === null && style.position !== 'fixed' &&
|
|
117
|
-
TAG !== 'BODY' && TAG !== 'HTML') return false;
|
|
118
|
-
return true;
|
|
109
|
+
return el.checkVisibility();
|
|
119
110
|
},
|
|
120
111
|
|
|
121
112
|
// Returns true if the element is obscured at its center point — i.e.
|
|
122
113
|
// hit-testing elementFromPoint at the center returns something that is
|
|
123
|
-
// not the element or its descendant.
|
|
124
|
-
//
|
|
125
|
-
//
|
|
114
|
+
// not the element or its descendant. `visibility:hidden|collapse` is
|
|
115
|
+
// short-circuited explicitly because those elements still produce a
|
|
116
|
+
// layout box (so the rect-zero check below can't catch them); every
|
|
117
|
+
// other "not rendered" case (display:none, [hidden], descendants of
|
|
118
|
+
// either) falls out naturally because getBoundingClientRect returns
|
|
119
|
+
// DOMRect{0,0,0,0} for elements with no layout box.
|
|
126
120
|
isObscured: function(el) {
|
|
127
121
|
var doc = el.ownerDocument;
|
|
128
122
|
var win = doc.defaultView || window;
|
|
129
123
|
var style = win.getComputedStyle(el);
|
|
130
|
-
if (style.display === 'none') return true;
|
|
131
124
|
if (style.visibility === 'hidden' || style.visibility === 'collapse') return true;
|
|
132
|
-
var anc = el;
|
|
133
|
-
while (anc && anc.nodeType === 1) {
|
|
134
|
-
if (anc.hasAttribute && anc.hasAttribute('hidden')) return true;
|
|
135
|
-
anc = anc.parentNode;
|
|
136
|
-
}
|
|
137
125
|
var r = el.getBoundingClientRect();
|
|
138
126
|
if (r.width === 0 || r.height === 0) return true;
|
|
139
127
|
var cx = r.left + (r.width / 2);
|
|
@@ -163,13 +151,12 @@
|
|
|
163
151
|
return false;
|
|
164
152
|
},
|
|
165
153
|
|
|
166
|
-
// True if the element is the host of a contenteditable region
|
|
167
|
-
//
|
|
168
|
-
// `
|
|
169
|
-
// `
|
|
170
|
-
//
|
|
154
|
+
// True if the element is the host of a contenteditable region: it (or any
|
|
155
|
+
// ancestor) has a non-"false" `contenteditable` attribute. Lightpanda's
|
|
156
|
+
// native `HTMLElement.isContentEditable` (PR #2310) is hardwired to return
|
|
157
|
+
// `false` for every element — it has no caret/keyboard editing pipeline —
|
|
158
|
+
// so the IDL property is useless here and we walk ancestors ourselves.
|
|
171
159
|
isContentEditable: function(el) {
|
|
172
|
-
if (el.isContentEditable) return true;
|
|
173
160
|
var n = el;
|
|
174
161
|
while (n && n.nodeType === 1) {
|
|
175
162
|
if (n.hasAttribute && n.hasAttribute('contenteditable')) {
|
|
@@ -212,7 +199,7 @@
|
|
|
212
199
|
return fout;
|
|
213
200
|
}
|
|
214
201
|
if (node.nodeType !== 1) return '';
|
|
215
|
-
if (!self.isVisible(node
|
|
202
|
+
if (!self.isVisible(node)) return '';
|
|
216
203
|
var tag = (node.tagName || '').toUpperCase();
|
|
217
204
|
if (tag === 'TEXTAREA') return node.value || '';
|
|
218
205
|
if (tag === 'BR') return '\n';
|
|
@@ -69,6 +69,23 @@ module Capybara
|
|
|
69
69
|
shift: 8,
|
|
70
70
|
}.freeze
|
|
71
71
|
|
|
72
|
+
# US-layout shifted variants of the printable ASCII characters that
|
|
73
|
+
# don't just upcase. Used by dispatch_modified_char so
|
|
74
|
+
# send_keys([:shift, "1"]) types "!" instead of "1". Letters fall
|
|
75
|
+
# through to String#upcase via the default branch.
|
|
76
|
+
SHIFT_MAP = {
|
|
77
|
+
"1" => "!", "2" => "@", "3" => "#", "4" => "$", "5" => "%",
|
|
78
|
+
"6" => "^", "7" => "&", "8" => "*", "9" => "(", "0" => ")",
|
|
79
|
+
"-" => "_", "=" => "+", "[" => "{", "]" => "}", "\\" => "|",
|
|
80
|
+
";" => ":", "'" => "\"", "," => "<", "." => ">", "/" => "?",
|
|
81
|
+
"`" => "~",
|
|
82
|
+
}.freeze
|
|
83
|
+
private_constant :SHIFT_MAP
|
|
84
|
+
|
|
85
|
+
def self.shifted(char)
|
|
86
|
+
SHIFT_MAP[char] || char.upcase
|
|
87
|
+
end
|
|
88
|
+
|
|
72
89
|
def initialize(browser)
|
|
73
90
|
@browser = browser
|
|
74
91
|
end
|
|
@@ -155,7 +172,7 @@ module Capybara
|
|
|
155
172
|
end
|
|
156
173
|
|
|
157
174
|
def dispatch_modified_char(char, modifier_value, modifiers)
|
|
158
|
-
text = modifiers.include?(:shift) ? char
|
|
175
|
+
text = modifiers.include?(:shift) ? self.class.shifted(char) : char
|
|
159
176
|
send_key_event("keyDown", { key: text, text: text, unmodifiedText: char }, modifiers: modifier_value)
|
|
160
177
|
send_key_event("keyUp", { key: text }, modifiers: modifier_value)
|
|
161
178
|
end
|
|
@@ -8,6 +8,7 @@ module Capybara
|
|
|
8
8
|
def initialize(browser)
|
|
9
9
|
@browser = browser
|
|
10
10
|
@traffic = []
|
|
11
|
+
@traffic_mutex = Mutex.new
|
|
11
12
|
@enabled = false
|
|
12
13
|
@request_handler = nil
|
|
13
14
|
@response_handler = nil
|
|
@@ -35,11 +36,11 @@ module Capybara
|
|
|
35
36
|
end
|
|
36
37
|
|
|
37
38
|
def traffic
|
|
38
|
-
@traffic.dup
|
|
39
|
+
@traffic_mutex.synchronize { @traffic.dup }
|
|
39
40
|
end
|
|
40
41
|
|
|
41
42
|
def clear
|
|
42
|
-
@traffic.clear
|
|
43
|
+
@traffic_mutex.synchronize { @traffic.clear }
|
|
43
44
|
end
|
|
44
45
|
|
|
45
46
|
# Wipe local state without sending Network.disable. Called by
|
|
@@ -50,61 +51,89 @@ module Capybara
|
|
|
50
51
|
# cleared the Subscriber first.
|
|
51
52
|
def reset
|
|
52
53
|
unsubscribe
|
|
53
|
-
@traffic.clear
|
|
54
|
+
@traffic_mutex.synchronize { @traffic.clear }
|
|
54
55
|
@enabled = false
|
|
55
56
|
end
|
|
56
57
|
|
|
58
|
+
# Setting extra headers also lazily enables the Network domain. Without
|
|
59
|
+
# this, headers were silently ignored until the caller separately ran
|
|
60
|
+
# `network.enable` (or `wait_for_network_idle`). Cuprite/Ferrum parity.
|
|
57
61
|
def headers=(headers)
|
|
62
|
+
enable
|
|
58
63
|
@extra_headers = headers
|
|
59
64
|
browser.page_command("Network.setExtraHTTPHeaders", headers: headers)
|
|
60
65
|
end
|
|
61
66
|
|
|
62
67
|
def add_headers(headers)
|
|
68
|
+
enable
|
|
63
69
|
@extra_headers = (@extra_headers || {}).merge(headers)
|
|
64
70
|
browser.page_command("Network.setExtraHTTPHeaders", headers: @extra_headers)
|
|
65
71
|
end
|
|
66
72
|
|
|
67
73
|
def clear_headers
|
|
74
|
+
enable
|
|
68
75
|
@extra_headers = {}
|
|
69
76
|
browser.page_command("Network.setExtraHTTPHeaders", headers: {})
|
|
70
77
|
end
|
|
71
78
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
return true if pending <= connections
|
|
79
|
+
# Count of in-flight requests (those with no response yet recorded).
|
|
80
|
+
# Cheap predicate-friendly accessor (ferrum parity).
|
|
81
|
+
def pending_connections
|
|
82
|
+
@traffic_mutex.synchronize { @traffic.count { |t| t[:response].nil? } }
|
|
83
|
+
end
|
|
78
84
|
|
|
79
|
-
|
|
80
|
-
|
|
85
|
+
# True when no more than `connections` requests are in-flight.
|
|
86
|
+
def idle?(connections = 0)
|
|
87
|
+
pending_connections <= connections
|
|
88
|
+
end
|
|
81
89
|
|
|
90
|
+
def wait_for_idle(timeout: 5, connections: 0)
|
|
91
|
+
wait_for_idle!(timeout: timeout, connections: connections)
|
|
92
|
+
rescue TimeoutError
|
|
82
93
|
false
|
|
83
94
|
end
|
|
84
95
|
|
|
96
|
+
# Raising variant of #wait_for_idle (ferrum parity). Returns true on
|
|
97
|
+
# success, raises TimeoutError on timeout so callers that treat the
|
|
98
|
+
# idle wait as a precondition don't have to remember to check a bool.
|
|
99
|
+
def wait_for_idle!(timeout: 5, connections: 0) # rubocop:disable Naming/PredicateMethod
|
|
100
|
+
Utils::Wait.until(
|
|
101
|
+
timeout: timeout,
|
|
102
|
+
interval: 0.1,
|
|
103
|
+
message: "Network did not become idle within #{timeout}s " \
|
|
104
|
+
"(pending=#{pending_connections}, allowed=#{connections})"
|
|
105
|
+
) { idle?(connections) }
|
|
106
|
+
true
|
|
107
|
+
end
|
|
108
|
+
|
|
85
109
|
private
|
|
86
110
|
|
|
87
111
|
def subscribe
|
|
112
|
+
# CDP events arrive on the message thread while wait_for_idle /
|
|
113
|
+
# pending_connections read from the main thread; serialize all
|
|
114
|
+
# @traffic mutations and reads through @traffic_mutex.
|
|
88
115
|
@request_handler = lambda do |params|
|
|
89
|
-
|
|
116
|
+
entry = {
|
|
90
117
|
request_id: params["requestId"],
|
|
91
118
|
url: params.dig("request", "url"),
|
|
92
119
|
method: params.dig("request", "method"),
|
|
93
120
|
timestamp: params["timestamp"],
|
|
94
121
|
response: nil,
|
|
95
122
|
}
|
|
123
|
+
@traffic_mutex.synchronize { @traffic << entry }
|
|
96
124
|
end
|
|
97
125
|
|
|
98
126
|
@response_handler = lambda do |params|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
127
|
+
@traffic_mutex.synchronize do
|
|
128
|
+
request = @traffic.find { |t| t[:request_id] == params["requestId"] }
|
|
129
|
+
next unless request
|
|
130
|
+
|
|
131
|
+
request[:response] = {
|
|
132
|
+
status: params.dig("response", "status"),
|
|
133
|
+
headers: params.dig("response", "headers"),
|
|
134
|
+
mime_type: params.dig("response", "mimeType"),
|
|
135
|
+
}
|
|
136
|
+
end
|
|
108
137
|
end
|
|
109
138
|
|
|
110
139
|
browser.on("Network.requestWillBeSent", &@request_handler)
|
|
@@ -14,12 +14,10 @@ module Capybara
|
|
|
14
14
|
end
|
|
15
15
|
|
|
16
16
|
def text
|
|
17
|
-
ensure_connected
|
|
18
17
|
call("function() { return this.textContent }")
|
|
19
18
|
end
|
|
20
19
|
|
|
21
20
|
def all_text
|
|
22
|
-
ensure_connected
|
|
23
21
|
filter_text(call("function() { return this.textContent }"))
|
|
24
22
|
end
|
|
25
23
|
|
|
@@ -28,7 +26,6 @@ module Capybara
|
|
|
28
26
|
# that fail VISIBLE_JS, and emit newlines around block-display elements
|
|
29
27
|
# (the part of innerText behavior we still need).
|
|
30
28
|
def visible_text
|
|
31
|
-
ensure_connected
|
|
32
29
|
call(VISIBLE_TEXT_JS).to_s
|
|
33
30
|
.gsub(/\A[[:space:]&&[^\u00A0]]+/, "")
|
|
34
31
|
.gsub(/[[:space:]&&[^\u00A0]]+\z/, "")
|
|
@@ -116,6 +113,17 @@ module Capybara
|
|
|
116
113
|
call("function() { this.dispatchEvent(new MouseEvent('mouseover', {bubbles: true, cancelable: true})) }")
|
|
117
114
|
end
|
|
118
115
|
|
|
116
|
+
# Lightpanda has no rendering engine — `window.scrollTo` / `scrollIntoView`
|
|
117
|
+
# are no-ops at the browser level, and `getBoundingClientRect` reflects
|
|
118
|
+
# logical-DOM geometry rather than scroll-aware viewport coords. So
|
|
119
|
+
# there's nothing to scroll. Silently succeed so callers like
|
|
120
|
+
# `session.scroll_to(find('#thing'))` (Selenium-flavoured specs leaning
|
|
121
|
+
# on real layout) don't crash with NotImplementedError; assertions that
|
|
122
|
+
# depend on post-scroll visibility are already gated by the cuprite
|
|
123
|
+
# fallback in dual-driver setups.
|
|
124
|
+
def scroll_to(*); end
|
|
125
|
+
def scroll_by(*); end
|
|
126
|
+
|
|
119
127
|
# Dispatch an arbitrary DOM event by name. Mirrors Cuprite's Node#trigger
|
|
120
128
|
# — picks the right Event constructor for known mouse/focus/form names
|
|
121
129
|
# and falls back to a generic Event for everything else (so callers can
|
|
@@ -199,6 +207,13 @@ module Capybara
|
|
|
199
207
|
call(GET_PATH_JS)
|
|
200
208
|
end
|
|
201
209
|
|
|
210
|
+
# Ancestor chain from `parentNode` up to (but not including) `document`,
|
|
211
|
+
# returned as Lightpanda::Node wrappers. Mirrors Cuprite's `Node#parents`.
|
|
212
|
+
def parents
|
|
213
|
+
oids = driver.browser.parents_of(@remote_object_id)
|
|
214
|
+
oids.map { |oid| self.class.new(driver, oid) }
|
|
215
|
+
end
|
|
216
|
+
|
|
202
217
|
def find_xpath(selector)
|
|
203
218
|
object_ids = driver.browser.find_within(@remote_object_id, "xpath", selector)
|
|
204
219
|
object_ids.map { |oid| self.class.new(driver, oid) }
|
|
@@ -243,19 +258,6 @@ module Capybara
|
|
|
243
258
|
|
|
244
259
|
private
|
|
245
260
|
|
|
246
|
-
# Capybara's `automatic_reload` re-runs the original query when an element
|
|
247
|
-
# access raises one of the driver's `invalid_element_errors`. After a DOM
|
|
248
|
-
# mutation like `replaceWith`, our cached objectId still resolves to the
|
|
249
|
-
# detached node, so reads succeed (with stale data) and the auto-reload
|
|
250
|
-
# never fires. Detect detachment via `isConnected` and raise so the
|
|
251
|
-
# synchronize-loop notices and triggers a re-find.
|
|
252
|
-
def ensure_connected
|
|
253
|
-
connected = call("function() { return this.isConnected }")
|
|
254
|
-
return if connected
|
|
255
|
-
|
|
256
|
-
raise ObsoleteNode.new(self, "Node is no longer attached to the document")
|
|
257
|
-
end
|
|
258
|
-
|
|
259
261
|
def implicit_submit
|
|
260
262
|
call(IMPLICIT_SUBMIT_JS)
|
|
261
263
|
driver.browser.wait_for_idle
|
|
@@ -277,6 +279,10 @@ module Capybara
|
|
|
277
279
|
call(SET_VALUE_JS, format_time_value(value))
|
|
278
280
|
when "datetime-local"
|
|
279
281
|
call(SET_VALUE_JS, format_datetime_value(value))
|
|
282
|
+
when "month"
|
|
283
|
+
call(SET_VALUE_JS, format_month_value(value))
|
|
284
|
+
when "week"
|
|
285
|
+
call(SET_VALUE_JS, format_week_value(value))
|
|
280
286
|
else
|
|
281
287
|
fill_text_input(type, value.to_s)
|
|
282
288
|
end
|
|
@@ -316,6 +322,22 @@ module Capybara
|
|
|
316
322
|
value.to_time.strftime("%Y-%m-%dT%H:%M")
|
|
317
323
|
end
|
|
318
324
|
|
|
325
|
+
def format_month_value(value)
|
|
326
|
+
return value.to_s if value.is_a?(String) || !value.respond_to?(:to_date)
|
|
327
|
+
|
|
328
|
+
value.to_date.strftime("%Y-%m")
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# ISO 8601 week-of-year, "%G" giving the ISO week-numbering year so that
|
|
332
|
+
# the last days of December that belong to week 1 of the next year are
|
|
333
|
+
# rendered with the correct year. Matches Cuprite's `Node#set` for week
|
|
334
|
+
# inputs and what the user would type into a `<input type=week>` field.
|
|
335
|
+
def format_week_value(value)
|
|
336
|
+
return value.to_s if value.is_a?(String) || !value.respond_to?(:to_date)
|
|
337
|
+
|
|
338
|
+
value.to_date.strftime("%G-W%V")
|
|
339
|
+
end
|
|
340
|
+
|
|
319
341
|
# `maxlength` only constrains user typing, not direct value assignment, but
|
|
320
342
|
# Selenium-style drivers truncate to match what a user would have ended up
|
|
321
343
|
# with. Honor it explicitly so Capybara-shared specs behave the same.
|
|
@@ -345,24 +367,41 @@ module Capybara
|
|
|
345
367
|
# JS bodies may reference `_lightpanda.*` helpers — they're registered via
|
|
346
368
|
# Page.addScriptToEvaluateOnNewDocument in every document (top frame and
|
|
347
369
|
# iframes alike), so the namespace is available wherever `this` lives.
|
|
370
|
+
#
|
|
371
|
+
# The supplied declaration is wrapped with an `isConnected` guard so a
|
|
372
|
+
# detached node raises ObsoleteNode (in Driver#invalid_element_errors)
|
|
373
|
+
# instead of returning stale data from V8's still-live reference. After
|
|
374
|
+
# a DOM mutation like `replaceWith`, the cached objectId still resolves
|
|
375
|
+
# to the detached node, so reads succeed quietly and Capybara's
|
|
376
|
+
# automatic_reload never re-runs the original query.
|
|
348
377
|
def call(function_declaration, *args)
|
|
378
|
+
guarded = wrap_with_attached_guard(function_declaration)
|
|
349
379
|
driver.browser.with_default_context_wait do
|
|
350
|
-
driver.browser.call_function_on(@remote_object_id,
|
|
380
|
+
driver.browser.call_function_on(@remote_object_id, guarded, *args)
|
|
351
381
|
end
|
|
352
382
|
rescue JavaScriptError => e
|
|
383
|
+
if e.message.include?(OBSOLETE_NODE_MARKER)
|
|
384
|
+
raise ObsoleteNode.new(self, "Node is no longer attached to the document")
|
|
385
|
+
end
|
|
386
|
+
|
|
353
387
|
case e.class_name
|
|
354
388
|
when "InvalidSelector"
|
|
355
389
|
raise InvalidSelector.new(e.message, nil, args.first)
|
|
356
390
|
else
|
|
357
391
|
raise
|
|
358
392
|
end
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
OBSOLETE_NODE_MARKER = "LIGHTPANDA_OBSOLETE_NODE"
|
|
396
|
+
private_constant :OBSOLETE_NODE_MARKER
|
|
397
|
+
|
|
398
|
+
def wrap_with_attached_guard(function_declaration)
|
|
399
|
+
<<~JS
|
|
400
|
+
function() {
|
|
401
|
+
if (!this.isConnected) throw new Error(#{OBSOLETE_NODE_MARKER.inspect});
|
|
402
|
+
return (#{function_declaration}).apply(this, arguments);
|
|
403
|
+
}
|
|
404
|
+
JS
|
|
366
405
|
end
|
|
367
406
|
|
|
368
407
|
# We dispatch a `MouseEvent` (not a generic `Event`) because Turbo's link
|