capybara-lightpanda 0.2.0 → 0.2.1
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 +11 -0
- data/lib/capybara/lightpanda/binary.rb +1 -1
- data/lib/capybara/lightpanda/browser.rb +134 -16
- 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 +81 -0
- data/lib/capybara/lightpanda/network.rb +32 -2
- data/lib/capybara/lightpanda/node.rb +87 -4
- data/lib/capybara/lightpanda/process.rb +5 -4
- 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: 42246ea44b80c592e6779cf2aa95890c7635fb057b0061064b63bf15004dcde6
|
|
4
|
+
data.tar.gz: 63131038538438b32d39d8e46a36005df0306e8daadaa38ff88540874c6c46aa
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1728491bdd3d3dac24559cc663ee250af16f90d7944d2c27be0b879ddbfc23a2a00181f39970d7622fd1b640449d97c7d373c4f6a414aae9073dc0f3cf92d1f8
|
|
7
|
+
data.tar.gz: 47e0408c55b5150347656d8136b6dc993b39d38a051a39efec480e2d913cf799f45c18242fd031bbb6962f79b7a39d63cc2eec8d2c14e2f42f34ed3b029af867
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.2.1] - 2026-05-05
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- 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>`.
|
|
8
|
+
- 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.
|
|
9
|
+
|
|
10
|
+
### Internal
|
|
11
|
+
|
|
12
|
+
- Local test suite migrated from RSpec to Minitest::Spec. The Capybara shared-spec battery (`spec/features/session_spec.rb`) still runs on RSpec.
|
|
13
|
+
|
|
3
14
|
## [0.2.0] - 2026-05-04
|
|
4
15
|
|
|
5
16
|
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
|
|
@@ -204,7 +250,10 @@ module Capybara
|
|
|
204
250
|
def evaluate(expression, *args)
|
|
205
251
|
if args.empty?
|
|
206
252
|
response = page_command("Runtime.evaluate", expression: expression, returnByValue: false, awaitPromise: true)
|
|
207
|
-
|
|
253
|
+
if response["exceptionDetails"]
|
|
254
|
+
debug_js_failure("evaluate", expression, response)
|
|
255
|
+
raise JavaScriptError, response
|
|
256
|
+
end
|
|
208
257
|
|
|
209
258
|
return unwrap_call_result(response["result"])
|
|
210
259
|
end
|
|
@@ -225,6 +274,15 @@ module Capybara
|
|
|
225
274
|
nil
|
|
226
275
|
end
|
|
227
276
|
|
|
277
|
+
# When LIGHTPANDA_DEBUG=1 is set, log the JS expression and full CDP
|
|
278
|
+
# response for every JsException to STDERR. Invaluable for isolating
|
|
279
|
+
# which exact JS triggers an upstream Lightpanda bug.
|
|
280
|
+
def debug_js_failure(site, expression, response)
|
|
281
|
+
return unless ENV["LIGHTPANDA_DEBUG"]
|
|
282
|
+
|
|
283
|
+
warn "[lightpanda:#{site}] expression:\n#{expression}\n[lightpanda:#{site}] response:\n#{response.inspect}\n"
|
|
284
|
+
end
|
|
285
|
+
|
|
228
286
|
# Evaluate async JS with a callback. The user's script receives
|
|
229
287
|
# the callback as its last argument (`arguments[arguments.length - 1]`),
|
|
230
288
|
# matching Capybara's evaluate_async_script contract.
|
|
@@ -249,7 +307,10 @@ module Capybara
|
|
|
249
307
|
# Evaluate JS and return a RemoteObject reference (for DOM nodes, arrays).
|
|
250
308
|
def evaluate_with_ref(expression)
|
|
251
309
|
response = page_command("Runtime.evaluate", expression: expression, returnByValue: false, awaitPromise: true)
|
|
252
|
-
|
|
310
|
+
if response["exceptionDetails"]
|
|
311
|
+
debug_js_failure("evaluate_with_ref", expression, response)
|
|
312
|
+
raise JavaScriptError, response
|
|
313
|
+
end
|
|
253
314
|
|
|
254
315
|
result = response["result"]
|
|
255
316
|
return nil if result["type"] == "undefined"
|
|
@@ -269,7 +330,10 @@ module Capybara
|
|
|
269
330
|
params[:arguments] = args.map { |a| serialize_argument(a) } unless args.empty?
|
|
270
331
|
|
|
271
332
|
response = page_command("Runtime.callFunctionOn", **params)
|
|
272
|
-
|
|
333
|
+
if response["exceptionDetails"]
|
|
334
|
+
debug_js_failure("call_function_on", function_declaration, response)
|
|
335
|
+
raise JavaScriptError, response
|
|
336
|
+
end
|
|
273
337
|
|
|
274
338
|
result = response["result"]
|
|
275
339
|
return nil if result["type"] == "undefined"
|
|
@@ -304,6 +368,8 @@ module Capybara
|
|
|
304
368
|
def find_within(remote_object_id, method, selector)
|
|
305
369
|
result = call_function_on(remote_object_id, FIND_WITHIN_JS, method, selector, return_by_value: false)
|
|
306
370
|
extract_node_object_ids(result)
|
|
371
|
+
rescue JavaScriptError => e
|
|
372
|
+
raise_invalid_selector(e, method, selector)
|
|
307
373
|
end
|
|
308
374
|
|
|
309
375
|
# objectId of document.activeElement, or nil if none/document detached.
|
|
@@ -528,20 +594,32 @@ module Capybara
|
|
|
528
594
|
raise Capybara::ModalNotFound, "Unable to find modal dialog#{" with #{text}" if text}"
|
|
529
595
|
end
|
|
530
596
|
|
|
597
|
+
# Sentinel string thrown from FIND_*_JS when querySelectorAll rejects a
|
|
598
|
+
# malformed selector, so the Ruby side can convert JavaScriptError into
|
|
599
|
+
# Capybara::Lightpanda::InvalidSelector. Cuprite uses a JS subclass for
|
|
600
|
+
# the same purpose; a plain prefixed string keeps our inline JS simple.
|
|
601
|
+
INVALID_SELECTOR_MARKER = "LIGHTPANDA_INVALID_SELECTOR:"
|
|
602
|
+
|
|
531
603
|
# JS function for finding elements within a node.
|
|
532
|
-
# Works in any execution context (top frame or iframe).
|
|
533
|
-
|
|
604
|
+
# Works in any execution context (top frame or iframe). Any throw from
|
|
605
|
+
# querySelectorAll means the selector is malformed (the spec only allows
|
|
606
|
+
# SYNTAX_ERR DOMException; Lightpanda's V8 currently throws a generic
|
|
607
|
+
# Error with messages like "InvalidClassSelector"). Re-throw with the
|
|
608
|
+
# marker prefix so Ruby converts to InvalidSelector regardless.
|
|
609
|
+
FIND_WITHIN_JS = <<~JS.freeze
|
|
534
610
|
function(method, selector) {
|
|
535
611
|
if (method === 'xpath') {
|
|
536
612
|
if (typeof _lightpanda !== 'undefined') return _lightpanda.xpathFind(selector, this);
|
|
537
613
|
return [];
|
|
538
614
|
}
|
|
539
|
-
try { return Array.from(this.querySelectorAll(selector)); }
|
|
615
|
+
try { return Array.from(this.querySelectorAll(selector)); }
|
|
616
|
+
catch(e) { throw new Error('#{INVALID_SELECTOR_MARKER}' + selector); }
|
|
540
617
|
}
|
|
541
618
|
JS
|
|
619
|
+
private_constant :FIND_WITHIN_JS
|
|
542
620
|
|
|
543
621
|
# JS function for finding elements in an iframe's contentDocument.
|
|
544
|
-
FIND_IN_FRAME_JS = <<~JS
|
|
622
|
+
FIND_IN_FRAME_JS = <<~JS.freeze
|
|
545
623
|
function(method, selector) {
|
|
546
624
|
var doc;
|
|
547
625
|
try { doc = this.contentDocument || (this.contentWindow && this.contentWindow.document); } catch(e) {}
|
|
@@ -550,9 +628,11 @@ module Capybara
|
|
|
550
628
|
if (typeof _lightpanda !== 'undefined') return _lightpanda.xpathFind(selector, doc);
|
|
551
629
|
return [];
|
|
552
630
|
}
|
|
553
|
-
try { return Array.from(doc.querySelectorAll(selector)); }
|
|
631
|
+
try { return Array.from(doc.querySelectorAll(selector)); }
|
|
632
|
+
catch(e) { throw new Error('#{INVALID_SELECTOR_MARKER}' + selector); }
|
|
554
633
|
}
|
|
555
634
|
JS
|
|
635
|
+
private_constant :FIND_IN_FRAME_JS
|
|
556
636
|
|
|
557
637
|
def find_in_document(method, selector)
|
|
558
638
|
with_default_context_wait do
|
|
@@ -563,12 +643,18 @@ module Capybara
|
|
|
563
643
|
js = if method == "xpath"
|
|
564
644
|
"(typeof _lightpanda !== 'undefined') ? _lightpanda.xpathFind(#{selector_literal}, document) : []"
|
|
565
645
|
else
|
|
566
|
-
|
|
567
|
-
|
|
646
|
+
<<~CSS_FIND
|
|
647
|
+
(function() {
|
|
648
|
+
try { return Array.from(document.querySelectorAll(#{selector_literal})); }
|
|
649
|
+
catch(e) { throw new Error('#{INVALID_SELECTOR_MARKER}' + #{selector_literal}); }
|
|
650
|
+
})()
|
|
651
|
+
CSS_FIND
|
|
568
652
|
end
|
|
569
653
|
result = evaluate_with_ref(js)
|
|
570
654
|
extract_node_object_ids(result)
|
|
571
655
|
end
|
|
656
|
+
rescue JavaScriptError => e
|
|
657
|
+
raise_invalid_selector(e, method, selector)
|
|
572
658
|
end
|
|
573
659
|
|
|
574
660
|
def find_in_frame(method, selector)
|
|
@@ -576,6 +662,16 @@ module Capybara
|
|
|
576
662
|
result = call_function_on(frame_node.remote_object_id, FIND_IN_FRAME_JS, method, selector,
|
|
577
663
|
return_by_value: false)
|
|
578
664
|
extract_node_object_ids(result)
|
|
665
|
+
rescue JavaScriptError => e
|
|
666
|
+
raise_invalid_selector(e, method, selector)
|
|
667
|
+
end
|
|
668
|
+
|
|
669
|
+
def raise_invalid_selector(js_error, method, selector)
|
|
670
|
+
if js_error.message.include?(INVALID_SELECTOR_MARKER)
|
|
671
|
+
raise InvalidSelector.new("Invalid #{method} selector: #{selector.inspect}", method, selector)
|
|
672
|
+
end
|
|
673
|
+
|
|
674
|
+
raise js_error
|
|
579
675
|
end
|
|
580
676
|
|
|
581
677
|
# Extract individual node objectIds from a remote array reference.
|
|
@@ -592,12 +688,13 @@ module Capybara
|
|
|
592
688
|
|
|
593
689
|
release_object(result["objectId"])
|
|
594
690
|
ids
|
|
595
|
-
rescue
|
|
691
|
+
rescue Error
|
|
596
692
|
[]
|
|
597
693
|
end
|
|
598
694
|
|
|
599
695
|
def register_auto_scripts
|
|
600
696
|
page_command("Page.addScriptToEvaluateOnNewDocument", source: XPathPolyfill::JS)
|
|
697
|
+
page_command("Page.addScriptToEvaluateOnNewDocument", source: XPathPolyfill::POLYFILLS_JS)
|
|
601
698
|
end
|
|
602
699
|
|
|
603
700
|
def subscribe_to_console_logs
|
|
@@ -716,7 +813,10 @@ module Capybara
|
|
|
716
813
|
end
|
|
717
814
|
|
|
718
815
|
def handle_evaluate_response(response)
|
|
719
|
-
|
|
816
|
+
if response["exceptionDetails"]
|
|
817
|
+
debug_js_failure("handle_evaluate_response", "(unknown — already-issued call)", response)
|
|
818
|
+
raise JavaScriptError, response
|
|
819
|
+
end
|
|
720
820
|
|
|
721
821
|
result = response["result"]
|
|
722
822
|
return nil if result["type"] == "undefined"
|
|
@@ -738,7 +838,10 @@ module Capybara
|
|
|
738
838
|
arguments: args.map { |a| serialize_argument(a) },
|
|
739
839
|
}
|
|
740
840
|
response = page_command("Runtime.callFunctionOn", **params)
|
|
741
|
-
|
|
841
|
+
if response["exceptionDetails"]
|
|
842
|
+
debug_js_failure("call_with_args", function_declaration, response)
|
|
843
|
+
raise JavaScriptError, response
|
|
844
|
+
end
|
|
742
845
|
|
|
743
846
|
return_by_value ? handle_evaluate_response(response) : unwrap_call_result(response["result"])
|
|
744
847
|
end
|
|
@@ -839,6 +942,21 @@ module Capybara
|
|
|
839
942
|
nil
|
|
840
943
|
end
|
|
841
944
|
|
|
945
|
+
def dispose_browser_context
|
|
946
|
+
return unless @browser_context_id
|
|
947
|
+
|
|
948
|
+
begin
|
|
949
|
+
@client.command("Target.disposeBrowserContext", { browserContextId: @browser_context_id })
|
|
950
|
+
rescue StandardError
|
|
951
|
+
# Context may already be disposed or the WS may be down; we
|
|
952
|
+
# recreate either way.
|
|
953
|
+
ensure
|
|
954
|
+
@browser_context_id = nil
|
|
955
|
+
@target_id = nil
|
|
956
|
+
@session_id = nil
|
|
957
|
+
end
|
|
958
|
+
end
|
|
959
|
+
|
|
842
960
|
def restart_process_if_dead
|
|
843
961
|
return unless @process && !@process.alive?
|
|
844
962
|
|
|
@@ -927,7 +1045,7 @@ module Capybara
|
|
|
927
1045
|
|
|
928
1046
|
url_changed = starting_url.nil? || state["u"] != starting_url
|
|
929
1047
|
url_changed && %w[complete interactive].include?(state["r"])
|
|
930
|
-
rescue
|
|
1048
|
+
rescue Error
|
|
931
1049
|
false
|
|
932
1050
|
end
|
|
933
1051
|
|
|
@@ -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,81 @@
|
|
|
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 #4 — HTMLDialogElement.{showModal, show, close} non implémentés ──
|
|
9
|
+
// https://html.spec.whatwg.org/multipage/interactive-elements.html#the-dialog-element
|
|
10
|
+
if (typeof HTMLDialogElement !== "undefined") {
|
|
11
|
+
var dproto = HTMLDialogElement.prototype;
|
|
12
|
+
if (typeof dproto.showModal !== "function") {
|
|
13
|
+
dproto.showModal = function () {
|
|
14
|
+
if (this.hasAttribute("open")) {
|
|
15
|
+
throw new (window.DOMException || Error)(
|
|
16
|
+
"The element already has an 'open' attribute, and therefore cannot be opened modally.",
|
|
17
|
+
"InvalidStateError"
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
this.setAttribute("open", "");
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
if (typeof dproto.show !== "function") {
|
|
24
|
+
dproto.show = function () {
|
|
25
|
+
if (!this.hasAttribute("open")) this.setAttribute("open", "");
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
if (typeof dproto.close !== "function") {
|
|
29
|
+
dproto.close = function (returnValue) {
|
|
30
|
+
if (!this.hasAttribute("open")) return;
|
|
31
|
+
this.removeAttribute("open");
|
|
32
|
+
if (returnValue !== undefined) this.returnValue = String(returnValue);
|
|
33
|
+
this.dispatchEvent(new Event("close"));
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ── Bug #3 (narrower than originally diagnosed; verified 2026-05-04 against build 6005) ──
|
|
39
|
+
// Native dispatch DOES propagate to ancestors with event.target preserved — but if
|
|
40
|
+
// ANY listener throws, Lightpanda halts the whole dispatch path (incl. the bubble
|
|
41
|
+
// phase) instead of reporting the exception and continuing per DOM §2.9 step 4.
|
|
42
|
+
// Stimulus / Turbo Drive listeners that throw silently swallow document-level
|
|
43
|
+
// delegation. Workaround: catch the propagated JsException and re-walk parents
|
|
44
|
+
// manually, spoofing event.target via Object.defineProperty for delegated handlers.
|
|
45
|
+
(function patchDispatch() {
|
|
46
|
+
if (!window.EventTarget || !EventTarget.prototype.dispatchEvent) return;
|
|
47
|
+
var orig = EventTarget.prototype.dispatchEvent;
|
|
48
|
+
|
|
49
|
+
EventTarget.prototype.dispatchEvent = function (event) {
|
|
50
|
+
try {
|
|
51
|
+
return orig.call(this, event);
|
|
52
|
+
} catch (err) {
|
|
53
|
+
if (!event || !event.bubbles || !this.parentNode) throw err;
|
|
54
|
+
|
|
55
|
+
var originalTarget = this;
|
|
56
|
+
try {
|
|
57
|
+
Object.defineProperty(event, "target", {
|
|
58
|
+
value: originalTarget,
|
|
59
|
+
configurable: true,
|
|
60
|
+
});
|
|
61
|
+
} catch (_) { /* target not redefinable — continue anyway */ }
|
|
62
|
+
|
|
63
|
+
var node = this.parentNode;
|
|
64
|
+
while (node) {
|
|
65
|
+
try {
|
|
66
|
+
Object.defineProperty(event, "currentTarget", {
|
|
67
|
+
value: node,
|
|
68
|
+
configurable: true,
|
|
69
|
+
});
|
|
70
|
+
} catch (_) {}
|
|
71
|
+
try {
|
|
72
|
+
orig.call(node, event);
|
|
73
|
+
} catch (_) { /* ignore intermediate crashes; keep propagating */ }
|
|
74
|
+
if (event.cancelBubble) break;
|
|
75
|
+
node = node.parentNode || node.host || null;
|
|
76
|
+
}
|
|
77
|
+
return !event.defaultPrevented;
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
})();
|
|
81
|
+
})();
|
|
@@ -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"
|
|
@@ -355,7 +363,77 @@ module Capybara
|
|
|
355
363
|
end
|
|
356
364
|
end
|
|
357
365
|
|
|
358
|
-
|
|
366
|
+
# Native `this.click()` reaches all ancestors on the happy path, but if any
|
|
367
|
+
# listener throws (Stimulus / Turbo edge cases) Lightpanda halts dispatch
|
|
368
|
+
# instead of reporting the exception per DOM §2.9 step 4 (see UPSTREAM_BUGS.md
|
|
369
|
+
# Bug #3). Dispatching via JS routes through `polyfills.js`'s patchDispatch
|
|
370
|
+
# IIFE, which catches the throw and re-walks parents manually so document-
|
|
371
|
+
# level delegated handlers still see the event.
|
|
372
|
+
#
|
|
373
|
+
# We dispatch a `MouseEvent` (not a generic `Event`) because Turbo's link
|
|
374
|
+
# and form interceptors guard with `event instanceof MouseEvent` before
|
|
375
|
+
# they consider intercepting — a synthetic `Event('click')` is silently
|
|
376
|
+
# ignored by Turbo Frame / Drive, and CLICK_JS would then fall through to
|
|
377
|
+
# the manual default action below, which does a full-page navigation
|
|
378
|
+
# instead of a frame swap.
|
|
379
|
+
#
|
|
380
|
+
# For submit buttons (`<button type=submit>`, `<input type=submit>`,
|
|
381
|
+
# `<input type=image>`): route through `form.requestSubmit(this)` so the
|
|
382
|
+
# browser dispatches a real `SubmitEvent` with submitter set, honors the
|
|
383
|
+
# submitter's `formaction` / `formmethod` / `formenctype`, and includes
|
|
384
|
+
# the submitter's name/value in the form data. A manual
|
|
385
|
+
# `dispatchEvent(new Event('submit'))` + `form.submit()` would lose all of
|
|
386
|
+
# that and break Turbo Drive / Hotwire form handling. We can't rely on
|
|
387
|
+
# the synthetic click's default action because synthetic events don't
|
|
388
|
+
# trigger the implicit form-submission default action per DOM spec.
|
|
389
|
+
CLICK_JS = <<~JS
|
|
390
|
+
function() {
|
|
391
|
+
var EventCtor = (typeof MouseEvent !== 'undefined') ? MouseEvent : Event;
|
|
392
|
+
var clickEvt = new EventCtor('click', { bubbles: true, cancelable: true });
|
|
393
|
+
var notCancelled = true;
|
|
394
|
+
try {
|
|
395
|
+
notCancelled = this.dispatchEvent(clickEvt);
|
|
396
|
+
} catch (e) { /* patchDispatch in polyfills.js rescues bubble phase */ }
|
|
397
|
+
if (!notCancelled || clickEvt.defaultPrevented) return;
|
|
398
|
+
var tag = this.tagName;
|
|
399
|
+
var type = (this.type || '').toLowerCase();
|
|
400
|
+
var isSubmitButton =
|
|
401
|
+
(tag === 'BUTTON' && (type === 'submit' || type === '')) ||
|
|
402
|
+
(tag === 'INPUT' && (type === 'submit' || type === 'image'));
|
|
403
|
+
if (isSubmitButton && this.form) {
|
|
404
|
+
this.form.requestSubmit(this);
|
|
405
|
+
} else if (tag === 'A' && this.href && this.target !== '_blank') {
|
|
406
|
+
window.location.href = this.href;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
JS
|
|
410
|
+
|
|
411
|
+
# Mirrors Cuprite's trigger map. Picks the right Event constructor based
|
|
412
|
+
# on the event name so listeners that key on `event instanceof MouseEvent`
|
|
413
|
+
# / `instanceof SubmitEvent` see what they expect; everything else goes
|
|
414
|
+
# through a generic Event so custom names ("turbo:load", "lp:custom")
|
|
415
|
+
# still dispatch. Each constructor is feature-detected (`typeof X !==
|
|
416
|
+
# 'undefined'`) before use so a missing IDL on Lightpanda falls back
|
|
417
|
+
# to plain Event rather than throwing.
|
|
418
|
+
TRIGGER_JS = <<~JS
|
|
419
|
+
function(name) {
|
|
420
|
+
var MOUSE = ['click','dblclick','mousedown','mouseenter','mouseleave',
|
|
421
|
+
'mousemove','mouseover','mouseout','mouseup','contextmenu'];
|
|
422
|
+
var FOCUS = ['blur','focus','focusin','focusout'];
|
|
423
|
+
var init = { bubbles: true, cancelable: true };
|
|
424
|
+
var event;
|
|
425
|
+
if (MOUSE.indexOf(name) !== -1 && typeof MouseEvent !== 'undefined') {
|
|
426
|
+
event = new MouseEvent(name, init);
|
|
427
|
+
} else if (FOCUS.indexOf(name) !== -1 && typeof FocusEvent !== 'undefined') {
|
|
428
|
+
event = new FocusEvent(name, init);
|
|
429
|
+
} else if (name === 'submit' && typeof SubmitEvent !== 'undefined') {
|
|
430
|
+
event = new SubmitEvent(name, init);
|
|
431
|
+
} else {
|
|
432
|
+
event = new Event(name, init);
|
|
433
|
+
}
|
|
434
|
+
this.dispatchEvent(event);
|
|
435
|
+
}
|
|
436
|
+
JS
|
|
359
437
|
|
|
360
438
|
VISIBLE_JS = "function() { return _lightpanda.isVisible(this); }"
|
|
361
439
|
|
|
@@ -382,7 +460,14 @@ module Capybara
|
|
|
382
460
|
autofocus: 'autofocus', required: 'required' };
|
|
383
461
|
var prop = BOOL_PROP[name.toLowerCase()];
|
|
384
462
|
if (prop && this[prop] !== undefined) return this[prop];
|
|
385
|
-
return this.getAttribute(name);
|
|
463
|
+
if (this.hasAttribute(name)) return this.getAttribute(name);
|
|
464
|
+
// Property-only fallback: things like `validationMessage` have no
|
|
465
|
+
// backing HTML attribute. Return primitives only — DOM-node properties
|
|
466
|
+
// (form, options, etc.) shouldn't leak through.
|
|
467
|
+
var live = this[name];
|
|
468
|
+
if (live === null || live === undefined) return null;
|
|
469
|
+
var t = typeof live;
|
|
470
|
+
return (t === 'string' || t === 'number' || t === 'boolean') ? live : null;
|
|
386
471
|
}
|
|
387
472
|
JS
|
|
388
473
|
|
|
@@ -464,8 +549,6 @@ module Capybara
|
|
|
464
549
|
|
|
465
550
|
SET_CHECKBOX_JS = <<~JS
|
|
466
551
|
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
552
|
if (this.checked !== value) this.click();
|
|
470
553
|
}
|
|
471
554
|
JS
|
|
@@ -22,10 +22,11 @@ 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
|
-
# 2026-05-04
|
|
28
|
-
|
|
25
|
+
# PR #2342 (<summary> click toggles parent <details>.open),
|
|
26
|
+
# PR #2352 (HTMLInputElement.pattern + patternMismatch via V8 RegExp).
|
|
27
|
+
# Build 6051 = main HEAD d360fcc0 (2026-05-04); ships in nightly
|
|
28
|
+
# published 2026-05-05 ~03:30 UTC for all four platforms.
|
|
29
|
+
MINIMUM_NIGHTLY_BUILD = Gem::Version.new("6051")
|
|
29
30
|
|
|
30
31
|
attr_reader :pid, :ws_url, :version, :nightly_build
|
|
31
32
|
|
|
@@ -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.1
|
|
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
|