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
|
@@ -2,15 +2,34 @@
|
|
|
2
2
|
|
|
3
3
|
require "forwardable"
|
|
4
4
|
|
|
5
|
+
require_relative "browser/runtime"
|
|
6
|
+
require_relative "browser/finder"
|
|
7
|
+
require_relative "browser/navigation"
|
|
8
|
+
require_relative "browser/modals"
|
|
9
|
+
require_relative "browser/console"
|
|
10
|
+
require_relative "browser/selenium_compat"
|
|
11
|
+
|
|
5
12
|
module Capybara
|
|
6
13
|
module Lightpanda
|
|
7
14
|
class Browser
|
|
8
15
|
extend Forwardable
|
|
9
16
|
|
|
17
|
+
include Runtime
|
|
18
|
+
include Finder
|
|
19
|
+
include Navigation
|
|
20
|
+
include Modals
|
|
21
|
+
include Console
|
|
22
|
+
include SeleniumCompat
|
|
23
|
+
|
|
10
24
|
attr_reader :options, :process, :client, :target_id, :session_id, :browser_context_id, :frame_stack
|
|
11
25
|
|
|
12
26
|
delegate %i[on off] => :client
|
|
13
27
|
|
|
28
|
+
# Sentinel key marking a serialized DOM node in JS-result payloads.
|
|
29
|
+
# Produced by #unwrap_call_result / #serialize_remote_array, consumed by
|
|
30
|
+
# Driver#unwrap_script_result, which wraps the objectId in a Node.
|
|
31
|
+
NODE_MARKER = "__lightpanda_node__"
|
|
32
|
+
|
|
14
33
|
# --- Live-browser registry: clean teardown at process exit --------------
|
|
15
34
|
# Capybara's per-test reset (Driver#reset!) disposes only the
|
|
16
35
|
# BrowserContext and keeps the process + CDP connection alive, so a
|
|
@@ -61,10 +80,17 @@ module Capybara
|
|
|
61
80
|
@process&.version
|
|
62
81
|
end
|
|
63
82
|
|
|
83
|
+
# Set on the nightly/dev channel only; nil for a tagged release, which
|
|
84
|
+
# carries no build counter. `release` is its mirror image — exactly one of
|
|
85
|
+
# the two is non-nil once the process has started.
|
|
64
86
|
def nightly_build
|
|
65
87
|
@process&.nightly_build
|
|
66
88
|
end
|
|
67
89
|
|
|
90
|
+
def release
|
|
91
|
+
@process&.release
|
|
92
|
+
end
|
|
93
|
+
|
|
68
94
|
def initialize(options = {})
|
|
69
95
|
@options = Options.new(options)
|
|
70
96
|
@process = nil
|
|
@@ -77,13 +103,15 @@ module Capybara
|
|
|
77
103
|
@modal_messages = []
|
|
78
104
|
@modal_messages_mutex = Mutex.new
|
|
79
105
|
@modal_handler_installed = false
|
|
106
|
+
@modal_armed = false
|
|
107
|
+
@unhandled_modal = nil
|
|
80
108
|
@console_logs = []
|
|
81
109
|
@console_logs_mutex = Mutex.new
|
|
110
|
+
@page_errors = []
|
|
111
|
+
@page_errors_mutex = Mutex.new
|
|
82
112
|
@frame_stack = []
|
|
83
113
|
@turbo_event = Utils::Event.new
|
|
84
114
|
@turbo_event.set
|
|
85
|
-
@last_navigation_response = nil
|
|
86
|
-
@document_request_id = nil
|
|
87
115
|
|
|
88
116
|
start
|
|
89
117
|
end
|
|
@@ -129,8 +157,28 @@ module Capybara
|
|
|
129
157
|
subscribe_to_console_capture
|
|
130
158
|
subscribe_to_execution_context
|
|
131
159
|
subscribe_to_turbo_signals
|
|
132
|
-
|
|
160
|
+
# Network owns the Network.* domain: enabling installs traffic
|
|
161
|
+
# tracking AND the navigation-response capture behind status_code.
|
|
162
|
+
# clear_session_state's network.reset flipped @enabled back, so this
|
|
163
|
+
# re-subscribes on the fresh context.
|
|
164
|
+
network.enable
|
|
165
|
+
# Opt into file downloads when a destination exists (driver :save_path
|
|
166
|
+
# or Capybara.save_path). No-op otherwise. Must run after the context
|
|
167
|
+
# is loaded — Browser.setDownloadBehavior is a no-op without one.
|
|
168
|
+
downloads.enable(configured_download_path)
|
|
133
169
|
register_auto_scripts
|
|
170
|
+
set_viewport
|
|
171
|
+
# Subscribe to dialogs from the first page, not lazily on the first
|
|
172
|
+
# accept_modal — otherwise a dialog nobody pre-armed is invisible and
|
|
173
|
+
# the unhandled-modal check below has nothing to report.
|
|
174
|
+
prepare_modals
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Download destination: explicit :save_path option wins, else
|
|
178
|
+
# Capybara.save_path (Cuprite parity). nil => downloads stay off.
|
|
179
|
+
def configured_download_path
|
|
180
|
+
@options.save_path ||
|
|
181
|
+
(defined?(Capybara) && Capybara.respond_to?(:save_path) ? Capybara.save_path : nil)
|
|
134
182
|
end
|
|
135
183
|
|
|
136
184
|
# Wipe per-session state — cookies, storage, all targets — and start
|
|
@@ -174,20 +222,46 @@ module Capybara
|
|
|
174
222
|
def clear_session_state
|
|
175
223
|
@page_events_enabled = false
|
|
176
224
|
@modal_handler_installed = false
|
|
177
|
-
@modal_messages_mutex.synchronize
|
|
225
|
+
@modal_messages_mutex.synchronize do
|
|
226
|
+
@modal_messages.clear
|
|
227
|
+
@modal_armed = false
|
|
228
|
+
@unhandled_modal = nil
|
|
229
|
+
end
|
|
178
230
|
@console_logs_mutex.synchronize { @console_logs.clear }
|
|
179
|
-
@
|
|
180
|
-
@document_request_id = nil
|
|
231
|
+
@page_errors_mutex.synchronize { @page_errors.clear }
|
|
181
232
|
clear_frames
|
|
182
233
|
# Network#reset, not #clear: disposing the BrowserContext also
|
|
183
234
|
# destroyed the Network domain and its subscriptions, so we must
|
|
184
235
|
# flip @enabled back to false — otherwise the next #enable
|
|
185
236
|
# short-circuits and traffic tracking is silently dead.
|
|
186
237
|
@network&.reset
|
|
238
|
+
# Same contract for Downloads: the disposed context drops the
|
|
239
|
+
# download config + subscriptions, so reset re-arms create_page's
|
|
240
|
+
# downloads.enable on the fresh context.
|
|
241
|
+
@downloads&.reset
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Liveness of the CDP transport. Driver#browser checks this to decide
|
|
245
|
+
# whether to respawn a dead browser.
|
|
246
|
+
def alive?
|
|
247
|
+
!client.nil? && !client.closed?
|
|
248
|
+
rescue StandardError
|
|
249
|
+
false
|
|
187
250
|
end
|
|
188
251
|
|
|
189
252
|
def quit
|
|
190
253
|
self.class.untrack(self)
|
|
254
|
+
# Flip Network back to disabled so a later #start re-installs its
|
|
255
|
+
# subscriptions — without this, quit→start reuse of the same
|
|
256
|
+
# instance leaves @enabled true and create_page's network.enable
|
|
257
|
+
# no-ops, silently killing status_code/traffic capture. Guarded on
|
|
258
|
+
# @client: with no client the handlers are already moot and
|
|
259
|
+
# unsubscribe would have nothing to detach from. Downloads carries the
|
|
260
|
+
# same subscription contract, so it resets under the same guard.
|
|
261
|
+
if @client
|
|
262
|
+
@network&.reset
|
|
263
|
+
@downloads&.reset
|
|
264
|
+
end
|
|
191
265
|
begin
|
|
192
266
|
@client&.close
|
|
193
267
|
rescue StandardError
|
|
@@ -216,31 +290,6 @@ module Capybara
|
|
|
216
290
|
@client.command(method, params, session_id: @session_id)
|
|
217
291
|
end
|
|
218
292
|
|
|
219
|
-
# Navigation with readyState fallback.
|
|
220
|
-
#
|
|
221
|
-
# Lightpanda may never fire Page.loadEventFired on complex JS pages
|
|
222
|
-
# (lightpanda-io/browser#1801, #1832). When the event times out,
|
|
223
|
-
# we poll document.readyState as a fallback.
|
|
224
|
-
#
|
|
225
|
-
# Page.navigate is sent asynchronously because Lightpanda may not
|
|
226
|
-
# return the command result until the page is fully loaded (unlike
|
|
227
|
-
# Chrome which returns immediately with frameId/loaderId). If we
|
|
228
|
-
# waited synchronously, the readyState fallback would never be
|
|
229
|
-
# reached on pages that fail to fully load.
|
|
230
|
-
#
|
|
231
|
-
# Uses a single shared deadline so the worst-case wait is 1x timeout,
|
|
232
|
-
# not 2x (lightpanda-io/browser#1849).
|
|
233
|
-
def go_to(url, wait: true, retried: false)
|
|
234
|
-
enable_page_events
|
|
235
|
-
|
|
236
|
-
if wait
|
|
237
|
-
wait_for_page_load(url, retried: retried)
|
|
238
|
-
else
|
|
239
|
-
page_command("Page.navigate", url: url)
|
|
240
|
-
end
|
|
241
|
-
end
|
|
242
|
-
alias goto go_to
|
|
243
|
-
|
|
244
293
|
def enable_page_events
|
|
245
294
|
return if @page_events_enabled
|
|
246
295
|
|
|
@@ -268,19 +317,6 @@ module Capybara
|
|
|
268
317
|
end
|
|
269
318
|
end
|
|
270
319
|
|
|
271
|
-
def back
|
|
272
|
-
wait_for_navigation { navigate_history(-1) }
|
|
273
|
-
end
|
|
274
|
-
|
|
275
|
-
def forward
|
|
276
|
-
wait_for_navigation { navigate_history(+1) }
|
|
277
|
-
end
|
|
278
|
-
|
|
279
|
-
def refresh
|
|
280
|
-
wait_for_navigation { page_command("Page.reload") }
|
|
281
|
-
end
|
|
282
|
-
alias reload refresh
|
|
283
|
-
|
|
284
320
|
def current_url
|
|
285
321
|
evaluate("window.location.href")
|
|
286
322
|
end
|
|
@@ -299,201 +335,20 @@ module Capybara
|
|
|
299
335
|
alias html body
|
|
300
336
|
|
|
301
337
|
# HTTP status of the last document navigation; nil before the first
|
|
302
|
-
# navigation completes.
|
|
303
|
-
#
|
|
338
|
+
# navigation completes. Captured by Network's subscription (installed
|
|
339
|
+
# via network.enable in create_page).
|
|
304
340
|
def status_code
|
|
305
|
-
|
|
341
|
+
network.last_navigation_response&.dig(:status)
|
|
306
342
|
end
|
|
307
343
|
|
|
308
344
|
# Response headers of the last document navigation, wrapped in a Headers
|
|
309
345
|
# instance so `["Content-Type"]` works despite CDP lowercasing keys.
|
|
310
346
|
# Returns an empty Headers (not nil) so callers can chain `[]` safely.
|
|
311
347
|
def response_headers
|
|
312
|
-
raw =
|
|
348
|
+
raw = network.last_navigation_response&.dig(:headers) || {}
|
|
313
349
|
Headers.new.tap { |h| raw.each { |k, v| h[k.to_s.downcase] = v } }
|
|
314
350
|
end
|
|
315
351
|
|
|
316
|
-
# Evaluate JS and return a serialized value.
|
|
317
|
-
# No-args fast path uses Runtime.evaluate; with args we wrap as a function
|
|
318
|
-
# and dispatch via Runtime.callFunctionOn so `arguments[i]` is bound.
|
|
319
|
-
# Both paths use `returnByValue: false` and unwrap so DOM-node returns
|
|
320
|
-
# come back as `{ "__lightpanda_node__" => ... }` for the Driver to wrap.
|
|
321
|
-
#
|
|
322
|
-
# Even the no-args path wraps the expression in an IIFE to isolate
|
|
323
|
-
# top-level `const`/`let` declarations. Upstream Lightpanda retains
|
|
324
|
-
# those bindings across `Runtime.evaluate` calls (V8 starts each call
|
|
325
|
-
# with fresh lexical scope per spec), so a second `const sel = ...`
|
|
326
|
-
# raises `SyntaxError: Identifier 'sel' has already been declared`.
|
|
327
|
-
# Wrapping pushes the declarations into a function scope that gets
|
|
328
|
-
# discarded when the IIFE returns.
|
|
329
|
-
#
|
|
330
|
-
# Use direct `eval` inside the IIFE so the user's text can be a bare
|
|
331
|
-
# expression (`'foo'`), a `throw` statement, OR a multi-statement
|
|
332
|
-
# script with `const`/`let`. `eval`'s completion-value semantics
|
|
333
|
-
# return the last expression's value in all cases. A naive
|
|
334
|
-
# `return EXPR;` wrap would syntax-error on `throw …` and on
|
|
335
|
-
# multi-statement scripts.
|
|
336
|
-
def evaluate(expression, *args)
|
|
337
|
-
if args.empty?
|
|
338
|
-
wrapped = "(function(){return eval(#{expression.to_json})})()"
|
|
339
|
-
response = page_command("Runtime.evaluate", expression: wrapped, returnByValue: false, awaitPromise: true)
|
|
340
|
-
if response["exceptionDetails"]
|
|
341
|
-
debug_js_failure("evaluate", expression, response)
|
|
342
|
-
raise JavaScriptError, response
|
|
343
|
-
end
|
|
344
|
-
|
|
345
|
-
return unwrap_call_result(response["result"])
|
|
346
|
-
end
|
|
347
|
-
|
|
348
|
-
wrapped = "function() { return #{expression} }"
|
|
349
|
-
call_with_args(wrapped, args)
|
|
350
|
-
end
|
|
351
|
-
|
|
352
|
-
# Execute JS without returning a value.
|
|
353
|
-
#
|
|
354
|
-
# Like `evaluate`, the no-args path wraps in an IIFE — same upstream
|
|
355
|
-
# `const`/`let` leak. Also raises on JS exceptions so silent
|
|
356
|
-
# failures don't mask test bugs (the previous fast path swallowed them
|
|
357
|
-
# because `awaitPromise: false` was checked but `exceptionDetails` was
|
|
358
|
-
# not).
|
|
359
|
-
def execute(expression, *args)
|
|
360
|
-
if args.empty?
|
|
361
|
-
wrapped = "(function(){#{expression}})()"
|
|
362
|
-
response = page_command("Runtime.evaluate", expression: wrapped, returnByValue: false, awaitPromise: false)
|
|
363
|
-
if response["exceptionDetails"]
|
|
364
|
-
debug_js_failure("execute", expression, response)
|
|
365
|
-
raise JavaScriptError, response
|
|
366
|
-
end
|
|
367
|
-
return nil
|
|
368
|
-
end
|
|
369
|
-
|
|
370
|
-
wrapped = "function() { #{expression} }"
|
|
371
|
-
call_with_args(wrapped, args, return_by_value: false)
|
|
372
|
-
nil
|
|
373
|
-
end
|
|
374
|
-
|
|
375
|
-
# When LIGHTPANDA_DEBUG=1 is set, log the JS expression and full CDP
|
|
376
|
-
# response for every JsException to STDERR. Invaluable for isolating
|
|
377
|
-
# which exact JS triggers an upstream Lightpanda bug.
|
|
378
|
-
def debug_js_failure(site, expression, response)
|
|
379
|
-
return unless ENV["LIGHTPANDA_DEBUG"]
|
|
380
|
-
|
|
381
|
-
warn "[lightpanda:#{site}] expression:\n#{expression}\n[lightpanda:#{site}] response:\n#{response.inspect}\n"
|
|
382
|
-
end
|
|
383
|
-
|
|
384
|
-
# Evaluate async JS with a callback. The user's script receives
|
|
385
|
-
# the callback as its last argument (`arguments[arguments.length - 1]`),
|
|
386
|
-
# matching Capybara's evaluate_async_script contract.
|
|
387
|
-
def evaluate_async(expression, *args, wait: @options.timeout)
|
|
388
|
-
timeout_ms = (wait * 1000).to_i
|
|
389
|
-
wrapped = <<~JS
|
|
390
|
-
function() {
|
|
391
|
-
var __args = Array.prototype.slice.call(arguments);
|
|
392
|
-
return new Promise(function(__resolve, __reject) {
|
|
393
|
-
var __timer = setTimeout(function() {
|
|
394
|
-
__reject(new Error('Async script timeout after #{timeout_ms}ms'));
|
|
395
|
-
}, #{timeout_ms});
|
|
396
|
-
var __done = function(val) { clearTimeout(__timer); __resolve(val); };
|
|
397
|
-
__args.push(__done);
|
|
398
|
-
(function() { #{expression} }).apply(null, __args);
|
|
399
|
-
});
|
|
400
|
-
}
|
|
401
|
-
JS
|
|
402
|
-
call_with_args(wrapped, args)
|
|
403
|
-
end
|
|
404
|
-
|
|
405
|
-
# Evaluate JS and return a RemoteObject reference (for DOM nodes, arrays).
|
|
406
|
-
def evaluate_with_ref(expression)
|
|
407
|
-
response = page_command("Runtime.evaluate", expression: expression, returnByValue: false, awaitPromise: true)
|
|
408
|
-
if response["exceptionDetails"]
|
|
409
|
-
debug_js_failure("evaluate_with_ref", expression, response)
|
|
410
|
-
raise JavaScriptError, response
|
|
411
|
-
end
|
|
412
|
-
|
|
413
|
-
result = response["result"]
|
|
414
|
-
return nil if result["type"] == "undefined"
|
|
415
|
-
|
|
416
|
-
result
|
|
417
|
-
end
|
|
418
|
-
|
|
419
|
-
# Call a function on a remote object via Runtime.callFunctionOn.
|
|
420
|
-
# Binds `this` to the DOM element referenced by remote_object_id.
|
|
421
|
-
def call_function_on(remote_object_id, function_declaration, *args, return_by_value: true)
|
|
422
|
-
params = {
|
|
423
|
-
objectId: remote_object_id,
|
|
424
|
-
functionDeclaration: function_declaration,
|
|
425
|
-
returnByValue: return_by_value,
|
|
426
|
-
awaitPromise: true,
|
|
427
|
-
}
|
|
428
|
-
params[:arguments] = args.map { |a| serialize_argument(a) } unless args.empty?
|
|
429
|
-
|
|
430
|
-
response = page_command("Runtime.callFunctionOn", **params)
|
|
431
|
-
if response["exceptionDetails"]
|
|
432
|
-
debug_js_failure("call_function_on", function_declaration, response)
|
|
433
|
-
raise JavaScriptError, response
|
|
434
|
-
end
|
|
435
|
-
|
|
436
|
-
result = response["result"]
|
|
437
|
-
return nil if result["type"] == "undefined"
|
|
438
|
-
|
|
439
|
-
return_by_value ? result["value"] : result
|
|
440
|
-
end
|
|
441
|
-
|
|
442
|
-
# Get properties of a remote object (used to extract array elements).
|
|
443
|
-
def get_object_properties(remote_object_id)
|
|
444
|
-
page_command("Runtime.getProperties", objectId: remote_object_id, ownProperties: true)
|
|
445
|
-
end
|
|
446
|
-
|
|
447
|
-
# Release a remote object reference to free V8 memory. Cleanup is
|
|
448
|
-
# best-effort: callers wrap their work in `ensure release_object(...)`,
|
|
449
|
-
# so a TimeoutError or transport hiccup here must not propagate out of
|
|
450
|
-
# the ensure block and bury the original failure.
|
|
451
|
-
def release_object(remote_object_id)
|
|
452
|
-
page_command("Runtime.releaseObject", objectId: remote_object_id)
|
|
453
|
-
rescue Error
|
|
454
|
-
# Object may already be released, context destroyed, or the CDP call
|
|
455
|
-
# itself timed out / failed in transport.
|
|
456
|
-
end
|
|
457
|
-
|
|
458
|
-
# Find elements in the current context (top frame or active frame).
|
|
459
|
-
# Returns an array of remote object ID strings.
|
|
460
|
-
def find(method, selector)
|
|
461
|
-
if @frame_stack.empty?
|
|
462
|
-
find_in_document(method, selector)
|
|
463
|
-
else
|
|
464
|
-
find_in_frame(method, selector)
|
|
465
|
-
end
|
|
466
|
-
end
|
|
467
|
-
|
|
468
|
-
# Find child elements within a specific node.
|
|
469
|
-
# Returns an array of remote object ID strings.
|
|
470
|
-
#
|
|
471
|
-
# Wrapped in `with_default_context_wait` so a click that triggered a
|
|
472
|
-
# navigation immediately before the find (e.g. a fill_in following a
|
|
473
|
-
# link that mutated the DOM) doesn't race against
|
|
474
|
-
# `Runtime.executionContextCreated` and surface as
|
|
475
|
-
# `NoExecutionContextError`. `find_in_document` and `find_in_frame`
|
|
476
|
-
# already use the same wrapper; `find_within` was the odd one out.
|
|
477
|
-
def find_within(remote_object_id, method, selector)
|
|
478
|
-
with_default_context_wait do
|
|
479
|
-
result = call_function_on(remote_object_id, FIND_WITHIN_JS, method, selector, return_by_value: false)
|
|
480
|
-
extract_node_object_ids(result)
|
|
481
|
-
end
|
|
482
|
-
rescue JavaScriptError => e
|
|
483
|
-
raise_invalid_selector(e, method, selector)
|
|
484
|
-
end
|
|
485
|
-
|
|
486
|
-
# Ancestor chain of `remote_object_id` from parentNode up to (but
|
|
487
|
-
# excluding) `document`, returned as an array of remote object IDs.
|
|
488
|
-
# Mirrors Cuprite's JS `parents` helper. Same `with_default_context_wait`
|
|
489
|
-
# wrapping as `find_within` — same race window applies.
|
|
490
|
-
def parents_of(remote_object_id)
|
|
491
|
-
with_default_context_wait do
|
|
492
|
-
result = call_function_on(remote_object_id, PARENTS_JS, return_by_value: false)
|
|
493
|
-
extract_node_object_ids(result)
|
|
494
|
-
end
|
|
495
|
-
end
|
|
496
|
-
|
|
497
352
|
# objectId of document.activeElement, or nil if none/document detached.
|
|
498
353
|
def active_element
|
|
499
354
|
result = evaluate_with_ref("document.activeElement")
|
|
@@ -512,7 +367,7 @@ module Capybara
|
|
|
512
367
|
# objectId, replaces input.files with a real FileList, and fires
|
|
513
368
|
# `input`/`change`. The submitted form then carries the bytes as
|
|
514
369
|
# multipart/form-data (PR #2654, build ≥6672) — both halves are needed,
|
|
515
|
-
#
|
|
370
|
+
# and both are guaranteed by the MINIMUM_NIGHTLY_BUILD floor. Paths are
|
|
516
371
|
# read off the machine running Lightpanda (local for the spawned process).
|
|
517
372
|
def set_file_input_files(remote_object_id, paths)
|
|
518
373
|
page_command("DOM.setFileInputFiles", objectId: remote_object_id, files: paths)
|
|
@@ -578,6 +433,7 @@ module Capybara
|
|
|
578
433
|
|
|
579
434
|
@default_context_event.wait(@options.timeout)
|
|
580
435
|
@turbo_event.wait(@options.timeout)
|
|
436
|
+
check_unhandled_modal!
|
|
581
437
|
end
|
|
582
438
|
|
|
583
439
|
def keyboard
|
|
@@ -588,24 +444,12 @@ module Capybara
|
|
|
588
444
|
@network ||= Network.new(self)
|
|
589
445
|
end
|
|
590
446
|
|
|
591
|
-
def
|
|
592
|
-
@
|
|
593
|
-
end
|
|
594
|
-
|
|
595
|
-
# Console messages captured from `Runtime.consoleAPICalled` since the
|
|
596
|
-
# last `reset` (Turbo-tracker sentinels excluded). Loose hashes, like
|
|
597
|
-
# Network#traffic: `{type:, text:, timestamp:, args:}` where `type` is
|
|
598
|
-
# the console method name ("log", "error", "warning", ...), `text` joins
|
|
599
|
-
# the arguments' primitive values/descriptions, and `args` keeps the raw
|
|
600
|
-
# CDP RemoteObjects. Lets suites assert on JS console errors
|
|
601
|
-
# (`browser.console_logs.select { |m| m[:type] == "error" }`) the way
|
|
602
|
-
# peer drivers do via custom Ferrum loggers.
|
|
603
|
-
def console_logs
|
|
604
|
-
@console_logs_mutex.synchronize { @console_logs.dup }
|
|
447
|
+
def downloads
|
|
448
|
+
@downloads ||= Downloads.new(self)
|
|
605
449
|
end
|
|
606
450
|
|
|
607
|
-
def
|
|
608
|
-
@
|
|
451
|
+
def cookies
|
|
452
|
+
@cookies ||= Cookies.new(self)
|
|
609
453
|
end
|
|
610
454
|
|
|
611
455
|
# -- Frame Support --
|
|
@@ -625,346 +469,84 @@ module Capybara
|
|
|
625
469
|
@frame_stack.clear
|
|
626
470
|
end
|
|
627
471
|
|
|
628
|
-
#
|
|
629
|
-
#
|
|
630
|
-
#
|
|
631
|
-
# the
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
# `pending_dialog_response` is one slot, so a second pre-arm before
|
|
636
|
-
# the first dialog opens overwrites the first.
|
|
637
|
-
|
|
638
|
-
def prepare_modals
|
|
639
|
-
return if @modal_handler_installed
|
|
640
|
-
|
|
641
|
-
enable_page_events
|
|
642
|
-
|
|
643
|
-
on("Page.javascriptDialogOpening") do |params|
|
|
644
|
-
entry = { type: params["type"], message: params["message"] }
|
|
645
|
-
@modal_messages_mutex.synchronize { @modal_messages << entry }
|
|
646
|
-
end
|
|
647
|
-
|
|
648
|
-
@modal_handler_installed = true
|
|
649
|
-
end
|
|
650
|
-
|
|
651
|
-
def accept_modal(_type, text: nil)
|
|
652
|
-
prepare_modals
|
|
653
|
-
params = { accept: true }
|
|
654
|
-
params[:promptText] = text if text
|
|
655
|
-
page_command("LP.handleJavaScriptDialog", **params)
|
|
656
|
-
end
|
|
657
|
-
|
|
658
|
-
def dismiss_modal(_type)
|
|
659
|
-
prepare_modals
|
|
660
|
-
page_command("LP.handleJavaScriptDialog", accept: false)
|
|
661
|
-
end
|
|
472
|
+
# Capybara::Driver::Base resolves frame_url/frame_title via the top
|
|
473
|
+
# execution context, which always reports the parent document. Resolve
|
|
474
|
+
# them through the iframe element's contentWindow / contentDocument so
|
|
475
|
+
# they reflect the active frame.
|
|
476
|
+
def frame_url
|
|
477
|
+
frame = frame_stack.last
|
|
478
|
+
return current_url unless frame
|
|
662
479
|
|
|
663
|
-
|
|
664
|
-
# alert/confirm are indistinguishable) and Cuprite (whose dialog handler
|
|
665
|
-
# accepts whatever fires), we deliberately do NOT reject a dialog whose
|
|
666
|
-
# reported type differs from the one Capybara asked for. Real suites
|
|
667
|
-
# wrap `data-confirm` deletes in `accept_alert` (e.g. solidus admin) and
|
|
668
|
-
# expect it to work; only the message text is matched.
|
|
669
|
-
def find_modal(type, text: nil, wait: options.timeout)
|
|
670
|
-
regexp = text.is_a?(Regexp) ? text : (text && Regexp.new(Regexp.escape(text.to_s)))
|
|
671
|
-
last_seen_message = nil
|
|
672
|
-
claimed = nil
|
|
673
|
-
Utils::Wait.until(timeout: wait, interval: 0.05) do
|
|
674
|
-
claimed = pop_modal_message(regexp)
|
|
675
|
-
next true if claimed
|
|
676
|
-
|
|
677
|
-
last_seen_message = peek_last_modal_message || last_seen_message
|
|
678
|
-
false
|
|
679
|
-
end
|
|
680
|
-
claimed[:message]
|
|
681
|
-
rescue TimeoutError
|
|
682
|
-
raise_modal_not_found(type, text, last_seen_message)
|
|
480
|
+
call_function_on(frame.remote_object_id, FRAME_URL_JS)
|
|
683
481
|
end
|
|
684
482
|
|
|
685
|
-
|
|
483
|
+
def frame_title
|
|
484
|
+
frame = frame_stack.last
|
|
485
|
+
return title unless frame
|
|
686
486
|
|
|
687
|
-
|
|
688
|
-
# pattern (any dialog when `regexp` is nil). Returns the entry or nil.
|
|
689
|
-
# Serialized with the message-thread writer.
|
|
690
|
-
def pop_modal_message(regexp)
|
|
691
|
-
@modal_messages_mutex.synchronize do
|
|
692
|
-
match = @modal_messages.find do |m|
|
|
693
|
-
regexp.nil? || m[:message].to_s.match?(regexp)
|
|
694
|
-
end
|
|
695
|
-
@modal_messages.delete(match) if match
|
|
696
|
-
match
|
|
697
|
-
end
|
|
487
|
+
call_function_on(frame.remote_object_id, FRAME_TITLE_JS)
|
|
698
488
|
end
|
|
699
489
|
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
end
|
|
490
|
+
FRAME_URL_JS = "function() { return this.contentWindow.location.href }"
|
|
491
|
+
FRAME_TITLE_JS = "function() { return this.contentDocument.title }"
|
|
492
|
+
private_constant :FRAME_URL_JS, :FRAME_TITLE_JS
|
|
704
493
|
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
end
|
|
710
|
-
raise Capybara::ModalNotFound, "Unable to find modal dialog#{" with #{text}" if text}"
|
|
711
|
-
end
|
|
494
|
+
# Internal lifecycle steps defined above near their topical groups —
|
|
495
|
+
# calling them out of order corrupts session state, so they are not API.
|
|
496
|
+
private :create_browser_context, :create_page, :clear_session_state,
|
|
497
|
+
:enable_page_events
|
|
712
498
|
|
|
713
|
-
|
|
714
|
-
# malformed selector, so the Ruby side can convert JavaScriptError into
|
|
715
|
-
# Capybara::Lightpanda::InvalidSelector. Cuprite uses a JS subclass for
|
|
716
|
-
# the same purpose; a plain prefixed string keeps our inline JS simple.
|
|
717
|
-
INVALID_SELECTOR_MARKER = "LIGHTPANDA_INVALID_SELECTOR:"
|
|
718
|
-
|
|
719
|
-
# JS function for finding elements within a node.
|
|
720
|
-
# Works in any execution context (top frame or iframe). For CSS, any
|
|
721
|
-
# throw from querySelectorAll means the selector is malformed
|
|
722
|
-
# (re-throw with the marker prefix so Ruby converts to InvalidSelector).
|
|
723
|
-
# XPath routes through native `Document.evaluate` + `XPathResult`
|
|
724
|
-
# (Lightpanda PR #2305, in nightly >=6109); on parse error we return
|
|
725
|
-
# [] silently to match Capybara's internal XPath generator, which
|
|
726
|
-
# sometimes produces selectors with empty trailing predicates like
|
|
727
|
-
# `(...)[]` that native rejects but `has_element?` expects to behave
|
|
728
|
-
# as "not found" rather than raise InvalidSelector.
|
|
729
|
-
# `XPathResult.ORDERED_NODE_SNAPSHOT_TYPE` is `7` in the spec — inlined
|
|
730
|
-
# so the JS doesn't depend on the enum being defined as a constant.
|
|
731
|
-
FIND_WITHIN_JS = <<~JS.freeze
|
|
732
|
-
function(method, selector) {
|
|
733
|
-
if (method === 'xpath') {
|
|
734
|
-
try {
|
|
735
|
-
var r = this.ownerDocument.evaluate(selector, this, null, 7, null);
|
|
736
|
-
var nodes = [];
|
|
737
|
-
for (var i = 0; i < r.snapshotLength; i++) nodes.push(r.snapshotItem(i));
|
|
738
|
-
return nodes;
|
|
739
|
-
} catch(e) { return []; }
|
|
740
|
-
}
|
|
741
|
-
try { return Array.from(this.querySelectorAll(selector)); }
|
|
742
|
-
catch(e) { throw new Error('#{INVALID_SELECTOR_MARKER}' + selector); }
|
|
743
|
-
}
|
|
744
|
-
JS
|
|
745
|
-
private_constant :FIND_WITHIN_JS
|
|
746
|
-
|
|
747
|
-
# JS function for finding elements in an iframe's contentDocument.
|
|
748
|
-
FIND_IN_FRAME_JS = <<~JS.freeze
|
|
749
|
-
function(method, selector) {
|
|
750
|
-
var doc;
|
|
751
|
-
try { doc = this.contentDocument || (this.contentWindow && this.contentWindow.document); } catch(e) {}
|
|
752
|
-
if (!doc) return [];
|
|
753
|
-
if (method === 'xpath') {
|
|
754
|
-
try {
|
|
755
|
-
var r = doc.evaluate(selector, doc, null, 7, null);
|
|
756
|
-
var nodes = [];
|
|
757
|
-
for (var i = 0; i < r.snapshotLength; i++) nodes.push(r.snapshotItem(i));
|
|
758
|
-
return nodes;
|
|
759
|
-
} catch(e) { return []; }
|
|
760
|
-
}
|
|
761
|
-
try { return Array.from(doc.querySelectorAll(selector)); }
|
|
762
|
-
catch(e) { throw new Error('#{INVALID_SELECTOR_MARKER}' + selector); }
|
|
763
|
-
}
|
|
764
|
-
JS
|
|
765
|
-
private_constant :FIND_IN_FRAME_JS
|
|
766
|
-
|
|
767
|
-
# Walks `parentNode` from `this` up to (but excluding) `document`,
|
|
768
|
-
# returning the chain as a JS array. Each entry is an element node so
|
|
769
|
-
# `extract_node_object_ids` can wrap them as Lightpanda::Nodes.
|
|
770
|
-
PARENTS_JS = <<~JS
|
|
771
|
-
function() {
|
|
772
|
-
var nodes = [];
|
|
773
|
-
var p = this.parentNode;
|
|
774
|
-
while (p && p !== this.ownerDocument) {
|
|
775
|
-
nodes.push(p);
|
|
776
|
-
p = p.parentNode;
|
|
777
|
-
}
|
|
778
|
-
return nodes;
|
|
779
|
-
}
|
|
780
|
-
JS
|
|
781
|
-
private_constant :PARENTS_JS
|
|
782
|
-
|
|
783
|
-
def find_in_document(method, selector)
|
|
784
|
-
with_default_context_wait do
|
|
785
|
-
# Coerce Symbol selectors (e.g. Capybara warning path lets `have_css(:p)`
|
|
786
|
-
# through) to a string before quoting. Symbol#inspect returns `:p`,
|
|
787
|
-
# which would inject a bare token into the JS source.
|
|
788
|
-
selector_literal = selector.to_s.inspect
|
|
789
|
-
# XPath parse errors return [] silently to match Capybara's expected
|
|
790
|
-
# "not found" behavior (see FIND_WITHIN_JS comment above for why).
|
|
791
|
-
js = if method == "xpath"
|
|
792
|
-
<<~XPATH_FIND
|
|
793
|
-
(function() {
|
|
794
|
-
try {
|
|
795
|
-
var r = document.evaluate(#{selector_literal}, document, null, 7, null);
|
|
796
|
-
var nodes = [];
|
|
797
|
-
for (var i = 0; i < r.snapshotLength; i++) nodes.push(r.snapshotItem(i));
|
|
798
|
-
return nodes;
|
|
799
|
-
} catch(e) { return []; }
|
|
800
|
-
})()
|
|
801
|
-
XPATH_FIND
|
|
802
|
-
else
|
|
803
|
-
<<~CSS_FIND
|
|
804
|
-
(function() {
|
|
805
|
-
try { return Array.from(document.querySelectorAll(#{selector_literal})); }
|
|
806
|
-
catch(e) { throw new Error('#{INVALID_SELECTOR_MARKER}' + #{selector_literal}); }
|
|
807
|
-
})()
|
|
808
|
-
CSS_FIND
|
|
809
|
-
end
|
|
810
|
-
result = evaluate_with_ref(js)
|
|
811
|
-
extract_node_object_ids(result)
|
|
812
|
-
end
|
|
813
|
-
rescue JavaScriptError => e
|
|
814
|
-
raise_invalid_selector(e, method, selector)
|
|
815
|
-
end
|
|
816
|
-
|
|
817
|
-
def find_in_frame(method, selector)
|
|
818
|
-
with_default_context_wait do
|
|
819
|
-
frame_node = @frame_stack.last
|
|
820
|
-
result = call_function_on(frame_node.remote_object_id, FIND_IN_FRAME_JS, method, selector,
|
|
821
|
-
return_by_value: false)
|
|
822
|
-
extract_node_object_ids(result)
|
|
823
|
-
end
|
|
824
|
-
rescue JavaScriptError => e
|
|
825
|
-
raise_invalid_selector(e, method, selector)
|
|
826
|
-
end
|
|
827
|
-
|
|
828
|
-
def raise_invalid_selector(js_error, method, selector)
|
|
829
|
-
if js_error.message.include?(INVALID_SELECTOR_MARKER)
|
|
830
|
-
raise InvalidSelector.new("Invalid #{method} selector: #{selector.inspect}", method, selector)
|
|
831
|
-
end
|
|
832
|
-
|
|
833
|
-
raise js_error
|
|
834
|
-
end
|
|
835
|
-
|
|
836
|
-
# Extract individual node objectIds from a remote array reference.
|
|
837
|
-
# `ensure release_object` so the outer array handle is freed even when
|
|
838
|
-
# property walking raises — without this, a transient CDP error during
|
|
839
|
-
# property enumeration leaks one V8 handle per failed find call.
|
|
840
|
-
def extract_node_object_ids(result)
|
|
841
|
-
return [] unless result && result["objectId"]
|
|
842
|
-
|
|
843
|
-
outer_id = result["objectId"]
|
|
844
|
-
begin
|
|
845
|
-
props = get_object_properties(outer_id)
|
|
846
|
-
properties = props["result"] || []
|
|
847
|
-
properties
|
|
848
|
-
.select { |p| p["name"] =~ /\A\d+\z/ }
|
|
849
|
-
.sort_by { |p| p["name"].to_i }
|
|
850
|
-
.filter_map { |p| p.dig("value", "objectId") }
|
|
851
|
-
rescue Error
|
|
852
|
-
[]
|
|
853
|
-
ensure
|
|
854
|
-
release_object(outer_id)
|
|
855
|
-
end
|
|
856
|
-
end
|
|
499
|
+
private
|
|
857
500
|
|
|
858
501
|
def register_auto_scripts
|
|
859
502
|
page_command("Page.addScriptToEvaluateOnNewDocument", source: AutoScripts::JS)
|
|
860
503
|
end
|
|
861
504
|
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
params["args"]&.each do |r|
|
|
868
|
-
value = r["value"]
|
|
869
|
-
next if value.is_a?(String) && value.start_with?(TURBO_SENTINEL_PREFIX)
|
|
870
|
-
|
|
871
|
-
logger.puts(value)
|
|
872
|
-
end
|
|
873
|
-
end
|
|
874
|
-
end
|
|
875
|
-
|
|
876
|
-
TURBO_SENTINEL_PREFIX = "__lightpanda_turbo_"
|
|
877
|
-
private_constant :TURBO_SENTINEL_PREFIX
|
|
878
|
-
|
|
879
|
-
# Oldest entries are dropped past this cap so a chatty page can't grow
|
|
880
|
-
# the buffer unbounded across a long session.
|
|
881
|
-
CONSOLE_LOGS_LIMIT = 1_000
|
|
882
|
-
|
|
883
|
-
# Ring-buffer every console.* call for `Browser#console_logs`. Separate
|
|
884
|
-
# from subscribe_to_console_logs (which streams to an optional IO logger)
|
|
885
|
-
# so capture works without any logger configured. Skips the Turbo
|
|
886
|
-
# activity-tracker sentinels — they're driver plumbing, not page output.
|
|
887
|
-
def subscribe_to_console_capture
|
|
888
|
-
on("Runtime.consoleAPICalled") do |params|
|
|
889
|
-
args = params["args"]
|
|
890
|
-
next unless args.is_a?(Array)
|
|
891
|
-
|
|
892
|
-
first = args.first&.dig("value")
|
|
893
|
-
next if first.is_a?(String) && first.start_with?(TURBO_SENTINEL_PREFIX)
|
|
894
|
-
|
|
895
|
-
entry = {
|
|
896
|
-
type: params["type"],
|
|
897
|
-
text: args.map { |a| a.fetch("value") { a["description"] }.to_s }.join(" "),
|
|
898
|
-
timestamp: params["timestamp"],
|
|
899
|
-
args: args,
|
|
900
|
-
}
|
|
901
|
-
@console_logs_mutex.synchronize do
|
|
902
|
-
@console_logs << entry
|
|
903
|
-
@console_logs.shift(@console_logs.size - CONSOLE_LOGS_LIMIT) if @console_logs.size > CONSOLE_LOGS_LIMIT
|
|
904
|
-
end
|
|
905
|
-
end
|
|
906
|
-
end
|
|
907
|
-
|
|
908
|
-
# Wire @turbo_event to the JS-side _signalTurbo emissions. The JS calls
|
|
909
|
-
# console.debug('__lightpanda_turbo_busy') / '_idle' on transitions across
|
|
910
|
-
# zero pending ops; Lightpanda forwards those to Runtime.consoleAPICalled.
|
|
911
|
-
# Idle → set the event (wakes any waiter); busy → reset.
|
|
505
|
+
# Apply the `window_size` option as a JS-visible viewport: it drives
|
|
506
|
+
# window.innerWidth/innerHeight and the viewport `matchMedia` / `@media`
|
|
507
|
+
# evaluate against, so responsive branches resolve at the requested size.
|
|
508
|
+
# It is NOT layout — Lightpanda has no rendering engine, so nothing
|
|
509
|
+
# reflows and getBoundingClientRect stays synthetic.
|
|
912
510
|
#
|
|
913
|
-
#
|
|
914
|
-
#
|
|
915
|
-
#
|
|
916
|
-
#
|
|
917
|
-
|
|
918
|
-
on("Runtime.consoleAPICalled") do |params|
|
|
919
|
-
next unless params["args"].is_a?(Array)
|
|
920
|
-
|
|
921
|
-
marker = params["args"].first&.dig("value")
|
|
922
|
-
next unless marker.is_a?(String) && marker.start_with?(TURBO_SENTINEL_PREFIX)
|
|
923
|
-
|
|
924
|
-
case marker
|
|
925
|
-
when "#{TURBO_SENTINEL_PREFIX}busy" then @turbo_event.reset
|
|
926
|
-
when "#{TURBO_SENTINEL_PREFIX}idle" then @turbo_event.set
|
|
927
|
-
end
|
|
928
|
-
end
|
|
929
|
-
|
|
930
|
-
on("Runtime.executionContextsCleared") { @turbo_event.set }
|
|
931
|
-
end
|
|
932
|
-
|
|
933
|
-
# Remember the latest top-level navigation response so
|
|
934
|
-
# `Driver#status_code` / `#response_headers` can answer it. Mirrors the
|
|
935
|
-
# capybara-playwright-driver page hook that captures
|
|
936
|
-
# `request.navigation_request?` (lib/capybara/playwright/page.rb#L33-L37);
|
|
937
|
-
# CDP normally signals "this is the main-document response" via
|
|
938
|
-
# `Network.responseReceived.type`, but Lightpanda omits that field on
|
|
939
|
-
# responses (only emits `type` on `Network.requestWillBeSent`). So we
|
|
940
|
-
# do the matching the long way: capture the document requestId from
|
|
941
|
-
# `requestWillBeSent {type: "Document"}`, then store the response whose
|
|
942
|
-
# `requestId` equals it. Re-installed per `create_page` so the new
|
|
943
|
-
# BrowserContext after `Driver#reset!` starts with a fresh slot.
|
|
511
|
+
# Re-applied on every create_page rather than once at connect. The
|
|
512
|
+
# override lives on the CDP-connection-scoped Browser upstream, so it
|
|
513
|
+
# already survives Driver#reset!'s disposeBrowserContext; re-sending is
|
|
514
|
+
# one idempotent call that also covers the reconnect path, where the
|
|
515
|
+
# connection (and therefore the override) is genuinely new.
|
|
944
516
|
#
|
|
945
|
-
#
|
|
946
|
-
#
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
517
|
+
# window_size is validated in Options#initialize, which runs before the
|
|
518
|
+
# process is spawned — a bad value must not leave an orphaned browser
|
|
519
|
+
# behind, and raising from here would (Browser#initialize starts the
|
|
520
|
+
# process before create_page ever runs).
|
|
521
|
+
#
|
|
522
|
+
# Public (declared below the private section via `public :set_viewport`)
|
|
523
|
+
# because Driver#resize_window_to drives it mid-test: with no arguments
|
|
524
|
+
# it re-applies the configured size, which is what create_page wants.
|
|
525
|
+
# SHARP EDGE, verified 2026-07-25 on nightly 8285: this updates
|
|
526
|
+
# window.innerWidth/innerHeight and what `matchMedia` reports
|
|
527
|
+
# immediately, but it does NOT re-resolve `@media` rules for the document
|
|
528
|
+
# already loaded. Lightpanda fixes the cascade when a document is parsed
|
|
529
|
+
# and nothing invalidates it on a metrics change, so a page loaded at
|
|
530
|
+
# 1920 keeps rendering its desktop branch even while `matchMedia`
|
|
531
|
+
# ("(max-width: 500px)") reports true. Navigating (Driver#visit) parses
|
|
532
|
+
# under the new metrics and resolves correctly.
|
|
533
|
+
#
|
|
534
|
+
# No workaround shipped on purpose. DOM/CSSOM mutations look like they
|
|
535
|
+
# force a re-cascade but don't survive scrutiny (the probe that suggested
|
|
536
|
+
# otherwise had been contaminated by the previous iteration's viewport),
|
|
537
|
+
# so anything here would be a guess dressed up as a fix. Driver's window
|
|
538
|
+
# methods document the resize-then-visit shape instead.
|
|
539
|
+
def set_viewport(width = nil, height = nil)
|
|
540
|
+
width, height = @options.window_size if width.nil? || height.nil?
|
|
541
|
+
page_command("Emulation.setDeviceMetricsOverride", width: width, height: height)
|
|
542
|
+
[width, height]
|
|
543
|
+
end
|
|
966
544
|
|
|
967
|
-
|
|
545
|
+
# The viewport as the page currently sees it. Read back from JS rather
|
|
546
|
+
# than echoing what we last set, so it stays truthful if a page (or a
|
|
547
|
+
# direct Emulation call through #execute_cdp) moved it behind our back.
|
|
548
|
+
def viewport_size
|
|
549
|
+
evaluate("[window.innerWidth, window.innerHeight]")
|
|
968
550
|
end
|
|
969
551
|
|
|
970
552
|
# Track default-execution-context availability via Runtime events.
|
|
@@ -987,153 +569,6 @@ module Capybara
|
|
|
987
569
|
page_command("Runtime.enable")
|
|
988
570
|
end
|
|
989
571
|
|
|
990
|
-
def serialize_argument(arg)
|
|
991
|
-
if arg.respond_to?(:remote_object_id)
|
|
992
|
-
{ objectId: arg.remote_object_id }
|
|
993
|
-
else
|
|
994
|
-
{ value: arg }
|
|
995
|
-
end
|
|
996
|
-
end
|
|
997
|
-
|
|
998
|
-
def document_node_id
|
|
999
|
-
result = page_command("DOM.getDocument")
|
|
1000
|
-
|
|
1001
|
-
result.dig("root", "nodeId")
|
|
1002
|
-
end
|
|
1003
|
-
|
|
1004
|
-
def handle_evaluate_response(response)
|
|
1005
|
-
if response["exceptionDetails"]
|
|
1006
|
-
debug_js_failure("handle_evaluate_response", "(unknown — already-issued call)", response)
|
|
1007
|
-
raise JavaScriptError, response
|
|
1008
|
-
end
|
|
1009
|
-
|
|
1010
|
-
result = response["result"]
|
|
1011
|
-
return nil if result["type"] == "undefined"
|
|
1012
|
-
|
|
1013
|
-
result["value"]
|
|
1014
|
-
end
|
|
1015
|
-
|
|
1016
|
-
# Run a wrapped function via Runtime.callFunctionOn with `arguments` bound.
|
|
1017
|
-
# `args` is converted via `serialize_argument` (Nodes → objectId, scalars → value).
|
|
1018
|
-
# When `return_by_value: false` (the default) the return value is unwrapped via
|
|
1019
|
-
# `unwrap_call_result` so that DOM nodes come back as `{ "__lightpanda_node__" => ... }`
|
|
1020
|
-
# hashes the Driver can wrap as Capybara nodes.
|
|
1021
|
-
def call_with_args(function_declaration, args, return_by_value: false)
|
|
1022
|
-
# document_object_id returns a fresh RemoteObject handle every call.
|
|
1023
|
-
# Release it on the way out so long-running shared-spec sessions don't
|
|
1024
|
-
# accumulate orphaned V8 handles between resets.
|
|
1025
|
-
doc_oid = document_object_id
|
|
1026
|
-
params = {
|
|
1027
|
-
objectId: doc_oid,
|
|
1028
|
-
functionDeclaration: function_declaration,
|
|
1029
|
-
returnByValue: return_by_value,
|
|
1030
|
-
awaitPromise: true,
|
|
1031
|
-
arguments: args.map { |a| serialize_argument(a) },
|
|
1032
|
-
}
|
|
1033
|
-
response = page_command("Runtime.callFunctionOn", **params)
|
|
1034
|
-
if response["exceptionDetails"]
|
|
1035
|
-
debug_js_failure("call_with_args", function_declaration, response)
|
|
1036
|
-
raise JavaScriptError, response
|
|
1037
|
-
end
|
|
1038
|
-
|
|
1039
|
-
return_by_value ? handle_evaluate_response(response) : unwrap_call_result(response["result"])
|
|
1040
|
-
ensure
|
|
1041
|
-
release_object(doc_oid) if doc_oid
|
|
1042
|
-
end
|
|
1043
|
-
|
|
1044
|
-
# Translate a non-by-value Runtime result into a plain Ruby value, surfacing
|
|
1045
|
-
# DOM nodes as `{ "__lightpanda_node__" => "..." }` so the Driver can wrap
|
|
1046
|
-
# them. The sentinel key (rather than a plain "objectId") prevents
|
|
1047
|
-
# misclassifying user JS that legitimately returns `{ objectId: "x" }`.
|
|
1048
|
-
#
|
|
1049
|
-
# When the result carries an objectId we can't unwrap (function, regexp,
|
|
1050
|
-
# date, …), release the handle before falling back to `result["value"]`
|
|
1051
|
-
# so V8 doesn't accumulate orphaned references across long sessions.
|
|
1052
|
-
def unwrap_call_result(result)
|
|
1053
|
-
return nil if result["type"] == "undefined"
|
|
1054
|
-
return nil if result["subtype"] == "null"
|
|
1055
|
-
|
|
1056
|
-
object_id = result["objectId"]
|
|
1057
|
-
if object_id
|
|
1058
|
-
return { "__lightpanda_node__" => object_id } if result["subtype"] == "node"
|
|
1059
|
-
return serialize_remote_array(object_id) if result["subtype"] == "array"
|
|
1060
|
-
return serialize_remote_object(object_id) if result["type"] == "object"
|
|
1061
|
-
|
|
1062
|
-
release_object(object_id)
|
|
1063
|
-
end
|
|
1064
|
-
|
|
1065
|
-
result["value"]
|
|
1066
|
-
end
|
|
1067
|
-
|
|
1068
|
-
# Re-fetch a remote object as JSON-serializable value for plain objects/arrays.
|
|
1069
|
-
# Cheaper than walking properties and good enough for shared specs. Releases
|
|
1070
|
-
# the original handle so long-lived sessions don't accumulate leaked objectIds.
|
|
1071
|
-
def serialize_remote_object(object_id)
|
|
1072
|
-
json = page_command(
|
|
1073
|
-
"Runtime.callFunctionOn",
|
|
1074
|
-
objectId: object_id,
|
|
1075
|
-
functionDeclaration: "function() { return this }",
|
|
1076
|
-
returnByValue: true
|
|
1077
|
-
)
|
|
1078
|
-
handle_evaluate_response(json)
|
|
1079
|
-
ensure
|
|
1080
|
-
release_object(object_id)
|
|
1081
|
-
end
|
|
1082
|
-
|
|
1083
|
-
# Walk an array's own indexed properties via `Runtime.getProperties`,
|
|
1084
|
-
# unwrapping each element through the regular result pipeline so that
|
|
1085
|
-
# DOM-node entries surface as `{ "__lightpanda_node__" => ... }` instead
|
|
1086
|
-
# of being flattened to `{}` by `returnByValue: true`. Releases the
|
|
1087
|
-
# outer array's objectId once we've harvested its elements.
|
|
1088
|
-
def serialize_remote_array(object_id)
|
|
1089
|
-
properties = get_object_properties(object_id).fetch("result", [])
|
|
1090
|
-
properties
|
|
1091
|
-
.select { |p| p["enumerable"] && p["name"] =~ /\A\d+\z/ }
|
|
1092
|
-
.sort_by { |p| p["name"].to_i }
|
|
1093
|
-
.map { |p| unwrap_call_result(p["value"] || {}) }
|
|
1094
|
-
ensure
|
|
1095
|
-
release_object(object_id)
|
|
1096
|
-
end
|
|
1097
|
-
|
|
1098
|
-
# objectId of `document`, used as the `this` context for callFunctionOn when
|
|
1099
|
-
# we need `arguments` binding but don't care about `this`. Re-resolved per
|
|
1100
|
-
# call because the document objectId is invalidated by navigation.
|
|
1101
|
-
def document_object_id
|
|
1102
|
-
result = page_command("Runtime.evaluate", expression: "document", returnByValue: false)
|
|
1103
|
-
result.dig("result", "objectId")
|
|
1104
|
-
end
|
|
1105
|
-
|
|
1106
|
-
def wait_for_page_load(url, retried:)
|
|
1107
|
-
deadline = await_navigation do
|
|
1108
|
-
@client.command("Page.navigate", { url: url }, async: true, session_id: @session_id)
|
|
1109
|
-
end
|
|
1110
|
-
handle_navigation_crash(url, deadline, retried: retried)
|
|
1111
|
-
end
|
|
1112
|
-
|
|
1113
|
-
# Lightpanda may kill the WebSocket or crash during complex page
|
|
1114
|
-
# navigation (lightpanda-io/browser#1849, #1854). Reconnect and
|
|
1115
|
-
# retry once. If the retry also crashes, raise a clear error
|
|
1116
|
-
# instead of leaving the client in a dead state.
|
|
1117
|
-
def handle_navigation_crash(url, deadline, retried:)
|
|
1118
|
-
if @client.closed? && !retried
|
|
1119
|
-
begin
|
|
1120
|
-
reconnect
|
|
1121
|
-
remaining = deadline - monotonic_time
|
|
1122
|
-
go_to(url, wait: remaining.positive?, retried: true) if remaining.positive?
|
|
1123
|
-
rescue DeadBrowserError
|
|
1124
|
-
raise
|
|
1125
|
-
rescue StandardError
|
|
1126
|
-
# reconnect itself failed (process won't restart, port stuck, etc.).
|
|
1127
|
-
# Fall through to the raise below — a second immediate reconnect
|
|
1128
|
-
# attempt would just duplicate the failure we already swallowed.
|
|
1129
|
-
end
|
|
1130
|
-
end
|
|
1131
|
-
|
|
1132
|
-
return unless @client.closed?
|
|
1133
|
-
|
|
1134
|
-
raise DeadBrowserError, "Lightpanda crashed navigating to #{url}"
|
|
1135
|
-
end
|
|
1136
|
-
|
|
1137
572
|
def close_client_silently
|
|
1138
573
|
@client&.close
|
|
1139
574
|
rescue StandardError
|
|
@@ -1166,105 +601,15 @@ module Capybara
|
|
|
1166
601
|
@process.start
|
|
1167
602
|
end
|
|
1168
603
|
|
|
1169
|
-
def safe_current_url
|
|
1170
|
-
current_url
|
|
1171
|
-
rescue StandardError
|
|
1172
|
-
nil
|
|
1173
|
-
end
|
|
1174
|
-
|
|
1175
|
-
# Wait for a navigation triggered by the given block.
|
|
1176
|
-
# Uses the same loadEventFired + readyState fallback as go_to.
|
|
1177
|
-
def wait_for_navigation(&)
|
|
1178
|
-
enable_page_events
|
|
1179
|
-
await_navigation(&)
|
|
1180
|
-
end
|
|
1181
|
-
|
|
1182
|
-
# Step the session history by `offset` (-1 = back, +1 = forward) using
|
|
1183
|
-
# native CDP. `Page.getNavigationHistory` returns the entry list and
|
|
1184
|
-
# `currentIndex`; `Page.navigateToHistoryEntry` jumps to the chosen
|
|
1185
|
-
# entry's `id`. No-op when the offset would step past either end so
|
|
1186
|
-
# the behavior matches `history.back()` / `history.forward()` on a
|
|
1187
|
-
# bounded session history.
|
|
1188
|
-
def navigate_history(offset)
|
|
1189
|
-
history = page_command("Page.getNavigationHistory")
|
|
1190
|
-
target_index = history["currentIndex"] + offset
|
|
1191
|
-
entries = history["entries"]
|
|
1192
|
-
return if target_index.negative? || target_index >= entries.length
|
|
1193
|
-
|
|
1194
|
-
page_command("Page.navigateToHistoryEntry", entryId: entries[target_index]["id"])
|
|
1195
|
-
end
|
|
1196
|
-
|
|
1197
|
-
# Common navigation lifecycle shared by `wait_for_page_load` (fresh
|
|
1198
|
-
# `Page.navigate`) and `wait_for_navigation` (back / forward / reload).
|
|
1199
|
-
# Subscribes to Page.loadEventFired, runs the trigger, waits briefly for
|
|
1200
|
-
# the event, falls back to readyState polling for the remaining budget.
|
|
1201
|
-
# The handler is unsubscribed via `ensure` so a raising trigger doesn't
|
|
1202
|
-
# leak a subscription onto the next navigation. Returns the deadline so
|
|
1203
|
-
# the caller can decide whether to attempt crash recovery.
|
|
1204
|
-
def await_navigation
|
|
1205
|
-
starting_url = safe_current_url
|
|
1206
|
-
deadline = monotonic_time + @options.timeout
|
|
1207
|
-
loaded = Utils::Event.new
|
|
1208
|
-
handler = proc { loaded.set }
|
|
1209
|
-
@client.on("Page.loadEventFired", &handler)
|
|
1210
|
-
|
|
1211
|
-
begin
|
|
1212
|
-
yield
|
|
1213
|
-
|
|
1214
|
-
unless loaded.wait([2, @options.timeout].min)
|
|
1215
|
-
remaining = deadline - monotonic_time
|
|
1216
|
-
poll_ready_state(remaining, loaded_event: loaded, starting_url: starting_url) if remaining.positive?
|
|
1217
|
-
end
|
|
1218
|
-
ensure
|
|
1219
|
-
@client.off("Page.loadEventFired", handler)
|
|
1220
|
-
end
|
|
1221
|
-
|
|
1222
|
-
deadline
|
|
1223
|
-
end
|
|
1224
|
-
|
|
1225
|
-
# Poll document.readyState as a fallback when Page.loadEventFired
|
|
1226
|
-
# doesn't fire (CLAUDE.md rules call this out as load-bearing — do
|
|
1227
|
-
# not remove). When starting_url is provided, the poll ignores
|
|
1228
|
-
# readyState values from the old page (e.g. about:blank reports
|
|
1229
|
-
# "complete" while the new page is still loading in the background).
|
|
1230
|
-
def poll_ready_state(timeout, loaded_event: nil, starting_url: nil)
|
|
1231
|
-
# Use a short per-evaluation timeout because Lightpanda may block
|
|
1232
|
-
# all commands while navigating. Without this, a single evaluate()
|
|
1233
|
-
# call would consume the entire @options.timeout, making the poll
|
|
1234
|
-
# loop effectively a single attempt.
|
|
1235
|
-
poll_cmd_timeout = [timeout / 5.0, 2].max
|
|
1236
|
-
|
|
1237
|
-
Utils::Wait.until(timeout: timeout, interval: 0.1) do
|
|
1238
|
-
loaded_event&.set? || @client.closed? || page_ready?(poll_cmd_timeout, starting_url)
|
|
1239
|
-
end
|
|
1240
|
-
rescue TimeoutError
|
|
1241
|
-
# Expected — readyState fallback exhausted its budget. The caller
|
|
1242
|
-
# (await_navigation) keeps going and lets handle_navigation_crash
|
|
1243
|
-
# decide whether the session is recoverable.
|
|
1244
|
-
end
|
|
1245
|
-
|
|
1246
|
-
POLL_STATE_JS = "(function(){return{r:document.readyState,u:location.href}})()"
|
|
1247
|
-
private_constant :POLL_STATE_JS
|
|
1248
|
-
|
|
1249
|
-
def page_ready?(cmd_timeout, starting_url)
|
|
1250
|
-
response = @client.command(
|
|
1251
|
-
"Runtime.evaluate",
|
|
1252
|
-
{ expression: POLL_STATE_JS, returnByValue: true, awaitPromise: true },
|
|
1253
|
-
session_id: @session_id,
|
|
1254
|
-
timeout: cmd_timeout
|
|
1255
|
-
)
|
|
1256
|
-
state = response.dig("result", "value")
|
|
1257
|
-
return false unless state
|
|
1258
|
-
|
|
1259
|
-
url_changed = starting_url.nil? || state["u"] != starting_url
|
|
1260
|
-
url_changed && %w[complete interactive].include?(state["r"])
|
|
1261
|
-
rescue Error
|
|
1262
|
-
false
|
|
1263
|
-
end
|
|
1264
|
-
|
|
1265
604
|
def monotonic_time
|
|
1266
605
|
::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
1267
606
|
end
|
|
607
|
+
|
|
608
|
+
# Viewport control is API, not lifecycle: Driver#resize_window_to and
|
|
609
|
+
# #window_size call it mid-test. It lives up in the private section
|
|
610
|
+
# beside register_auto_scripts because create_page is its other caller
|
|
611
|
+
# and the two read together.
|
|
612
|
+
public :set_viewport, :viewport_size
|
|
1268
613
|
end
|
|
1269
614
|
end
|
|
1270
615
|
end
|