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
|
@@ -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
|
|
@@ -372,15 +411,14 @@ module Capybara
|
|
|
372
411
|
# the manual default action below, which does a full-page navigation
|
|
373
412
|
# instead of a frame swap.
|
|
374
413
|
#
|
|
375
|
-
#
|
|
376
|
-
# `<
|
|
377
|
-
#
|
|
378
|
-
#
|
|
379
|
-
#
|
|
380
|
-
#
|
|
381
|
-
#
|
|
382
|
-
# the
|
|
383
|
-
# trigger the implicit form-submission default action per DOM spec.
|
|
414
|
+
# Submit buttons (`<input type=submit>`, `<input type=image>`,
|
|
415
|
+
# `<button type=submit>`): native click on the dispatched MouseEvent
|
|
416
|
+
# already runs the form-submission default action via Frame.submitForm
|
|
417
|
+
# in Lightpanda (extension of PR #2312 for image to all submit
|
|
418
|
+
# variants). A manual `form.requestSubmit(this)` here would fire a
|
|
419
|
+
# second SubmitEvent and double-submit the form — observed on nightly
|
|
420
|
+
# 6167 as duplicate `turbo:submit-start` events; the first request
|
|
421
|
+
# gets aborted by the second and Turbo never renders the response.
|
|
384
422
|
CLICK_JS = <<~JS
|
|
385
423
|
function() {
|
|
386
424
|
var EventCtor = (typeof MouseEvent !== 'undefined') ? MouseEvent : Event;
|
|
@@ -388,24 +426,7 @@ module Capybara
|
|
|
388
426
|
var notCancelled = this.dispatchEvent(clickEvt);
|
|
389
427
|
if (!notCancelled || clickEvt.defaultPrevented) return;
|
|
390
428
|
var tag = this.tagName;
|
|
391
|
-
|
|
392
|
-
var isSubmitButton =
|
|
393
|
-
(tag === 'BUTTON' && (type === 'submit' || type === '')) ||
|
|
394
|
-
(tag === 'INPUT' && (type === 'submit' || type === 'image'));
|
|
395
|
-
if (isSubmitButton && this.form) {
|
|
396
|
-
// Lightpanda raises a JsException from requestSubmit when a
|
|
397
|
-
// bubble-phase listener (e.g. Turbo's submitBubbled) calls
|
|
398
|
-
// preventDefault + stopImmediatePropagation on the SubmitEvent.
|
|
399
|
-
// Per HTML spec a cancelled submission should be a silent no-op.
|
|
400
|
-
// Log unexpected errors via console.warn so they remain
|
|
401
|
-
// diagnosable (LIGHTPANDA_DEBUG surfaces console output) instead
|
|
402
|
-
// of silently swallowing future regressions.
|
|
403
|
-
try {
|
|
404
|
-
this.form.requestSubmit(this);
|
|
405
|
-
} catch (e) {
|
|
406
|
-
try { console.warn('[capybara-lightpanda] requestSubmit threw:', e && e.message ? e.message : e); } catch (_) {}
|
|
407
|
-
}
|
|
408
|
-
} else if (tag === 'A' && this.href && this.target !== '_blank') {
|
|
429
|
+
if (tag === 'A' && this.href && this.target !== '_blank') {
|
|
409
430
|
// Same-document fragment-only navigation: just update hash (or do
|
|
410
431
|
// nothing if identical). Mirrors Chrome — assigning location.href
|
|
411
432
|
// to a same-document URL on Lightpanda triggers a real navigation
|
|
@@ -8,10 +8,10 @@ module Capybara
|
|
|
8
8
|
READY_PATTERN = /server running.*address\s*=\s*(\d+\.\d+\.\d+\.\d+:\d+)/m
|
|
9
9
|
ADDRESS_IN_USE_PATTERN = /err=AddressInUse/
|
|
10
10
|
|
|
11
|
-
# Floor for the cookie/navigation/redirect/modal/keyboard/css/forms/dispatch
|
|
12
|
-
# fixes the gem now relies on:
|
|
13
|
-
# empty params + Network.getAllCookies),
|
|
14
|
-
# (window.location.pathname/.search assignment triggers navigation),
|
|
11
|
+
# Floor for the cookie/navigation/redirect/modal/keyboard/css/forms/dispatch/
|
|
12
|
+
# xpath/history/iframe-context/dialog fixes the gem now relies on:
|
|
13
|
+
# PR #2255 (Network.clearBrowserCookies empty params + Network.getAllCookies),
|
|
14
|
+
# PR #2257 (window.location.pathname/.search assignment triggers navigation),
|
|
15
15
|
# PR #2265 (URL fragment inherited across fragment-less redirect),
|
|
16
16
|
# PR #2261 (LP.handleJavaScriptDialog pre-arm), PR #2283 (Referer on
|
|
17
17
|
# cross-page nav), PR #2292 (KeyboardEvent.keyCode/charCode), PR #2294
|
|
@@ -25,10 +25,37 @@ module Capybara
|
|
|
25
25
|
# PR #2342 (<summary> click toggles parent <details>.open),
|
|
26
26
|
# PR #2352 (HTMLInputElement.pattern + patternMismatch via V8 RegExp),
|
|
27
27
|
# PR #2368 (events: report listener exceptions instead of halting
|
|
28
|
-
# dispatch —
|
|
29
|
-
#
|
|
30
|
-
#
|
|
31
|
-
|
|
28
|
+
# dispatch — load-bearing for the gem's JS bundle dispatch assumptions),
|
|
29
|
+
# PR #2289 (Page.getNavigationHistory + Page.navigateToHistoryEntry —
|
|
30
|
+
# lets us drop the history.back()/history.forward() JS workaround in
|
|
31
|
+
# Browser#back / #forward), PR #2305 (XPath 1.0: Document.evaluate,
|
|
32
|
+
# XPathResult, XPathEvaluator, XPathExpression — lets us drop the
|
|
33
|
+
# ~700 LOC XPath polyfill in javascripts/index.js),
|
|
34
|
+
# PR #2431 (cdp: remove duplicate Page.frameNavigated emission + reuse
|
|
35
|
+
# child frame's V8 context — fixes issue #2400 iframe contextId churn,
|
|
36
|
+
# lets us drop Browser#find_in_frame's refresh_frame_stack! rescue),
|
|
37
|
+
# PR #2445 (cdp: reset browser context arena on Target.disposeBrowserContext
|
|
38
|
+
# — restores per-spec state hygiene during Driver#reset!, cures the
|
|
39
|
+
# batch-mode pollution that PR #2431 alone exposed),
|
|
40
|
+
# PR #2435 (dom: implement HTMLDialogElement.{show, showModal, close}
|
|
41
|
+
# natively — load-bearing for the gem's HTMLDialogElement assumptions
|
|
42
|
+
# after polyfills.js was deleted),
|
|
43
|
+
# PR #2450 (forms: add enctype + 5 submitter form-* IDL accessors +
|
|
44
|
+
# text/plain submission — lets us delete polyfills.js entirely; reads
|
|
45
|
+
# of form.enctype / submitter.form{Action,Enctype,Method,NoValidate,
|
|
46
|
+
# Target} now return spec-typed values natively),
|
|
47
|
+
# PR #2478 (css: evaluate @media and matchMedia against viewport —
|
|
48
|
+
# inline <style> @media blocks now apply declarations against the
|
|
49
|
+
# hardcoded 1920×1080 viewport, and window.matchMedia(q).matches
|
|
50
|
+
# returns spec-correct booleans. Lets _lightpanda.isVisible detect
|
|
51
|
+
# inline-@media-gated hides via el.checkVisibility() without any
|
|
52
|
+
# gem-side workaround. External <link rel="stylesheet"> fetch stays
|
|
53
|
+
# out of scope by design — see .claude/rules/lightpanda-io.md
|
|
54
|
+
# limitation #6).
|
|
55
|
+
# Build 6269 = first nightly carrying PR #2478 (merge commit
|
|
56
|
+
# ab63cfbf, 2026-05-16); the 2026-05-16 nightly cut at 03:36 UTC
|
|
57
|
+
# was hours before the merge at 13:42 UTC.
|
|
58
|
+
MINIMUM_NIGHTLY_BUILD = Gem::Version.new("6269")
|
|
32
59
|
|
|
33
60
|
attr_reader :pid, :ws_url, :version, :nightly_build
|
|
34
61
|
|
|
@@ -42,10 +69,11 @@ module Capybara
|
|
|
42
69
|
@stdout_w = nil
|
|
43
70
|
@stderr_r = nil
|
|
44
71
|
@stderr_w = nil
|
|
72
|
+
@finalizer_registered = false
|
|
45
73
|
end
|
|
46
74
|
|
|
47
75
|
def start
|
|
48
|
-
binary_path = @options.browser_path || Binary.
|
|
76
|
+
binary_path = @options.browser_path || Binary.update
|
|
49
77
|
|
|
50
78
|
raise BinaryNotFoundError, "Lightpanda binary not found" unless binary_path
|
|
51
79
|
|
|
@@ -165,7 +193,7 @@ module Capybara
|
|
|
165
193
|
end
|
|
166
194
|
|
|
167
195
|
def build_args
|
|
168
|
-
[
|
|
196
|
+
base = [
|
|
169
197
|
"serve",
|
|
170
198
|
"--host",
|
|
171
199
|
@options.host.to_s,
|
|
@@ -174,6 +202,8 @@ module Capybara
|
|
|
174
202
|
"--log_level",
|
|
175
203
|
"info",
|
|
176
204
|
]
|
|
205
|
+
extra = ENV.fetch("LIGHTPANDA_EXTRA_ARGS", "").split
|
|
206
|
+
base + extra
|
|
177
207
|
end
|
|
178
208
|
|
|
179
209
|
def wait_for_ready
|
|
@@ -252,17 +282,29 @@ module Capybara
|
|
|
252
282
|
end
|
|
253
283
|
|
|
254
284
|
# Returns an array of PIDs holding the TCP port, [] if none, or nil if
|
|
255
|
-
# `lsof` itself isn't available on this system.
|
|
285
|
+
# `lsof` itself isn't available / usable on this system.
|
|
286
|
+
#
|
|
287
|
+
# `lsof -ti` exits 1 with empty stdout/stderr when nothing matches the
|
|
288
|
+
# filter — that's the common "port not held" case, so we treat
|
|
289
|
+
# (exit != 0, empty stdout, empty stderr) as []. A non-zero exit with
|
|
290
|
+
# stderr content is a real lsof failure (broken install, permission
|
|
291
|
+
# error, etc.); surface that as `nil` so the caller raises a clear
|
|
292
|
+
# BinaryError instead of silently retrying the start.
|
|
256
293
|
def pids_listening_on(port)
|
|
257
|
-
stdout,
|
|
258
|
-
return
|
|
294
|
+
stdout, stderr, status = Open3.capture3("lsof", "-ti", "tcp:#{port}")
|
|
295
|
+
return parse_lsof_pids(stdout) if status.success?
|
|
296
|
+
return [] if stdout.strip.empty? && stderr.strip.empty?
|
|
259
297
|
|
|
298
|
+
nil
|
|
299
|
+
rescue Errno::ENOENT
|
|
300
|
+
nil
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def parse_lsof_pids(stdout)
|
|
260
304
|
stdout.split("\n").filter_map do |line|
|
|
261
305
|
pid = line.strip.to_i
|
|
262
306
|
pid.positive? ? pid : nil
|
|
263
307
|
end
|
|
264
|
-
rescue Errno::ENOENT
|
|
265
|
-
nil
|
|
266
308
|
end
|
|
267
309
|
|
|
268
310
|
# Class method so the finalizer proc doesn't capture `self` (which
|
|
@@ -280,8 +322,19 @@ module Capybara
|
|
|
280
322
|
end
|
|
281
323
|
end
|
|
282
324
|
|
|
325
|
+
# `start` may be called more than once on the same Process instance
|
|
326
|
+
# (Browser#restart_process_if_dead runs `stop` then `start` after a
|
|
327
|
+
# crash). Each `attempt_start` calls `register_finalizer`, and
|
|
328
|
+
# ObjectSpace allows multiple finalizers per object — so without
|
|
329
|
+
# this guard the second start would queue a redundant TERM-on-GC
|
|
330
|
+
# whose first invocation no-ops on ESRCH but is still pure noise.
|
|
331
|
+
# We register exactly once; the captured `pid` is overwritten by
|
|
332
|
+
# `undefine_finalizer + define_finalizer` so the finalizer always
|
|
333
|
+
# references the most recently started process.
|
|
283
334
|
def register_finalizer(pid)
|
|
335
|
+
ObjectSpace.undefine_finalizer(self) if @finalizer_registered
|
|
284
336
|
ObjectSpace.define_finalizer(self, self.class.send(:weak_kill, pid))
|
|
337
|
+
@finalizer_registered = true
|
|
285
338
|
end
|
|
286
339
|
|
|
287
340
|
def cleanup_pipes
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "capybara-lightpanda"
|
|
4
|
+
|
|
5
|
+
namespace :lightpanda do
|
|
6
|
+
namespace :binary do
|
|
7
|
+
desc "Print the version of the cached Lightpanda binary"
|
|
8
|
+
task :version do
|
|
9
|
+
version = Capybara::Lightpanda::Binary.current_version
|
|
10
|
+
if version
|
|
11
|
+
puts version
|
|
12
|
+
else
|
|
13
|
+
warn "No cached Lightpanda binary at #{Capybara::Lightpanda::Binary.install_path}"
|
|
14
|
+
exit 1
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
desc "Download the Lightpanda binary (optionally pinned: rake lightpanda:binary:update[0.3.0])"
|
|
19
|
+
task :update, [:version] do |_, args|
|
|
20
|
+
Capybara::Lightpanda::Binary.required_version = args[:version] if args[:version]
|
|
21
|
+
path = Capybara::Lightpanda::Binary.update
|
|
22
|
+
puts "Lightpanda binary ready at #{path}"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
desc "Remove the cached Lightpanda binary"
|
|
26
|
+
task :remove do
|
|
27
|
+
removed = Capybara::Lightpanda::Binary.remove
|
|
28
|
+
if removed
|
|
29
|
+
puts "Removed #{removed}"
|
|
30
|
+
else
|
|
31
|
+
puts "Nothing to remove at #{Capybara::Lightpanda::Binary.install_path}"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Capybara
|
|
4
|
+
module Lightpanda
|
|
5
|
+
module Utils
|
|
6
|
+
# Block-based polling helper. Borrowed from selenium-webdriver's
|
|
7
|
+
# Wait class (rb/lib/selenium/webdriver/common/wait.rb). Sibling
|
|
8
|
+
# of Utils::Attempt — Attempt retries on a specific error class,
|
|
9
|
+
# Wait loops until the block returns truthy.
|
|
10
|
+
module Wait
|
|
11
|
+
DEFAULT_INTERVAL = 0.1
|
|
12
|
+
|
|
13
|
+
# Polls the block until it returns a truthy value or `timeout`
|
|
14
|
+
# seconds elapse. Exceptions whose class is listed in `ignore`
|
|
15
|
+
# are swallowed between polls; the most recent one is appended
|
|
16
|
+
# to the timeout message so the failure stays diagnosable.
|
|
17
|
+
#
|
|
18
|
+
# @raise [Capybara::Lightpanda::TimeoutError] if the block never
|
|
19
|
+
# returns truthy within `timeout` seconds.
|
|
20
|
+
# @return the truthy value returned by the block.
|
|
21
|
+
def self.until(timeout:, interval: DEFAULT_INTERVAL, ignore: [], message: nil)
|
|
22
|
+
deadline = monotonic_time + timeout
|
|
23
|
+
last_error = nil
|
|
24
|
+
loop do
|
|
25
|
+
begin
|
|
26
|
+
result = yield
|
|
27
|
+
return result if result
|
|
28
|
+
rescue *Array(ignore) => e
|
|
29
|
+
last_error = e
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
break if monotonic_time > deadline
|
|
33
|
+
|
|
34
|
+
sleep interval
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
msg = message || "timed out after #{timeout}s"
|
|
38
|
+
msg = "#{msg} (#{last_error.message})" if last_error
|
|
39
|
+
raise Capybara::Lightpanda::TimeoutError, msg
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.monotonic_time
|
|
43
|
+
::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
data/lib/capybara-lightpanda.rb
CHANGED
|
@@ -10,14 +10,16 @@ require_relative "capybara/lightpanda/binary"
|
|
|
10
10
|
require_relative "capybara/lightpanda/process"
|
|
11
11
|
require_relative "capybara/lightpanda/utils/event"
|
|
12
12
|
require_relative "capybara/lightpanda/utils/attempt"
|
|
13
|
+
require_relative "capybara/lightpanda/utils/wait"
|
|
13
14
|
require_relative "capybara/lightpanda/client"
|
|
15
|
+
require_relative "capybara/lightpanda/headers"
|
|
14
16
|
require_relative "capybara/lightpanda/network"
|
|
15
17
|
require_relative "capybara/lightpanda/cookies"
|
|
16
18
|
require_relative "capybara/lightpanda/keyboard"
|
|
17
|
-
require_relative "capybara/lightpanda/frame"
|
|
18
19
|
require_relative "capybara/lightpanda/browser"
|
|
19
|
-
require_relative "capybara/lightpanda/
|
|
20
|
+
require_relative "capybara/lightpanda/auto_scripts"
|
|
20
21
|
require_relative "capybara/lightpanda/node"
|
|
22
|
+
require_relative "capybara/lightpanda/element_extension"
|
|
21
23
|
require_relative "capybara/lightpanda/driver"
|
|
22
24
|
|
|
23
25
|
module Capybara
|