capybara-lightpanda 0.8.0 → 0.10.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 +62 -0
- data/README.md +14 -1
- data/lib/capybara/lightpanda/auto_scripts.rb +30 -2
- data/lib/capybara/lightpanda/binary.rb +72 -54
- data/lib/capybara/lightpanda/browser/console.rb +190 -0
- data/lib/capybara/lightpanda/browser/finder.rb +196 -0
- data/lib/capybara/lightpanda/browser/modals.rb +150 -0
- data/lib/capybara/lightpanda/browser/navigation.rb +186 -0
- data/lib/capybara/lightpanda/browser/runtime.rb +258 -0
- data/lib/capybara/lightpanda/browser/selenium_compat.rb +124 -0
- data/lib/capybara/lightpanda/browser.rb +158 -813
- data/lib/capybara/lightpanda/client/subscriber.rb +2 -0
- data/lib/capybara/lightpanda/client/web_socket.rb +19 -21
- data/lib/capybara/lightpanda/client.rb +5 -4
- data/lib/capybara/lightpanda/downloads.rb +176 -0
- data/lib/capybara/lightpanda/driver.rb +124 -32
- data/lib/capybara/lightpanda/errors.rb +24 -10
- data/lib/capybara/lightpanda/javascripts/attach.js +16 -0
- data/lib/capybara/lightpanda/javascripts/banner.js +15 -0
- data/lib/capybara/lightpanda/javascripts/errors.js +74 -0
- data/lib/capybara/lightpanda/javascripts/predicates.js +116 -0
- data/lib/capybara/lightpanda/javascripts/turbo.js +67 -0
- data/lib/capybara/lightpanda/keyboard.rb +23 -19
- data/lib/capybara/lightpanda/network.rb +103 -18
- data/lib/capybara/lightpanda/node.rb +200 -95
- data/lib/capybara/lightpanda/options.rb +44 -3
- data/lib/capybara/lightpanda/process.rb +154 -29
- data/lib/capybara/lightpanda/version.rb +1 -1
- data/lib/capybara-lightpanda.rb +1 -0
- metadata +14 -3
- data/lib/capybara/lightpanda/javascripts/index.js +0 -226
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Capybara
|
|
4
|
+
module Lightpanda
|
|
5
|
+
class Browser
|
|
6
|
+
# JS evaluation and RemoteObject plumbing: Runtime.evaluate /
|
|
7
|
+
# callFunctionOn dispatch, result serialization (Ferrum's
|
|
8
|
+
# Frame::Runtime is the peer-gem equivalent).
|
|
9
|
+
module Runtime
|
|
10
|
+
# Evaluate JS and return a serialized value.
|
|
11
|
+
# No-args fast path uses Runtime.evaluate; with args we wrap as a function
|
|
12
|
+
# and dispatch via Runtime.callFunctionOn so `arguments[i]` is bound.
|
|
13
|
+
# Both paths use `returnByValue: false` and unwrap so DOM-node returns
|
|
14
|
+
# come back as `{ "__lightpanda_node__" => ... }` for the Driver to wrap.
|
|
15
|
+
#
|
|
16
|
+
# The no-args path sends the user's text verbatim with `replMode: true`
|
|
17
|
+
# (V8's DevTools-console REPL mode — Lightpanda forwards Runtime.evaluate
|
|
18
|
+
# to the V8 inspector, which handles the flag natively). Without it,
|
|
19
|
+
# top-level `const`/`let` persist in the global lexical environment
|
|
20
|
+
# across classic scripts — per spec, and Chrome behaves identically —
|
|
21
|
+
# so a second `const sel = ...` raises `SyntaxError: Identifier 'sel'
|
|
22
|
+
# has already been declared`. REPL mode keeps the bindings (visible to
|
|
23
|
+
# later calls, like the DevTools console) but allows redeclaration.
|
|
24
|
+
# Completion-value semantics cover a bare expression (`'foo'`), a
|
|
25
|
+
# `throw` statement, and multi-statement scripts alike.
|
|
26
|
+
def evaluate(expression, *args)
|
|
27
|
+
if args.empty?
|
|
28
|
+
response = page_command("Runtime.evaluate", expression: expression, returnByValue: false,
|
|
29
|
+
awaitPromise: true, replMode: true)
|
|
30
|
+
raise_on_js_error!("evaluate", expression, response)
|
|
31
|
+
|
|
32
|
+
return unwrap_call_result(response["result"])
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
wrapped = "function() { return #{expression} }"
|
|
36
|
+
call_with_args(wrapped, args)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Execute JS without returning a value.
|
|
40
|
+
#
|
|
41
|
+
# Like `evaluate`, the no-args path uses `replMode: true` so top-level
|
|
42
|
+
# `const`/`let` redeclarations across calls don't raise. Also raises
|
|
43
|
+
# on JS exceptions so silent failures don't mask test bugs (the
|
|
44
|
+
# previous fast path swallowed them because `awaitPromise: false` was
|
|
45
|
+
# checked but `exceptionDetails` was not).
|
|
46
|
+
def execute(expression, *args)
|
|
47
|
+
if args.empty?
|
|
48
|
+
response = page_command("Runtime.evaluate", expression: expression, returnByValue: false,
|
|
49
|
+
awaitPromise: false, replMode: true)
|
|
50
|
+
raise_on_js_error!("execute", expression, response)
|
|
51
|
+
return nil
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
wrapped = "function() { #{expression} }"
|
|
55
|
+
call_with_args(wrapped, args, return_by_value: false)
|
|
56
|
+
nil
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Single home for the exceptionDetails check on Runtime responses:
|
|
60
|
+
# optional LIGHTPANDA_DEBUG dump, then JavaScriptError.
|
|
61
|
+
def raise_on_js_error!(site, expression, response)
|
|
62
|
+
return unless response["exceptionDetails"]
|
|
63
|
+
|
|
64
|
+
debug_js_failure(site, expression, response)
|
|
65
|
+
raise JavaScriptError, response
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# When LIGHTPANDA_DEBUG=1 is set, log the JS expression and full CDP
|
|
69
|
+
# response for every JsException to STDERR. Invaluable for isolating
|
|
70
|
+
# which exact JS triggers an upstream Lightpanda bug.
|
|
71
|
+
def debug_js_failure(site, expression, response)
|
|
72
|
+
return unless ENV["LIGHTPANDA_DEBUG"]
|
|
73
|
+
|
|
74
|
+
warn "[lightpanda:#{site}] expression:\n#{expression}\n[lightpanda:#{site}] response:\n#{response.inspect}\n"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Evaluate async JS with a callback. The user's script receives
|
|
78
|
+
# the callback as its last argument (`arguments[arguments.length - 1]`),
|
|
79
|
+
# matching Capybara's evaluate_async_script contract.
|
|
80
|
+
def evaluate_async(expression, *args, wait: @options.timeout)
|
|
81
|
+
timeout_ms = (wait * 1000).to_i
|
|
82
|
+
wrapped = <<~JS
|
|
83
|
+
function() {
|
|
84
|
+
var __args = Array.prototype.slice.call(arguments);
|
|
85
|
+
return new Promise(function(__resolve, __reject) {
|
|
86
|
+
var __timer = setTimeout(function() {
|
|
87
|
+
__reject(new Error('Async script timeout after #{timeout_ms}ms'));
|
|
88
|
+
}, #{timeout_ms});
|
|
89
|
+
var __done = function(val) { clearTimeout(__timer); __resolve(val); };
|
|
90
|
+
__args.push(__done);
|
|
91
|
+
(function() { #{expression} }).apply(null, __args);
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
JS
|
|
95
|
+
call_with_args(wrapped, args)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Evaluate JS and return a RemoteObject reference (for DOM nodes, arrays).
|
|
99
|
+
def evaluate_with_ref(expression)
|
|
100
|
+
response = page_command("Runtime.evaluate", expression: expression, returnByValue: false, awaitPromise: true)
|
|
101
|
+
raise_on_js_error!("evaluate_with_ref", expression, response)
|
|
102
|
+
|
|
103
|
+
result = response["result"]
|
|
104
|
+
return nil if result["type"] == "undefined"
|
|
105
|
+
|
|
106
|
+
result
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Call a function on a remote object via Runtime.callFunctionOn.
|
|
110
|
+
# Binds `this` to the DOM element referenced by remote_object_id.
|
|
111
|
+
def call_function_on(remote_object_id, function_declaration, *args, return_by_value: true)
|
|
112
|
+
params = {
|
|
113
|
+
objectId: remote_object_id,
|
|
114
|
+
functionDeclaration: function_declaration,
|
|
115
|
+
returnByValue: return_by_value,
|
|
116
|
+
awaitPromise: true,
|
|
117
|
+
}
|
|
118
|
+
params[:arguments] = args.map { |a| serialize_argument(a) } unless args.empty?
|
|
119
|
+
|
|
120
|
+
response = page_command("Runtime.callFunctionOn", **params)
|
|
121
|
+
raise_on_js_error!("call_function_on", function_declaration, response)
|
|
122
|
+
|
|
123
|
+
result = response["result"]
|
|
124
|
+
return nil if result["type"] == "undefined"
|
|
125
|
+
|
|
126
|
+
return_by_value ? result["value"] : result
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Get properties of a remote object (used to extract array elements).
|
|
130
|
+
def get_object_properties(remote_object_id)
|
|
131
|
+
page_command("Runtime.getProperties", objectId: remote_object_id, ownProperties: true)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Release a remote object reference to free V8 memory. Cleanup is
|
|
135
|
+
# best-effort: callers wrap their work in `ensure release_object(...)`,
|
|
136
|
+
# so a TimeoutError or transport hiccup here must not propagate out of
|
|
137
|
+
# the ensure block and bury the original failure.
|
|
138
|
+
def release_object(remote_object_id)
|
|
139
|
+
page_command("Runtime.releaseObject", objectId: remote_object_id)
|
|
140
|
+
rescue Error
|
|
141
|
+
# Object may already be released, context destroyed, or the CDP call
|
|
142
|
+
# itself timed out / failed in transport.
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
private
|
|
146
|
+
|
|
147
|
+
def serialize_argument(arg)
|
|
148
|
+
if arg.respond_to?(:remote_object_id)
|
|
149
|
+
{ objectId: arg.remote_object_id }
|
|
150
|
+
else
|
|
151
|
+
{ value: arg }
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Extract the by-value result of an already-issued Runtime call.
|
|
156
|
+
def handle_evaluate_response(response, expression)
|
|
157
|
+
raise_on_js_error!("handle_evaluate_response", expression, response)
|
|
158
|
+
|
|
159
|
+
result = response["result"]
|
|
160
|
+
return nil if result["type"] == "undefined"
|
|
161
|
+
|
|
162
|
+
result["value"]
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Run a wrapped function via Runtime.callFunctionOn with `arguments` bound.
|
|
166
|
+
# `args` is converted via `serialize_argument` (Nodes → objectId, scalars → value).
|
|
167
|
+
# When `return_by_value: false` (the default) the return value is unwrapped via
|
|
168
|
+
# `unwrap_call_result` so that DOM nodes come back as `{ "__lightpanda_node__" => ... }`
|
|
169
|
+
# hashes the Driver can wrap as Capybara nodes.
|
|
170
|
+
def call_with_args(function_declaration, args, return_by_value: false)
|
|
171
|
+
# document_object_id returns a fresh RemoteObject handle every call.
|
|
172
|
+
# Release it on the way out so long-running shared-spec sessions don't
|
|
173
|
+
# accumulate orphaned V8 handles between resets.
|
|
174
|
+
doc_oid = document_object_id
|
|
175
|
+
params = {
|
|
176
|
+
objectId: doc_oid,
|
|
177
|
+
functionDeclaration: function_declaration,
|
|
178
|
+
returnByValue: return_by_value,
|
|
179
|
+
awaitPromise: true,
|
|
180
|
+
arguments: args.map { |a| serialize_argument(a) },
|
|
181
|
+
}
|
|
182
|
+
response = page_command("Runtime.callFunctionOn", **params)
|
|
183
|
+
raise_on_js_error!("call_with_args", function_declaration, response)
|
|
184
|
+
|
|
185
|
+
return unwrap_call_result(response["result"]) unless return_by_value
|
|
186
|
+
|
|
187
|
+
handle_evaluate_response(response, function_declaration)
|
|
188
|
+
ensure
|
|
189
|
+
release_object(doc_oid) if doc_oid
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Translate a non-by-value Runtime result into a plain Ruby value, surfacing
|
|
193
|
+
# DOM nodes as `{ "__lightpanda_node__" => "..." }` so the Driver can wrap
|
|
194
|
+
# them. The sentinel key (rather than a plain "objectId") prevents
|
|
195
|
+
# misclassifying user JS that legitimately returns `{ objectId: "x" }`.
|
|
196
|
+
#
|
|
197
|
+
# When the result carries an objectId we can't unwrap (function, regexp,
|
|
198
|
+
# date, …), release the handle before falling back to `result["value"]`
|
|
199
|
+
# so V8 doesn't accumulate orphaned references across long sessions.
|
|
200
|
+
def unwrap_call_result(result)
|
|
201
|
+
return nil if result["type"] == "undefined"
|
|
202
|
+
return nil if result["subtype"] == "null"
|
|
203
|
+
|
|
204
|
+
object_id = result["objectId"]
|
|
205
|
+
if object_id
|
|
206
|
+
return { NODE_MARKER => object_id } if result["subtype"] == "node"
|
|
207
|
+
return serialize_remote_array(object_id) if result["subtype"] == "array"
|
|
208
|
+
return serialize_remote_object(object_id) if result["type"] == "object"
|
|
209
|
+
|
|
210
|
+
release_object(object_id)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
result["value"]
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Re-fetch a remote object as JSON-serializable value for plain objects/arrays.
|
|
217
|
+
# Cheaper than walking properties and good enough for shared specs. Releases
|
|
218
|
+
# the original handle so long-lived sessions don't accumulate leaked objectIds.
|
|
219
|
+
def serialize_remote_object(object_id)
|
|
220
|
+
json = page_command(
|
|
221
|
+
"Runtime.callFunctionOn",
|
|
222
|
+
objectId: object_id,
|
|
223
|
+
functionDeclaration: "function() { return this }",
|
|
224
|
+
returnByValue: true
|
|
225
|
+
)
|
|
226
|
+
handle_evaluate_response(json, "function() { return this }")
|
|
227
|
+
ensure
|
|
228
|
+
release_object(object_id)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Walk an array's own indexed properties via `Runtime.getProperties`,
|
|
232
|
+
# unwrapping each element through the regular result pipeline so that
|
|
233
|
+
# DOM-node entries surface as `{ "__lightpanda_node__" => ... }` instead
|
|
234
|
+
# of being flattened to `{}` by `returnByValue: true`. Releases the
|
|
235
|
+
# outer array's objectId once we've harvested its elements.
|
|
236
|
+
def serialize_remote_array(object_id)
|
|
237
|
+
properties = get_object_properties(object_id).fetch("result", [])
|
|
238
|
+
properties
|
|
239
|
+
.select { |p| p["enumerable"] && p["name"] =~ /\A\d+\z/ }
|
|
240
|
+
.sort_by { |p| p["name"].to_i }
|
|
241
|
+
.map { |p| unwrap_call_result(p["value"] || {}) }
|
|
242
|
+
ensure
|
|
243
|
+
release_object(object_id)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# objectId of `document`, used as the `this` context for callFunctionOn when
|
|
247
|
+
# we need `arguments` binding but don't care about `this`. Re-resolved per
|
|
248
|
+
# call because the document objectId is invalidated by navigation.
|
|
249
|
+
def document_object_id
|
|
250
|
+
result = page_command("Runtime.evaluate", expression: "document", returnByValue: false)
|
|
251
|
+
result.dig("result", "objectId")
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
private :raise_on_js_error!, :debug_js_failure, :get_object_properties
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Capybara
|
|
4
|
+
module Lightpanda
|
|
5
|
+
class Browser
|
|
6
|
+
# Selenium-shaped entry points onto capability the gem already has under
|
|
7
|
+
# its own names.
|
|
8
|
+
#
|
|
9
|
+
# Rails suites reach through `page.driver.browser.<x>` in *shared* helpers
|
|
10
|
+
# — `expect_no_js_errors` (the widely-copied "catch JavaScript errors in
|
|
11
|
+
# your system tests" helper) calls `browser.logs.get(:browser)`, and the
|
|
12
|
+
# axe-core accessibility matchers call `browser.execute_async_script`.
|
|
13
|
+
# Both are driver-agnostic in intent but Selenium-named in fact, so on
|
|
14
|
+
# this driver they raised NoMethodError — which takes out every example in
|
|
15
|
+
# the file before a single real assertion runs. Two aliases buy back whole
|
|
16
|
+
# spec files (decidim's `account_spec.rb` + the shared "accessible page"
|
|
17
|
+
# examples, real-apps run 30116365373).
|
|
18
|
+
#
|
|
19
|
+
# Scope is deliberately narrow: alias what maps cleanly onto an existing
|
|
20
|
+
# API, and raise something *actionable* where the underlying model
|
|
21
|
+
# genuinely differs (see #switch_to). We do not grow a Selenium
|
|
22
|
+
# emulation layer.
|
|
23
|
+
module SeleniumCompat
|
|
24
|
+
# Selenium's `Logs#get(:browser)` shape over Browser#console_logs.
|
|
25
|
+
# `level` is the Selenium severity string ("SEVERE"/"WARNING"/…), not
|
|
26
|
+
# the CDP console type, because that is what callers compare against.
|
|
27
|
+
LogEntry = Struct.new(:level, :message, :timestamp) do
|
|
28
|
+
def to_s
|
|
29
|
+
"#{timestamp} #{level} #{message}"
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# CDP `Runtime.consoleAPICalled` type -> Selenium log level. Anything
|
|
34
|
+
# unlisted (log, info, dir, table, …) is INFO, matching Chrome. "warn"
|
|
35
|
+
# is mapped alongside "warning" because Lightpanda emitted the former
|
|
36
|
+
# before upstream #2731.
|
|
37
|
+
CONSOLE_LEVELS = {
|
|
38
|
+
"error" => "SEVERE",
|
|
39
|
+
"assert" => "SEVERE",
|
|
40
|
+
"warning" => "WARNING",
|
|
41
|
+
"warn" => "WARNING",
|
|
42
|
+
"debug" => "DEBUG",
|
|
43
|
+
"trace" => "DEBUG",
|
|
44
|
+
}.freeze
|
|
45
|
+
|
|
46
|
+
# Selenium's `driver.logs` accessor. Only the `:browser` type has a
|
|
47
|
+
# meaning here; other Selenium log types (`:driver`, `:client`,
|
|
48
|
+
# `:server`) have no analogue and read as empty rather than raising, so
|
|
49
|
+
# a helper that probes several types still works.
|
|
50
|
+
class Logs
|
|
51
|
+
def initialize(browser)
|
|
52
|
+
@browser = browser
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def get(type = :browser)
|
|
56
|
+
return [] unless type.to_sym == :browser
|
|
57
|
+
|
|
58
|
+
@browser.console_logs.map do |entry|
|
|
59
|
+
LogEntry.new(
|
|
60
|
+
CONSOLE_LEVELS.fetch(entry[:type], "INFO"),
|
|
61
|
+
entry[:text],
|
|
62
|
+
entry[:timestamp]
|
|
63
|
+
)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def available_types
|
|
68
|
+
[:browser]
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# NOTE: unlike Chrome's `getLog`, this does NOT drain the buffer —
|
|
73
|
+
# repeated calls re-report the same entries. Draining would let an
|
|
74
|
+
# earlier reader silently swallow a JS error from a later assertion,
|
|
75
|
+
# and the buffer is already scoped to the session (cleared by
|
|
76
|
+
# Driver#reset!). Use #clear_console_logs for an explicit reset.
|
|
77
|
+
#
|
|
78
|
+
# Not memoized on purpose: Logs holds nothing but a back-reference, and
|
|
79
|
+
# an ivar here would have to be declared in Browser#initialize per the
|
|
80
|
+
# "all ivars initialized in the constructor" rule for these modules.
|
|
81
|
+
def logs
|
|
82
|
+
Logs.new(self)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Selenium's `execute_async_script`: the script receives a completion
|
|
86
|
+
# callback as its last argument. Same contract as
|
|
87
|
+
# Driver#evaluate_async_script, minus the DOM-node unwrapping — callers
|
|
88
|
+
# on this path (axe-core et al.) hand back plain JSON.
|
|
89
|
+
def execute_async_script(script, *)
|
|
90
|
+
evaluate_async(script.to_s.strip, *)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Selenium's raw-CDP escape hatch, as used by
|
|
94
|
+
# `page.driver.browser.execute_cdp("Network.setBlockedURLs", urls: [...])`.
|
|
95
|
+
# Scoped to the page session, matching Selenium — that covers the
|
|
96
|
+
# Page / Runtime / DOM / Network / Emulation domains a suite would
|
|
97
|
+
# reach for. Browser-scoped commands (Target.*, Browser.*) still go
|
|
98
|
+
# through Browser#command.
|
|
99
|
+
#
|
|
100
|
+
# Deliberately unvalidated: the point is to reach CDP surface the gem
|
|
101
|
+
# has not wrapped yet, so an unknown method should fail with
|
|
102
|
+
# Lightpanda's own error rather than one of ours.
|
|
103
|
+
def execute_cdp(method, **params)
|
|
104
|
+
page_command(method.to_s, **params)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Selenium's post-hoc `switch_to.alert` cannot be honored: Lightpanda
|
|
108
|
+
# requires the accept/dismiss response to be armed BEFORE the action
|
|
109
|
+
# that opens the dialog (LP.handleJavaScriptDialog, upstream #2261 —
|
|
110
|
+
# Page.handleJavaScriptDialog deliberately errors), so by the time a
|
|
111
|
+
# caller could ask for the alert there is nothing left to answer. Fake
|
|
112
|
+
# it and the dialog silently keeps whatever default it already took.
|
|
113
|
+
# Raise with the migration instead of NoMethodError.
|
|
114
|
+
def switch_to
|
|
115
|
+
raise ::Capybara::NotSupportedByDriverError,
|
|
116
|
+
"Lightpanda arms dialog responses before the triggering action, so Selenium's " \
|
|
117
|
+
"post-hoc switch_to.alert has nothing to act on. Wrap the action instead: " \
|
|
118
|
+
"accept_alert/accept_confirm/accept_prompt (or Driver#accept_modal/#dismiss_modal), " \
|
|
119
|
+
"which pre-arm the response and still expose the dialog text."
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|