capybara-lightpanda 0.2.0 → 0.2.2
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 +43 -0
- data/lib/capybara/lightpanda/binary.rb +1 -1
- data/lib/capybara/lightpanda/browser.rb +168 -19
- data/lib/capybara/lightpanda/client/subscriber.rb +29 -2
- data/lib/capybara/lightpanda/client.rb +7 -0
- data/lib/capybara/lightpanda/driver.rb +5 -5
- data/lib/capybara/lightpanda/errors.rb +30 -5
- data/lib/capybara/lightpanda/javascripts/polyfills.js +212 -0
- data/lib/capybara/lightpanda/network.rb +32 -2
- data/lib/capybara/lightpanda/node.rb +105 -4
- data/lib/capybara/lightpanda/process.rb +8 -5
- data/lib/capybara/lightpanda/version.rb +1 -1
- data/lib/capybara/lightpanda/xpath_polyfill.rb +5 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1cb36e68fb7bcc35abcbae5a3417091eb54444f7b57e161cb44cb6436d243684
|
|
4
|
+
data.tar.gz: 9eff2c5cd278d16ed8ad55d106dca14dab3597a87c3315c46371b98672a35163
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 156126b7f57785869aca23b79fe02bc3fe7370cf5b2826999715d5e69794a6b62f8c69bb64601e5e67f935d946e2348274c8ec76d79b674930b239d6b2383677
|
|
7
|
+
data.tar.gz: c73dab9284d0d8e0091794e2f88674951bafc64af2fa2e87200926a60d7d86767f31cd5114d5348af51013fe3b4846f1066d0bdca886a5a1051432673b76ef52
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,48 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.2.2] - 2026-05-06
|
|
4
|
+
|
|
5
|
+
> **Update Lightpanda before upgrading.** Requires a nightly build ≥ 6065 (published 2026-05-06). The driver refuses to start against older binaries.
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Turbo Drive `<form>` submissions now intercept correctly. Forms inside a
|
|
10
|
+
Hotwire / Turbo Drive page no longer crash on submit, and Turbo's `submit`
|
|
11
|
+
interceptors fire as they should — `click_button`, `find('form').submit`,
|
|
12
|
+
and Enter-key implicit submission all complete end-to-end.
|
|
13
|
+
- `evaluate_script` and `execute_script` calls with top-level `const` / `let`
|
|
14
|
+
no longer collide across calls. Consecutive scripts that each declare the
|
|
15
|
+
same identifier used to fail with `Identifier 'foo' has already been
|
|
16
|
+
declared`; they're now isolated. `execute_script` also now raises on
|
|
17
|
+
JavaScript errors instead of silently swallowing them.
|
|
18
|
+
- Same-document fragment-only `<a href="#…">` clicks update the URL hash
|
|
19
|
+
instead of triggering a real navigation. Tests that drive DOM updates from
|
|
20
|
+
an anchor click no longer lose pending `setTimeout` callbacks or have form
|
|
21
|
+
values cleared from under them.
|
|
22
|
+
- `body` returns an empty string rather than crashing during the brief window
|
|
23
|
+
after `reset_session!` when the new session has a target but no document yet.
|
|
24
|
+
- Stale element references during cross-document navigation now resolve to
|
|
25
|
+
`nil` internally instead of bubbling a browser error up to your test,
|
|
26
|
+
letting Capybara's automatic-reload pick a fresh element.
|
|
27
|
+
|
|
28
|
+
### Internal
|
|
29
|
+
|
|
30
|
+
- One internal polyfill removed: Lightpanda now matches the spec when a DOM
|
|
31
|
+
event listener throws (a throwing listener no longer halts the rest of the
|
|
32
|
+
bubble walk), so the gem doesn't need to compensate. No code change required
|
|
33
|
+
on your end.
|
|
34
|
+
|
|
35
|
+
## [0.2.1] - 2026-05-05
|
|
36
|
+
|
|
37
|
+
### Fixed
|
|
38
|
+
|
|
39
|
+
- Turbo Frame links now correctly swap the frame instead of falling through to a full-page navigation. Affects any test that clicks a link or submits a form inside a `<turbo-frame>`.
|
|
40
|
+
- Internal driver errors (`NoMethodError`, `NameError`, `Errno::*`) inside `extract_node_object_ids` and `page_ready?` are no longer swallowed and silently downgraded to `[]` / `false` — they surface as real exceptions so bugs are visible.
|
|
41
|
+
|
|
42
|
+
### Internal
|
|
43
|
+
|
|
44
|
+
- Local test suite migrated from RSpec to Minitest::Spec. The Capybara shared-spec battery (`spec/features/session_spec.rb`) still runs on RSpec.
|
|
45
|
+
|
|
3
46
|
## [0.2.0] - 2026-05-04
|
|
4
47
|
|
|
5
48
|
Reliability and feature polish as Lightpanda matured. **Update Lightpanda before upgrading**: this release requires a current nightly (the gem will tell you if yours is too old).
|
|
@@ -8,7 +8,7 @@ module Capybara
|
|
|
8
8
|
class Browser
|
|
9
9
|
extend Forwardable
|
|
10
10
|
|
|
11
|
-
attr_reader :options, :process, :client, :target_id, :session_id, :frame_stack
|
|
11
|
+
attr_reader :options, :process, :client, :target_id, :session_id, :browser_context_id, :frame_stack
|
|
12
12
|
|
|
13
13
|
delegate %i[on off] => :client
|
|
14
14
|
|
|
@@ -29,6 +29,7 @@ module Capybara
|
|
|
29
29
|
@client = nil
|
|
30
30
|
@target_id = nil
|
|
31
31
|
@session_id = nil
|
|
32
|
+
@browser_context_id = nil
|
|
32
33
|
@started = false
|
|
33
34
|
@page_events_enabled = false
|
|
34
35
|
@modal_messages = []
|
|
@@ -52,13 +53,25 @@ module Capybara
|
|
|
52
53
|
@client = Client.new(@process.ws_url, @options)
|
|
53
54
|
end
|
|
54
55
|
|
|
56
|
+
create_browser_context
|
|
55
57
|
create_page
|
|
56
58
|
|
|
57
59
|
@started = true
|
|
58
60
|
end
|
|
59
61
|
|
|
62
|
+
# Per-session BrowserContext (Chrome's incognito-profile primitive).
|
|
63
|
+
# Cookies, storage, and targets created within the context are wiped
|
|
64
|
+
# when it's disposed — so `reset` is one CDP call instead of an
|
|
65
|
+
# explicit cookies.clear / storage.clear / close-target dance.
|
|
66
|
+
# Mirrors ferrum's Contexts model.
|
|
67
|
+
def create_browser_context
|
|
68
|
+
result = @client.command("Target.createBrowserContext")
|
|
69
|
+
@browser_context_id = result["browserContextId"]
|
|
70
|
+
end
|
|
71
|
+
|
|
60
72
|
def create_page
|
|
61
|
-
result = @client.command("Target.createTarget",
|
|
73
|
+
result = @client.command("Target.createTarget",
|
|
74
|
+
{ url: "about:blank", browserContextId: @browser_context_id }.compact)
|
|
62
75
|
@target_id = result["targetId"]
|
|
63
76
|
|
|
64
77
|
attach_result = @client.command("Target.attachToTarget", { targetId: @target_id, flatten: true })
|
|
@@ -78,6 +91,33 @@ module Capybara
|
|
|
78
91
|
start
|
|
79
92
|
end
|
|
80
93
|
|
|
94
|
+
# Wipe per-session state — cookies, storage, all targets — and start
|
|
95
|
+
# over with a fresh BrowserContext. Mirrors ferrum's Browser#reset:
|
|
96
|
+
# one CDP call (`Target.disposeBrowserContext`) does the work that
|
|
97
|
+
# would otherwise require explicit cookies.clear / storage.clear /
|
|
98
|
+
# close-target dance, and the browser auto-isolates state for the
|
|
99
|
+
# new context. Driver#reset! delegates here.
|
|
100
|
+
#
|
|
101
|
+
# Side benefit: avoids `Page.navigate("about:blank")` against a
|
|
102
|
+
# non-blank tab, which doesn't actually replace the document on
|
|
103
|
+
# current Lightpanda nightly (lightpanda-io/browser#2363). The
|
|
104
|
+
# context-disposal path sidesteps that bug entirely.
|
|
105
|
+
def reset
|
|
106
|
+
dispose_browser_context
|
|
107
|
+
@client.clear_subscriptions
|
|
108
|
+
@page_events_enabled = false
|
|
109
|
+
@modal_handler_installed = false
|
|
110
|
+
@modal_messages.clear
|
|
111
|
+
@frame_stack.clear
|
|
112
|
+
# Network#reset, not #clear: disposing the BrowserContext also
|
|
113
|
+
# destroyed the Network domain and its subscriptions, so we must
|
|
114
|
+
# flip @enabled back to false — otherwise the next #enable
|
|
115
|
+
# short-circuits and traffic tracking is silently dead.
|
|
116
|
+
@network&.reset
|
|
117
|
+
create_browser_context
|
|
118
|
+
create_page
|
|
119
|
+
end
|
|
120
|
+
|
|
81
121
|
# Recover after a WebSocket disconnect or process crash during navigation.
|
|
82
122
|
# Restarts the process if it died, then creates a fresh client and page.
|
|
83
123
|
def reconnect
|
|
@@ -88,6 +128,9 @@ module Capybara
|
|
|
88
128
|
raise DeadBrowserError, "Cannot reconnect: no WebSocket URL" unless ws_url
|
|
89
129
|
|
|
90
130
|
@client = Client.new(ws_url, @options)
|
|
131
|
+
# Process may have died; the old browserContextId is gone with it.
|
|
132
|
+
@browser_context_id = nil
|
|
133
|
+
create_browser_context
|
|
91
134
|
create_page
|
|
92
135
|
@page_events_enabled = false
|
|
93
136
|
end
|
|
@@ -106,6 +149,9 @@ module Capybara
|
|
|
106
149
|
@client = nil
|
|
107
150
|
@process = nil
|
|
108
151
|
@started = false
|
|
152
|
+
@browser_context_id = nil
|
|
153
|
+
@target_id = nil
|
|
154
|
+
@session_id = nil
|
|
109
155
|
@modal_handler_installed = false
|
|
110
156
|
@frame_stack.clear
|
|
111
157
|
end
|
|
@@ -192,7 +238,11 @@ module Capybara
|
|
|
192
238
|
end
|
|
193
239
|
|
|
194
240
|
def body
|
|
195
|
-
|
|
241
|
+
# Guard against the brief window after a fresh BrowserContext / target
|
|
242
|
+
# is created where the V8 context exists but `document.documentElement`
|
|
243
|
+
# is still null. Hit by Capybara's `#reset_session! resets page body`
|
|
244
|
+
# spec since the 0.2.0 Ferrum-style reset rewrite.
|
|
245
|
+
evaluate("(document.documentElement && document.documentElement.outerHTML) || ''")
|
|
196
246
|
end
|
|
197
247
|
alias html body
|
|
198
248
|
|
|
@@ -201,10 +251,29 @@ module Capybara
|
|
|
201
251
|
# and dispatch via Runtime.callFunctionOn so `arguments[i]` is bound.
|
|
202
252
|
# Both paths use `returnByValue: false` and unwrap so DOM-node returns
|
|
203
253
|
# come back as `{ "__lightpanda_node__" => ... }` for the Driver to wrap.
|
|
254
|
+
#
|
|
255
|
+
# Even the no-args path wraps the expression in an IIFE to isolate
|
|
256
|
+
# top-level `const`/`let` declarations. Upstream Lightpanda retains
|
|
257
|
+
# those bindings across `Runtime.evaluate` calls (V8 starts each call
|
|
258
|
+
# with fresh lexical scope per spec), so a second `const sel = ...`
|
|
259
|
+
# raises `SyntaxError: Identifier 'sel' has already been declared`.
|
|
260
|
+
# Wrapping pushes the declarations into a function scope that gets
|
|
261
|
+
# discarded when the IIFE returns.
|
|
262
|
+
#
|
|
263
|
+
# Use direct `eval` inside the IIFE so the user's text can be a bare
|
|
264
|
+
# expression (`'foo'`), a `throw` statement, OR a multi-statement
|
|
265
|
+
# script with `const`/`let`. `eval`'s completion-value semantics
|
|
266
|
+
# return the last expression's value in all cases. A naive
|
|
267
|
+
# `return EXPR;` wrap would syntax-error on `throw …` and on
|
|
268
|
+
# multi-statement scripts.
|
|
204
269
|
def evaluate(expression, *args)
|
|
205
270
|
if args.empty?
|
|
206
|
-
|
|
207
|
-
|
|
271
|
+
wrapped = "(function(){return eval(#{expression.to_json})})()"
|
|
272
|
+
response = page_command("Runtime.evaluate", expression: wrapped, returnByValue: false, awaitPromise: true)
|
|
273
|
+
if response["exceptionDetails"]
|
|
274
|
+
debug_js_failure("evaluate", expression, response)
|
|
275
|
+
raise JavaScriptError, response
|
|
276
|
+
end
|
|
208
277
|
|
|
209
278
|
return unwrap_call_result(response["result"])
|
|
210
279
|
end
|
|
@@ -214,9 +283,20 @@ module Capybara
|
|
|
214
283
|
end
|
|
215
284
|
|
|
216
285
|
# Execute JS without returning a value.
|
|
286
|
+
#
|
|
287
|
+
# Like `evaluate`, the no-args path wraps in an IIFE — same upstream
|
|
288
|
+
# `const`/`let` leak. Also raises on JS exceptions so silent
|
|
289
|
+
# failures don't mask test bugs (the previous fast path swallowed them
|
|
290
|
+
# because `awaitPromise: false` was checked but `exceptionDetails` was
|
|
291
|
+
# not).
|
|
217
292
|
def execute(expression, *args)
|
|
218
293
|
if args.empty?
|
|
219
|
-
|
|
294
|
+
wrapped = "(function(){#{expression}})()"
|
|
295
|
+
response = page_command("Runtime.evaluate", expression: wrapped, returnByValue: false, awaitPromise: false)
|
|
296
|
+
if response["exceptionDetails"]
|
|
297
|
+
debug_js_failure("execute", expression, response)
|
|
298
|
+
raise JavaScriptError, response
|
|
299
|
+
end
|
|
220
300
|
return nil
|
|
221
301
|
end
|
|
222
302
|
|
|
@@ -225,6 +305,15 @@ module Capybara
|
|
|
225
305
|
nil
|
|
226
306
|
end
|
|
227
307
|
|
|
308
|
+
# When LIGHTPANDA_DEBUG=1 is set, log the JS expression and full CDP
|
|
309
|
+
# response for every JsException to STDERR. Invaluable for isolating
|
|
310
|
+
# which exact JS triggers an upstream Lightpanda bug.
|
|
311
|
+
def debug_js_failure(site, expression, response)
|
|
312
|
+
return unless ENV["LIGHTPANDA_DEBUG"]
|
|
313
|
+
|
|
314
|
+
warn "[lightpanda:#{site}] expression:\n#{expression}\n[lightpanda:#{site}] response:\n#{response.inspect}\n"
|
|
315
|
+
end
|
|
316
|
+
|
|
228
317
|
# Evaluate async JS with a callback. The user's script receives
|
|
229
318
|
# the callback as its last argument (`arguments[arguments.length - 1]`),
|
|
230
319
|
# matching Capybara's evaluate_async_script contract.
|
|
@@ -249,7 +338,10 @@ module Capybara
|
|
|
249
338
|
# Evaluate JS and return a RemoteObject reference (for DOM nodes, arrays).
|
|
250
339
|
def evaluate_with_ref(expression)
|
|
251
340
|
response = page_command("Runtime.evaluate", expression: expression, returnByValue: false, awaitPromise: true)
|
|
252
|
-
|
|
341
|
+
if response["exceptionDetails"]
|
|
342
|
+
debug_js_failure("evaluate_with_ref", expression, response)
|
|
343
|
+
raise JavaScriptError, response
|
|
344
|
+
end
|
|
253
345
|
|
|
254
346
|
result = response["result"]
|
|
255
347
|
return nil if result["type"] == "undefined"
|
|
@@ -269,7 +361,10 @@ module Capybara
|
|
|
269
361
|
params[:arguments] = args.map { |a| serialize_argument(a) } unless args.empty?
|
|
270
362
|
|
|
271
363
|
response = page_command("Runtime.callFunctionOn", **params)
|
|
272
|
-
|
|
364
|
+
if response["exceptionDetails"]
|
|
365
|
+
debug_js_failure("call_function_on", function_declaration, response)
|
|
366
|
+
raise JavaScriptError, response
|
|
367
|
+
end
|
|
273
368
|
|
|
274
369
|
result = response["result"]
|
|
275
370
|
return nil if result["type"] == "undefined"
|
|
@@ -304,6 +399,8 @@ module Capybara
|
|
|
304
399
|
def find_within(remote_object_id, method, selector)
|
|
305
400
|
result = call_function_on(remote_object_id, FIND_WITHIN_JS, method, selector, return_by_value: false)
|
|
306
401
|
extract_node_object_ids(result)
|
|
402
|
+
rescue JavaScriptError => e
|
|
403
|
+
raise_invalid_selector(e, method, selector)
|
|
307
404
|
end
|
|
308
405
|
|
|
309
406
|
# objectId of document.activeElement, or nil if none/document detached.
|
|
@@ -528,20 +625,32 @@ module Capybara
|
|
|
528
625
|
raise Capybara::ModalNotFound, "Unable to find modal dialog#{" with #{text}" if text}"
|
|
529
626
|
end
|
|
530
627
|
|
|
628
|
+
# Sentinel string thrown from FIND_*_JS when querySelectorAll rejects a
|
|
629
|
+
# malformed selector, so the Ruby side can convert JavaScriptError into
|
|
630
|
+
# Capybara::Lightpanda::InvalidSelector. Cuprite uses a JS subclass for
|
|
631
|
+
# the same purpose; a plain prefixed string keeps our inline JS simple.
|
|
632
|
+
INVALID_SELECTOR_MARKER = "LIGHTPANDA_INVALID_SELECTOR:"
|
|
633
|
+
|
|
531
634
|
# JS function for finding elements within a node.
|
|
532
|
-
# Works in any execution context (top frame or iframe).
|
|
533
|
-
|
|
635
|
+
# Works in any execution context (top frame or iframe). Any throw from
|
|
636
|
+
# querySelectorAll means the selector is malformed (the spec only allows
|
|
637
|
+
# SYNTAX_ERR DOMException; Lightpanda's V8 currently throws a generic
|
|
638
|
+
# Error with messages like "InvalidClassSelector"). Re-throw with the
|
|
639
|
+
# marker prefix so Ruby converts to InvalidSelector regardless.
|
|
640
|
+
FIND_WITHIN_JS = <<~JS.freeze
|
|
534
641
|
function(method, selector) {
|
|
535
642
|
if (method === 'xpath') {
|
|
536
643
|
if (typeof _lightpanda !== 'undefined') return _lightpanda.xpathFind(selector, this);
|
|
537
644
|
return [];
|
|
538
645
|
}
|
|
539
|
-
try { return Array.from(this.querySelectorAll(selector)); }
|
|
646
|
+
try { return Array.from(this.querySelectorAll(selector)); }
|
|
647
|
+
catch(e) { throw new Error('#{INVALID_SELECTOR_MARKER}' + selector); }
|
|
540
648
|
}
|
|
541
649
|
JS
|
|
650
|
+
private_constant :FIND_WITHIN_JS
|
|
542
651
|
|
|
543
652
|
# JS function for finding elements in an iframe's contentDocument.
|
|
544
|
-
FIND_IN_FRAME_JS = <<~JS
|
|
653
|
+
FIND_IN_FRAME_JS = <<~JS.freeze
|
|
545
654
|
function(method, selector) {
|
|
546
655
|
var doc;
|
|
547
656
|
try { doc = this.contentDocument || (this.contentWindow && this.contentWindow.document); } catch(e) {}
|
|
@@ -550,9 +659,11 @@ module Capybara
|
|
|
550
659
|
if (typeof _lightpanda !== 'undefined') return _lightpanda.xpathFind(selector, doc);
|
|
551
660
|
return [];
|
|
552
661
|
}
|
|
553
|
-
try { return Array.from(doc.querySelectorAll(selector)); }
|
|
662
|
+
try { return Array.from(doc.querySelectorAll(selector)); }
|
|
663
|
+
catch(e) { throw new Error('#{INVALID_SELECTOR_MARKER}' + selector); }
|
|
554
664
|
}
|
|
555
665
|
JS
|
|
666
|
+
private_constant :FIND_IN_FRAME_JS
|
|
556
667
|
|
|
557
668
|
def find_in_document(method, selector)
|
|
558
669
|
with_default_context_wait do
|
|
@@ -563,12 +674,18 @@ module Capybara
|
|
|
563
674
|
js = if method == "xpath"
|
|
564
675
|
"(typeof _lightpanda !== 'undefined') ? _lightpanda.xpathFind(#{selector_literal}, document) : []"
|
|
565
676
|
else
|
|
566
|
-
|
|
567
|
-
|
|
677
|
+
<<~CSS_FIND
|
|
678
|
+
(function() {
|
|
679
|
+
try { return Array.from(document.querySelectorAll(#{selector_literal})); }
|
|
680
|
+
catch(e) { throw new Error('#{INVALID_SELECTOR_MARKER}' + #{selector_literal}); }
|
|
681
|
+
})()
|
|
682
|
+
CSS_FIND
|
|
568
683
|
end
|
|
569
684
|
result = evaluate_with_ref(js)
|
|
570
685
|
extract_node_object_ids(result)
|
|
571
686
|
end
|
|
687
|
+
rescue JavaScriptError => e
|
|
688
|
+
raise_invalid_selector(e, method, selector)
|
|
572
689
|
end
|
|
573
690
|
|
|
574
691
|
def find_in_frame(method, selector)
|
|
@@ -576,6 +693,16 @@ module Capybara
|
|
|
576
693
|
result = call_function_on(frame_node.remote_object_id, FIND_IN_FRAME_JS, method, selector,
|
|
577
694
|
return_by_value: false)
|
|
578
695
|
extract_node_object_ids(result)
|
|
696
|
+
rescue JavaScriptError => e
|
|
697
|
+
raise_invalid_selector(e, method, selector)
|
|
698
|
+
end
|
|
699
|
+
|
|
700
|
+
def raise_invalid_selector(js_error, method, selector)
|
|
701
|
+
if js_error.message.include?(INVALID_SELECTOR_MARKER)
|
|
702
|
+
raise InvalidSelector.new("Invalid #{method} selector: #{selector.inspect}", method, selector)
|
|
703
|
+
end
|
|
704
|
+
|
|
705
|
+
raise js_error
|
|
579
706
|
end
|
|
580
707
|
|
|
581
708
|
# Extract individual node objectIds from a remote array reference.
|
|
@@ -592,12 +719,13 @@ module Capybara
|
|
|
592
719
|
|
|
593
720
|
release_object(result["objectId"])
|
|
594
721
|
ids
|
|
595
|
-
rescue
|
|
722
|
+
rescue Error
|
|
596
723
|
[]
|
|
597
724
|
end
|
|
598
725
|
|
|
599
726
|
def register_auto_scripts
|
|
600
727
|
page_command("Page.addScriptToEvaluateOnNewDocument", source: XPathPolyfill::JS)
|
|
728
|
+
page_command("Page.addScriptToEvaluateOnNewDocument", source: XPathPolyfill::POLYFILLS_JS)
|
|
601
729
|
end
|
|
602
730
|
|
|
603
731
|
def subscribe_to_console_logs
|
|
@@ -716,7 +844,10 @@ module Capybara
|
|
|
716
844
|
end
|
|
717
845
|
|
|
718
846
|
def handle_evaluate_response(response)
|
|
719
|
-
|
|
847
|
+
if response["exceptionDetails"]
|
|
848
|
+
debug_js_failure("handle_evaluate_response", "(unknown — already-issued call)", response)
|
|
849
|
+
raise JavaScriptError, response
|
|
850
|
+
end
|
|
720
851
|
|
|
721
852
|
result = response["result"]
|
|
722
853
|
return nil if result["type"] == "undefined"
|
|
@@ -738,7 +869,10 @@ module Capybara
|
|
|
738
869
|
arguments: args.map { |a| serialize_argument(a) },
|
|
739
870
|
}
|
|
740
871
|
response = page_command("Runtime.callFunctionOn", **params)
|
|
741
|
-
|
|
872
|
+
if response["exceptionDetails"]
|
|
873
|
+
debug_js_failure("call_with_args", function_declaration, response)
|
|
874
|
+
raise JavaScriptError, response
|
|
875
|
+
end
|
|
742
876
|
|
|
743
877
|
return_by_value ? handle_evaluate_response(response) : unwrap_call_result(response["result"])
|
|
744
878
|
end
|
|
@@ -839,6 +973,21 @@ module Capybara
|
|
|
839
973
|
nil
|
|
840
974
|
end
|
|
841
975
|
|
|
976
|
+
def dispose_browser_context
|
|
977
|
+
return unless @browser_context_id
|
|
978
|
+
|
|
979
|
+
begin
|
|
980
|
+
@client.command("Target.disposeBrowserContext", { browserContextId: @browser_context_id })
|
|
981
|
+
rescue StandardError
|
|
982
|
+
# Context may already be disposed or the WS may be down; we
|
|
983
|
+
# recreate either way.
|
|
984
|
+
ensure
|
|
985
|
+
@browser_context_id = nil
|
|
986
|
+
@target_id = nil
|
|
987
|
+
@session_id = nil
|
|
988
|
+
end
|
|
989
|
+
end
|
|
990
|
+
|
|
842
991
|
def restart_process_if_dead
|
|
843
992
|
return unless @process && !@process.alive?
|
|
844
993
|
|
|
@@ -927,7 +1076,7 @@ module Capybara
|
|
|
927
1076
|
|
|
928
1077
|
url_changed = starting_url.nil? || state["u"] != starting_url
|
|
929
1078
|
url_changed && %w[complete interactive].include?(state["r"])
|
|
930
|
-
rescue
|
|
1079
|
+
rescue Error
|
|
931
1080
|
false
|
|
932
1081
|
end
|
|
933
1082
|
|
|
@@ -4,9 +4,18 @@ module Capybara
|
|
|
4
4
|
module Lightpanda
|
|
5
5
|
class Client
|
|
6
6
|
class Subscriber
|
|
7
|
-
|
|
7
|
+
# Default error sink: write a one-line warning so a misbehaving handler
|
|
8
|
+
# is visible without crashing the CDP message thread. Tests can inject
|
|
9
|
+
# a custom proc via `on_error:` to capture failures.
|
|
10
|
+
DEFAULT_ON_ERROR = lambda do |event, error|
|
|
11
|
+
warn("[capybara-lightpanda] subscriber callback for #{event.inspect} raised " \
|
|
12
|
+
"#{error.class}: #{error.message}")
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def initialize(on_error: DEFAULT_ON_ERROR)
|
|
8
16
|
@subscriptions = Hash.new { |h, k| h[k] = [] }
|
|
9
17
|
@mutex = Mutex.new
|
|
18
|
+
@on_error = on_error
|
|
10
19
|
end
|
|
11
20
|
|
|
12
21
|
def subscribe(event, &block)
|
|
@@ -25,10 +34,28 @@ module Capybara
|
|
|
25
34
|
end
|
|
26
35
|
end
|
|
27
36
|
|
|
37
|
+
# Run every callback registered for `event`. Exceptions in one
|
|
38
|
+
# callback must not stop the others or propagate out — the message
|
|
39
|
+
# thread sets `abort_on_exception = true`, so an unhandled raise
|
|
40
|
+
# would tear down the entire CDP connection.
|
|
41
|
+
#
|
|
42
|
+
# Two layers of rescue:
|
|
43
|
+
# 1. The callback itself may raise — route to @on_error.
|
|
44
|
+
# 2. @on_error itself may raise (custom hook, broken stderr) —
|
|
45
|
+
# swallow at the last level so the dispatch loop survives.
|
|
28
46
|
def dispatch(event, params)
|
|
29
47
|
callbacks = @mutex.synchronize { @subscriptions[event].dup }
|
|
30
48
|
|
|
31
|
-
callbacks.each
|
|
49
|
+
callbacks.each do |callback|
|
|
50
|
+
callback.call(params)
|
|
51
|
+
rescue StandardError => e
|
|
52
|
+
begin
|
|
53
|
+
@on_error.call(event, e)
|
|
54
|
+
rescue StandardError
|
|
55
|
+
# The error sink failed — nothing to do but keep going. We
|
|
56
|
+
# cannot log here without re-entering the broken path.
|
|
57
|
+
end
|
|
58
|
+
end
|
|
32
59
|
end
|
|
33
60
|
|
|
34
61
|
def subscribed?(event)
|
|
@@ -60,6 +60,13 @@ module Capybara
|
|
|
60
60
|
@subscriber.unsubscribe(event, block)
|
|
61
61
|
end
|
|
62
62
|
|
|
63
|
+
# Drop all event subscriptions without closing the WebSocket. Used by
|
|
64
|
+
# Browser#recreate_page so a fresh target's event handlers don't pile
|
|
65
|
+
# up on top of the previous target's subscriptions.
|
|
66
|
+
def clear_subscriptions
|
|
67
|
+
@subscriber.clear
|
|
68
|
+
end
|
|
69
|
+
|
|
63
70
|
def close
|
|
64
71
|
@ws&.close
|
|
65
72
|
@message_thread&.join(1) || @message_thread&.kill
|
|
@@ -196,12 +196,12 @@ module Capybara
|
|
|
196
196
|
|
|
197
197
|
# -- Lifecycle --
|
|
198
198
|
|
|
199
|
+
# Thin Cuprite-style wrapper. The interesting work — disposing the
|
|
200
|
+
# BrowserContext (cookies, storage, all targets) and starting a fresh
|
|
201
|
+
# one — happens in Browser#reset.
|
|
199
202
|
def reset!
|
|
200
|
-
browser.
|
|
201
|
-
|
|
202
|
-
browser.cookies.clear
|
|
203
|
-
browser.network.clear
|
|
204
|
-
browser.go_to("about:blank")
|
|
203
|
+
browser.reset
|
|
204
|
+
@started = false
|
|
205
205
|
rescue StandardError
|
|
206
206
|
@browser&.quit
|
|
207
207
|
@browser = nil
|
|
@@ -46,11 +46,36 @@ module Capybara
|
|
|
46
46
|
attr_reader :class_name, :stack_trace
|
|
47
47
|
|
|
48
48
|
def initialize(response)
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
super(
|
|
49
|
+
details = response["exceptionDetails"] || {}
|
|
50
|
+
exception = details["exception"] || {}
|
|
51
|
+
@class_name = exception["className"]
|
|
52
|
+
@stack_trace = details["stackTrace"]
|
|
53
|
+
super(build_message(details, exception))
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def build_message(details, exception)
|
|
59
|
+
base = exception["description"] || details["text"] || "JsException"
|
|
60
|
+
parts = [base]
|
|
61
|
+
parts << "(#{@class_name})" if @class_name && !base.include?(@class_name)
|
|
62
|
+
if (val = exception["value"])
|
|
63
|
+
parts << "value=#{val.inspect}"
|
|
64
|
+
end
|
|
65
|
+
if @stack_trace && (frames = @stack_trace["callFrames"])
|
|
66
|
+
parts << format_stack(frames)
|
|
67
|
+
end
|
|
68
|
+
parts.join(" | ")
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def format_stack(frames)
|
|
72
|
+
formatted = frames.first(5).map { |f| format_frame(f) }
|
|
73
|
+
"stack:\n #{formatted.join("\n ")}"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def format_frame(frame)
|
|
77
|
+
name = frame["functionName"].to_s.empty? ? "<anon>" : frame["functionName"]
|
|
78
|
+
"#{name} @ #{frame['url']}:#{frame['lineNumber']}:#{frame['columnNumber']}"
|
|
54
79
|
end
|
|
55
80
|
end
|
|
56
81
|
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// Polyfills compensant des limitations du binaire Lightpanda.
|
|
2
|
+
// Chaque section est gardée par un test de feature : dès qu'upstream implémente
|
|
3
|
+
// l'API native, le polyfill devient un no-op et peut être retiré.
|
|
4
|
+
// Voir UPSTREAM_BUGS.md à la racine du gem pour les repros et liens d'issues.
|
|
5
|
+
(function () {
|
|
6
|
+
"use strict";
|
|
7
|
+
|
|
8
|
+
// ── Bug #7 — HTMLFormElement / HTMLButtonElement / HTMLInputElement form-* IDL gaps ──
|
|
9
|
+
// Lightpanda doesn't expose `form.enctype`, `form.method`, `form.action`,
|
|
10
|
+
// `form.target`, nor the submitter-side `formEnctype` / `formMethod` /
|
|
11
|
+
// `formAction` / `formTarget` overrides. Per WHATWG HTML these must always
|
|
12
|
+
// return a string (with the spec's missing-value default) so consumers can
|
|
13
|
+
// call `.toLowerCase()` etc. directly. Turbo's `FormSubmission` constructor
|
|
14
|
+
// does exactly that and crashes with `Cannot read properties of undefined
|
|
15
|
+
// (reading 'toLowerCase')` when it touches enctype.
|
|
16
|
+
//
|
|
17
|
+
// Polyfill strategy: only define the IDL getter when it's missing on the
|
|
18
|
+
// prototype, so a future Lightpanda nightly that adds native support wins
|
|
19
|
+
// automatically. Each getter falls back to the underlying attribute, with
|
|
20
|
+
// the spec's default if the attribute is absent. For submitter overrides
|
|
21
|
+
// (formEnctype, formMethod, etc.) we return the empty string when the
|
|
22
|
+
// override attribute is unset — Turbo and Hotwire all use the
|
|
23
|
+
// `submitter.formX || form.X` idiom, which resolves correctly when the
|
|
24
|
+
// submitter side returns "".
|
|
25
|
+
(function patchFormIDL() {
|
|
26
|
+
var ENCTYPE_VALUES = ["application/x-www-form-urlencoded", "multipart/form-data", "text/plain"];
|
|
27
|
+
function normEnctype(v) {
|
|
28
|
+
if (!v) return "application/x-www-form-urlencoded";
|
|
29
|
+
v = String(v).toLowerCase();
|
|
30
|
+
return ENCTYPE_VALUES.indexOf(v) >= 0 ? v : "application/x-www-form-urlencoded";
|
|
31
|
+
}
|
|
32
|
+
function normMethod(v) {
|
|
33
|
+
if (!v) return "get";
|
|
34
|
+
v = String(v).toLowerCase();
|
|
35
|
+
return (v === "post" || v === "dialog") ? v : "get";
|
|
36
|
+
}
|
|
37
|
+
function defineIfMissing(proto, name, getter) {
|
|
38
|
+
if (!proto || name in proto) return;
|
|
39
|
+
try { Object.defineProperty(proto, name, { configurable: true, enumerable: true, get: getter }); } catch (_) {}
|
|
40
|
+
}
|
|
41
|
+
if (typeof HTMLFormElement !== "undefined") {
|
|
42
|
+
var fp = HTMLFormElement.prototype;
|
|
43
|
+
defineIfMissing(fp, "enctype", function () { return normEnctype(this.getAttribute("enctype")); });
|
|
44
|
+
defineIfMissing(fp, "method", function () { return normMethod(this.getAttribute("method")); });
|
|
45
|
+
defineIfMissing(fp, "action", function () {
|
|
46
|
+
var a = this.getAttribute("action");
|
|
47
|
+
if (a == null || a === "") return (this.ownerDocument && this.ownerDocument.URL) || "";
|
|
48
|
+
try { return new URL(a, (this.ownerDocument && this.ownerDocument.URL) || undefined).href; }
|
|
49
|
+
catch (_) { return a; }
|
|
50
|
+
});
|
|
51
|
+
defineIfMissing(fp, "target", function () { return this.getAttribute("target") || ""; });
|
|
52
|
+
}
|
|
53
|
+
function patchSubmitter(Ctor) {
|
|
54
|
+
if (typeof Ctor === "undefined") return;
|
|
55
|
+
var p = Ctor.prototype;
|
|
56
|
+
// Empty string is the spec's missing-value default for the submitter-side
|
|
57
|
+
// IDL attrs — keep Turbo's `submitter.formX || form.X` idiom flowing
|
|
58
|
+
// through to the form's value.
|
|
59
|
+
defineIfMissing(p, "formEnctype", function () {
|
|
60
|
+
var v = this.getAttribute("formenctype");
|
|
61
|
+
return v == null ? "" : normEnctype(v);
|
|
62
|
+
});
|
|
63
|
+
defineIfMissing(p, "formMethod", function () {
|
|
64
|
+
var v = this.getAttribute("formmethod");
|
|
65
|
+
return v == null ? "" : normMethod(v);
|
|
66
|
+
});
|
|
67
|
+
defineIfMissing(p, "formAction", function () {
|
|
68
|
+
var a = this.getAttribute("formaction");
|
|
69
|
+
if (a == null || a === "") return "";
|
|
70
|
+
try { return new URL(a, (this.ownerDocument && this.ownerDocument.URL) || undefined).href; }
|
|
71
|
+
catch (_) { return a; }
|
|
72
|
+
});
|
|
73
|
+
defineIfMissing(p, "formTarget", function () { return this.getAttribute("formtarget") || ""; });
|
|
74
|
+
defineIfMissing(p, "formNoValidate", function () { return this.hasAttribute("formnovalidate"); });
|
|
75
|
+
}
|
|
76
|
+
patchSubmitter(typeof HTMLButtonElement !== "undefined" ? HTMLButtonElement : null);
|
|
77
|
+
patchSubmitter(typeof HTMLInputElement !== "undefined" ? HTMLInputElement : null);
|
|
78
|
+
})();
|
|
79
|
+
|
|
80
|
+
// ── Bug #4 — HTMLDialogElement.{showModal, show, close} non implémentés ──
|
|
81
|
+
// https://html.spec.whatwg.org/multipage/interactive-elements.html#the-dialog-element
|
|
82
|
+
if (typeof HTMLDialogElement !== "undefined") {
|
|
83
|
+
var dproto = HTMLDialogElement.prototype;
|
|
84
|
+
if (typeof dproto.showModal !== "function") {
|
|
85
|
+
dproto.showModal = function () {
|
|
86
|
+
if (this.hasAttribute("open")) {
|
|
87
|
+
throw new (window.DOMException || Error)(
|
|
88
|
+
"The element already has an 'open' attribute, and therefore cannot be opened modally.",
|
|
89
|
+
"InvalidStateError"
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
this.setAttribute("open", "");
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
if (typeof dproto.show !== "function") {
|
|
96
|
+
dproto.show = function () {
|
|
97
|
+
if (!this.hasAttribute("open")) this.setAttribute("open", "");
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
if (typeof dproto.close !== "function") {
|
|
101
|
+
dproto.close = function (returnValue) {
|
|
102
|
+
if (!this.hasAttribute("open")) return;
|
|
103
|
+
this.removeAttribute("open");
|
|
104
|
+
if (returnValue !== undefined) this.returnValue = String(returnValue);
|
|
105
|
+
this.dispatchEvent(new Event("close"));
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ── Bug #8 (added 2026-05-05) — sync remove + re-add lost across dispatch phases ──
|
|
111
|
+
// WHATWG DOM specifies that each phase of a dispatch snapshots `currentTarget`'s
|
|
112
|
+
// listener list AT THAT PHASE. Listeners removed and re-added during the capture
|
|
113
|
+
// phase correctly appear in the bubble-phase snapshot in Chrome/Cuprite. Lightpanda
|
|
114
|
+
// takes the snapshot once at dispatch start, so a remove+add during capture loses
|
|
115
|
+
// the listener for the in-flight bubble. This breaks Turbo's `FormSubmitObserver`
|
|
116
|
+
// pattern, where `submitCaptured` does `remove+add` on its own `submitBubbled` to
|
|
117
|
+
// ensure that handler runs LAST in bubble — under Lightpanda, `submitBubbled` is
|
|
118
|
+
// dropped entirely and Turbo never intercepts form submissions.
|
|
119
|
+
//
|
|
120
|
+
// Native form submission via `requestSubmit()` doesn't route through JS-exposed
|
|
121
|
+
// `dispatchEvent`, so we can't detect "in-flight dispatch" by patching that. The
|
|
122
|
+
// workaround instead targets the remove+add idiom directly: defer every
|
|
123
|
+
// `removeEventListener` to a microtask. When `addEventListener` runs in the same
|
|
124
|
+
// synchronous turn with the SAME (target, type, fn, capture), we cancel the
|
|
125
|
+
// pending remove — the listener was never actually unregistered, so the in-flight
|
|
126
|
+
// bubble snapshot still contains it. Genuine removes (no matching add follows)
|
|
127
|
+
// happen at end-of-tick, indistinguishable from the unpatched behavior modulo
|
|
128
|
+
// tick boundary.
|
|
129
|
+
//
|
|
130
|
+
// Scope: only capture-phase `submit` listeners, the exact tuple Turbo Drive
|
|
131
|
+
// uses. This avoids changing observable DOM semantics for arbitrary
|
|
132
|
+
// remove/dispatch sequences elsewhere on the page — synchronous remove +
|
|
133
|
+
// dispatch outside this narrow tuple still fires the native `removeEventListener`
|
|
134
|
+
// path immediately.
|
|
135
|
+
//
|
|
136
|
+
// Trade-offs (within the narrowed scope):
|
|
137
|
+
// • Code that removes a capture-phase submit listener and then reads
|
|
138
|
+
// listener state synchronously before the microtask flush will see it
|
|
139
|
+
// as still-attached. No known framework does this on `submit`.
|
|
140
|
+
// • If something removes capture-phase submit listener X then adds Y of
|
|
141
|
+
// the same type/capture before the flush, the add happens immediately
|
|
142
|
+
// but the deferred remove fires after, removing X *after* Y is
|
|
143
|
+
// registered. Y persists, X is gone. Same end state as without the
|
|
144
|
+
// polyfill, just reordered in time.
|
|
145
|
+
(function patchListenerLifecycle() {
|
|
146
|
+
if (!window.EventTarget || !EventTarget.prototype.addEventListener) return;
|
|
147
|
+
if (typeof Promise === "undefined") return; // need microtasks
|
|
148
|
+
var origAdd = EventTarget.prototype.addEventListener;
|
|
149
|
+
var origRemove = EventTarget.prototype.removeEventListener;
|
|
150
|
+
|
|
151
|
+
function captureFlag(opts) {
|
|
152
|
+
return opts === true || (opts && typeof opts === "object" && opts.capture === true);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function inScope(type, capture) {
|
|
156
|
+
return type === "submit" && capture === true;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
var pending = []; // [{ target, type, fn, capture, cancelled }]
|
|
160
|
+
var flushScheduled = false;
|
|
161
|
+
|
|
162
|
+
function scheduleFlush() {
|
|
163
|
+
if (flushScheduled) return;
|
|
164
|
+
flushScheduled = true;
|
|
165
|
+
Promise.resolve().then(function () {
|
|
166
|
+
flushScheduled = false;
|
|
167
|
+
var queue = pending;
|
|
168
|
+
pending = [];
|
|
169
|
+
for (var i = 0; i < queue.length; i++) {
|
|
170
|
+
var r = queue[i];
|
|
171
|
+
if (r.cancelled) continue;
|
|
172
|
+
try {
|
|
173
|
+
origRemove.call(r.target, r.type, r.fn, r.capture);
|
|
174
|
+
} catch (_) {}
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
EventTarget.prototype.removeEventListener = function (type, fn, opts) {
|
|
180
|
+
var capture = captureFlag(opts);
|
|
181
|
+
if (!fn || !inScope(type, capture)) return origRemove.call(this, type, fn, opts);
|
|
182
|
+
pending.push({
|
|
183
|
+
target: this,
|
|
184
|
+
type: type,
|
|
185
|
+
fn: fn,
|
|
186
|
+
capture: capture,
|
|
187
|
+
cancelled: false,
|
|
188
|
+
});
|
|
189
|
+
scheduleFlush();
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
EventTarget.prototype.addEventListener = function (type, fn, opts) {
|
|
193
|
+
if (!fn) return origAdd.call(this, type, fn, opts);
|
|
194
|
+
var capture = captureFlag(opts);
|
|
195
|
+
if (inScope(type, capture)) {
|
|
196
|
+
// Cancel a pending remove for the same tuple (LIFO so the most recent
|
|
197
|
+
// pending remove wins for the remove-then-add idiom). Always call
|
|
198
|
+
// native add too: addEventListener is idempotent per DOM spec, and
|
|
199
|
+
// skipping risks losing the listener on a later unmatched remove.
|
|
200
|
+
for (var i = pending.length - 1; i >= 0; i--) {
|
|
201
|
+
var r = pending[i];
|
|
202
|
+
if (r.cancelled) continue;
|
|
203
|
+
if (r.target === this && r.type === type && r.fn === fn && r.capture === capture) {
|
|
204
|
+
r.cancelled = true;
|
|
205
|
+
break;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return origAdd.call(this, type, fn, opts);
|
|
210
|
+
};
|
|
211
|
+
})();
|
|
212
|
+
})();
|
|
@@ -9,6 +9,8 @@ module Capybara
|
|
|
9
9
|
@browser = browser
|
|
10
10
|
@traffic = []
|
|
11
11
|
@enabled = false
|
|
12
|
+
@request_handler = nil
|
|
13
|
+
@response_handler = nil
|
|
12
14
|
end
|
|
13
15
|
|
|
14
16
|
def enable
|
|
@@ -22,7 +24,13 @@ module Capybara
|
|
|
22
24
|
def disable
|
|
23
25
|
return unless @enabled
|
|
24
26
|
|
|
27
|
+
# Tell the browser to stop emitting BEFORE unsubscribing locally:
|
|
28
|
+
# otherwise an in-flight Network.responseReceived can race past the
|
|
29
|
+
# already-removed handler and leave a `response: nil` entry in
|
|
30
|
+
# @traffic for the matching request — which then trips
|
|
31
|
+
# wait_for_idle's pending count on a future call.
|
|
25
32
|
browser.command("Network.disable")
|
|
33
|
+
unsubscribe
|
|
26
34
|
@enabled = false
|
|
27
35
|
end
|
|
28
36
|
|
|
@@ -34,6 +42,18 @@ module Capybara
|
|
|
34
42
|
@traffic.clear
|
|
35
43
|
end
|
|
36
44
|
|
|
45
|
+
# Wipe local state without sending Network.disable. Called by
|
|
46
|
+
# Browser#reset after Target.disposeBrowserContext, which destroys
|
|
47
|
+
# the subscriptions and the Network domain along with the context —
|
|
48
|
+
# leaving @enabled true would silently no-op the next #enable.
|
|
49
|
+
# Also unsubscribes locally so we don't rely on the caller having
|
|
50
|
+
# cleared the Subscriber first.
|
|
51
|
+
def reset
|
|
52
|
+
unsubscribe
|
|
53
|
+
@traffic.clear
|
|
54
|
+
@enabled = false
|
|
55
|
+
end
|
|
56
|
+
|
|
37
57
|
def headers=(headers)
|
|
38
58
|
@extra_headers = headers
|
|
39
59
|
browser.page_command("Network.setExtraHTTPHeaders", headers: headers)
|
|
@@ -65,7 +85,7 @@ module Capybara
|
|
|
65
85
|
private
|
|
66
86
|
|
|
67
87
|
def subscribe
|
|
68
|
-
|
|
88
|
+
@request_handler = lambda do |params|
|
|
69
89
|
@traffic << {
|
|
70
90
|
request_id: params["requestId"],
|
|
71
91
|
url: params.dig("request", "url"),
|
|
@@ -75,7 +95,7 @@ module Capybara
|
|
|
75
95
|
}
|
|
76
96
|
end
|
|
77
97
|
|
|
78
|
-
|
|
98
|
+
@response_handler = lambda do |params|
|
|
79
99
|
request = @traffic.find { |t| t[:request_id] == params["requestId"] }
|
|
80
100
|
|
|
81
101
|
next unless request
|
|
@@ -86,6 +106,16 @@ module Capybara
|
|
|
86
106
|
mime_type: params.dig("response", "mimeType"),
|
|
87
107
|
}
|
|
88
108
|
end
|
|
109
|
+
|
|
110
|
+
browser.on("Network.requestWillBeSent", &@request_handler)
|
|
111
|
+
browser.on("Network.responseReceived", &@response_handler)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def unsubscribe
|
|
115
|
+
browser.off("Network.requestWillBeSent", @request_handler) if @request_handler
|
|
116
|
+
browser.off("Network.responseReceived", @response_handler) if @response_handler
|
|
117
|
+
@request_handler = nil
|
|
118
|
+
@response_handler = nil
|
|
89
119
|
end
|
|
90
120
|
end
|
|
91
121
|
end
|
|
@@ -116,6 +116,14 @@ module Capybara
|
|
|
116
116
|
call("function() { this.dispatchEvent(new MouseEvent('mouseover', {bubbles: true, cancelable: true})) }")
|
|
117
117
|
end
|
|
118
118
|
|
|
119
|
+
# Dispatch an arbitrary DOM event by name. Mirrors Cuprite's Node#trigger
|
|
120
|
+
# — picks the right Event constructor for known mouse/focus/form names
|
|
121
|
+
# and falls back to a generic Event for everything else (so callers can
|
|
122
|
+
# fire custom events like `node.trigger('lp:custom')`).
|
|
123
|
+
def trigger(event)
|
|
124
|
+
call(TRIGGER_JS, event.to_s)
|
|
125
|
+
end
|
|
126
|
+
|
|
119
127
|
def set(value, **_options)
|
|
120
128
|
case tag_name
|
|
121
129
|
when "input"
|
|
@@ -229,6 +237,8 @@ module Capybara
|
|
|
229
237
|
|
|
230
238
|
def backend_node_id
|
|
231
239
|
@backend_node_id ||= driver.browser.backend_node_id(@remote_object_id)
|
|
240
|
+
rescue BrowserError
|
|
241
|
+
nil
|
|
232
242
|
end
|
|
233
243
|
|
|
234
244
|
private
|
|
@@ -355,7 +365,93 @@ module Capybara
|
|
|
355
365
|
end
|
|
356
366
|
end
|
|
357
367
|
|
|
358
|
-
|
|
368
|
+
# We dispatch a `MouseEvent` (not a generic `Event`) because Turbo's link
|
|
369
|
+
# and form interceptors guard with `event instanceof MouseEvent` before
|
|
370
|
+
# they consider intercepting — a synthetic `Event('click')` is silently
|
|
371
|
+
# ignored by Turbo Frame / Drive, and CLICK_JS would then fall through to
|
|
372
|
+
# the manual default action below, which does a full-page navigation
|
|
373
|
+
# instead of a frame swap.
|
|
374
|
+
#
|
|
375
|
+
# For submit buttons (`<button type=submit>`, `<input type=submit>`,
|
|
376
|
+
# `<input type=image>`): route through `form.requestSubmit(this)` so the
|
|
377
|
+
# browser dispatches a real `SubmitEvent` with submitter set, honors the
|
|
378
|
+
# submitter's `formaction` / `formmethod` / `formenctype`, and includes
|
|
379
|
+
# the submitter's name/value in the form data. A manual
|
|
380
|
+
# `dispatchEvent(new Event('submit'))` + `form.submit()` would lose all of
|
|
381
|
+
# that and break Turbo Drive / Hotwire form handling. We can't rely on
|
|
382
|
+
# the synthetic click's default action because synthetic events don't
|
|
383
|
+
# trigger the implicit form-submission default action per DOM spec.
|
|
384
|
+
CLICK_JS = <<~JS
|
|
385
|
+
function() {
|
|
386
|
+
var EventCtor = (typeof MouseEvent !== 'undefined') ? MouseEvent : Event;
|
|
387
|
+
var clickEvt = new EventCtor('click', { bubbles: true, cancelable: true });
|
|
388
|
+
var notCancelled = this.dispatchEvent(clickEvt);
|
|
389
|
+
if (!notCancelled || clickEvt.defaultPrevented) return;
|
|
390
|
+
var tag = this.tagName;
|
|
391
|
+
var type = (this.type || '').toLowerCase();
|
|
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') {
|
|
409
|
+
// Same-document fragment-only navigation: just update hash (or do
|
|
410
|
+
// nothing if identical). Mirrors Chrome — assigning location.href
|
|
411
|
+
// to a same-document URL on Lightpanda triggers a real navigation
|
|
412
|
+
// tick that cancels pending setTimeout callbacks and clears form
|
|
413
|
+
// values, which breaks any test driving DOM updates from a click
|
|
414
|
+
// handler on `<a href="#...">`.
|
|
415
|
+
var dest = new URL(this.href, document.baseURI);
|
|
416
|
+
var here = new URL(window.location.href);
|
|
417
|
+
if (dest.origin === here.origin && dest.pathname === here.pathname &&
|
|
418
|
+
dest.search === here.search) {
|
|
419
|
+
if (dest.hash !== here.hash) {
|
|
420
|
+
window.location.hash = dest.hash;
|
|
421
|
+
}
|
|
422
|
+
} else {
|
|
423
|
+
window.location.href = this.href;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
JS
|
|
428
|
+
|
|
429
|
+
# Mirrors Cuprite's trigger map. Picks the right Event constructor based
|
|
430
|
+
# on the event name so listeners that key on `event instanceof MouseEvent`
|
|
431
|
+
# / `instanceof SubmitEvent` see what they expect; everything else goes
|
|
432
|
+
# through a generic Event so custom names ("turbo:load", "lp:custom")
|
|
433
|
+
# still dispatch. Each constructor is feature-detected (`typeof X !==
|
|
434
|
+
# 'undefined'`) before use so a missing IDL on Lightpanda falls back
|
|
435
|
+
# to plain Event rather than throwing.
|
|
436
|
+
TRIGGER_JS = <<~JS
|
|
437
|
+
function(name) {
|
|
438
|
+
var MOUSE = ['click','dblclick','mousedown','mouseenter','mouseleave',
|
|
439
|
+
'mousemove','mouseover','mouseout','mouseup','contextmenu'];
|
|
440
|
+
var FOCUS = ['blur','focus','focusin','focusout'];
|
|
441
|
+
var init = { bubbles: true, cancelable: true };
|
|
442
|
+
var event;
|
|
443
|
+
if (MOUSE.indexOf(name) !== -1 && typeof MouseEvent !== 'undefined') {
|
|
444
|
+
event = new MouseEvent(name, init);
|
|
445
|
+
} else if (FOCUS.indexOf(name) !== -1 && typeof FocusEvent !== 'undefined') {
|
|
446
|
+
event = new FocusEvent(name, init);
|
|
447
|
+
} else if (name === 'submit' && typeof SubmitEvent !== 'undefined') {
|
|
448
|
+
event = new SubmitEvent(name, init);
|
|
449
|
+
} else {
|
|
450
|
+
event = new Event(name, init);
|
|
451
|
+
}
|
|
452
|
+
this.dispatchEvent(event);
|
|
453
|
+
}
|
|
454
|
+
JS
|
|
359
455
|
|
|
360
456
|
VISIBLE_JS = "function() { return _lightpanda.isVisible(this); }"
|
|
361
457
|
|
|
@@ -382,7 +478,14 @@ module Capybara
|
|
|
382
478
|
autofocus: 'autofocus', required: 'required' };
|
|
383
479
|
var prop = BOOL_PROP[name.toLowerCase()];
|
|
384
480
|
if (prop && this[prop] !== undefined) return this[prop];
|
|
385
|
-
return this.getAttribute(name);
|
|
481
|
+
if (this.hasAttribute(name)) return this.getAttribute(name);
|
|
482
|
+
// Property-only fallback: things like `validationMessage` have no
|
|
483
|
+
// backing HTML attribute. Return primitives only — DOM-node properties
|
|
484
|
+
// (form, options, etc.) shouldn't leak through.
|
|
485
|
+
var live = this[name];
|
|
486
|
+
if (live === null || live === undefined) return null;
|
|
487
|
+
var t = typeof live;
|
|
488
|
+
return (t === 'string' || t === 'number' || t === 'boolean') ? live : null;
|
|
386
489
|
}
|
|
387
490
|
JS
|
|
388
491
|
|
|
@@ -464,8 +567,6 @@ module Capybara
|
|
|
464
567
|
|
|
465
568
|
SET_CHECKBOX_JS = <<~JS
|
|
466
569
|
function(value) {
|
|
467
|
-
// Use `click()` so user-installed click/change handlers fire and
|
|
468
|
-
// observe a real toggle. No-op if already in the requested state.
|
|
469
570
|
if (this.checked !== value) this.click();
|
|
470
571
|
}
|
|
471
572
|
JS
|
|
@@ -8,7 +8,7 @@ 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
|
|
11
|
+
# Floor for the cookie/navigation/redirect/modal/keyboard/css/forms/dispatch
|
|
12
12
|
# fixes the gem now relies on: PR #2255 (Network.clearBrowserCookies
|
|
13
13
|
# empty params + Network.getAllCookies), PR #2257
|
|
14
14
|
# (window.location.pathname/.search assignment triggers navigation),
|
|
@@ -22,10 +22,13 @@ module Capybara
|
|
|
22
22
|
# promptText is null), PR #2324 (<label> click runs activation behavior
|
|
23
23
|
# on labeled control), PR #2286 (HTML constraint validation API:
|
|
24
24
|
# el.validity, validationMessage, checkValidity, reportValidity),
|
|
25
|
-
# PR #2342 (<summary> click toggles parent <details>.open)
|
|
26
|
-
#
|
|
27
|
-
#
|
|
28
|
-
|
|
25
|
+
# PR #2342 (<summary> click toggles parent <details>.open),
|
|
26
|
+
# PR #2352 (HTMLInputElement.pattern + patternMismatch via V8 RegExp),
|
|
27
|
+
# PR #2368 (events: report listener exceptions instead of halting
|
|
28
|
+
# dispatch — lets us drop the polyfills.js patchDispatch IIFE).
|
|
29
|
+
# Build 6065 = main HEAD 61364437 (2026-05-06, PR #2368 merge);
|
|
30
|
+
# ships in nightly published 2026-05-06 ~03:30 UTC for all four platforms.
|
|
31
|
+
MINIMUM_NIGHTLY_BUILD = Gem::Version.new("6065")
|
|
29
32
|
|
|
30
33
|
attr_reader :pid, :ws_url, :version, :nightly_build
|
|
31
34
|
|
|
@@ -5,6 +5,11 @@ module Capybara
|
|
|
5
5
|
module XPathPolyfill
|
|
6
6
|
JS_PATH = File.expand_path("javascripts/index.js", __dir__).freeze
|
|
7
7
|
JS = File.read(JS_PATH).freeze
|
|
8
|
+
|
|
9
|
+
# Polyfills pour les APIs DOM manquantes du binaire Lightpanda.
|
|
10
|
+
# Voir UPSTREAM_BUGS.md à la racine du gem.
|
|
11
|
+
POLYFILLS_PATH = File.expand_path("javascripts/polyfills.js", __dir__).freeze
|
|
12
|
+
POLYFILLS_JS = File.read(POLYFILLS_PATH).freeze
|
|
8
13
|
end
|
|
9
14
|
end
|
|
10
15
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: capybara-lightpanda
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.2.
|
|
4
|
+
version: 0.2.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Navid Emad
|
|
@@ -81,6 +81,7 @@ files:
|
|
|
81
81
|
- lib/capybara/lightpanda/errors.rb
|
|
82
82
|
- lib/capybara/lightpanda/frame.rb
|
|
83
83
|
- lib/capybara/lightpanda/javascripts/index.js
|
|
84
|
+
- lib/capybara/lightpanda/javascripts/polyfills.js
|
|
84
85
|
- lib/capybara/lightpanda/keyboard.rb
|
|
85
86
|
- lib/capybara/lightpanda/logger.rb
|
|
86
87
|
- lib/capybara/lightpanda/network.rb
|