capybara-lightpanda 0.8.0 → 0.9.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 +25 -0
- data/lib/capybara/lightpanda/auto_scripts.rb +28 -2
- data/lib/capybara/lightpanda/binary.rb +2 -46
- data/lib/capybara/lightpanda/browser/console.rb +108 -0
- data/lib/capybara/lightpanda/browser/finder.rb +196 -0
- data/lib/capybara/lightpanda/browser/modals.rb +99 -0
- data/lib/capybara/lightpanda/browser/navigation.rb +185 -0
- data/lib/capybara/lightpanda/browser/runtime.rb +258 -0
- data/lib/capybara/lightpanda/browser.rb +61 -816
- 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/driver.rb +21 -36
- data/lib/capybara/lightpanda/errors.rb +19 -10
- data/lib/capybara/lightpanda/javascripts/attach.js +16 -0
- data/lib/capybara/lightpanda/javascripts/banner.js +15 -0
- data/lib/capybara/lightpanda/javascripts/predicates.js +152 -0
- data/lib/capybara/lightpanda/javascripts/turbo.js +67 -0
- data/lib/capybara/lightpanda/keyboard.rb +23 -19
- data/lib/capybara/lightpanda/network.rb +72 -13
- data/lib/capybara/lightpanda/node.rb +24 -31
- data/lib/capybara/lightpanda/options.rb +4 -0
- data/lib/capybara/lightpanda/process.rb +3 -18
- data/lib/capybara/lightpanda/version.rb +1 -1
- metadata +10 -2
- data/lib/capybara/lightpanda/javascripts/index.js +0 -226
|
@@ -2,15 +2,32 @@
|
|
|
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
|
+
|
|
5
11
|
module Capybara
|
|
6
12
|
module Lightpanda
|
|
7
13
|
class Browser
|
|
8
14
|
extend Forwardable
|
|
9
15
|
|
|
16
|
+
include Runtime
|
|
17
|
+
include Finder
|
|
18
|
+
include Navigation
|
|
19
|
+
include Modals
|
|
20
|
+
include Console
|
|
21
|
+
|
|
10
22
|
attr_reader :options, :process, :client, :target_id, :session_id, :browser_context_id, :frame_stack
|
|
11
23
|
|
|
12
24
|
delegate %i[on off] => :client
|
|
13
25
|
|
|
26
|
+
# Sentinel key marking a serialized DOM node in JS-result payloads.
|
|
27
|
+
# Produced by #unwrap_call_result / #serialize_remote_array, consumed by
|
|
28
|
+
# Driver#unwrap_script_result, which wraps the objectId in a Node.
|
|
29
|
+
NODE_MARKER = "__lightpanda_node__"
|
|
30
|
+
|
|
14
31
|
# --- Live-browser registry: clean teardown at process exit --------------
|
|
15
32
|
# Capybara's per-test reset (Driver#reset!) disposes only the
|
|
16
33
|
# BrowserContext and keeps the process + CDP connection alive, so a
|
|
@@ -82,8 +99,6 @@ module Capybara
|
|
|
82
99
|
@frame_stack = []
|
|
83
100
|
@turbo_event = Utils::Event.new
|
|
84
101
|
@turbo_event.set
|
|
85
|
-
@last_navigation_response = nil
|
|
86
|
-
@document_request_id = nil
|
|
87
102
|
|
|
88
103
|
start
|
|
89
104
|
end
|
|
@@ -129,7 +144,11 @@ module Capybara
|
|
|
129
144
|
subscribe_to_console_capture
|
|
130
145
|
subscribe_to_execution_context
|
|
131
146
|
subscribe_to_turbo_signals
|
|
132
|
-
|
|
147
|
+
# Network owns the Network.* domain: enabling installs traffic
|
|
148
|
+
# tracking AND the navigation-response capture behind status_code.
|
|
149
|
+
# clear_session_state's network.reset flipped @enabled back, so this
|
|
150
|
+
# re-subscribes on the fresh context.
|
|
151
|
+
network.enable
|
|
133
152
|
register_auto_scripts
|
|
134
153
|
end
|
|
135
154
|
|
|
@@ -176,8 +195,6 @@ module Capybara
|
|
|
176
195
|
@modal_handler_installed = false
|
|
177
196
|
@modal_messages_mutex.synchronize { @modal_messages.clear }
|
|
178
197
|
@console_logs_mutex.synchronize { @console_logs.clear }
|
|
179
|
-
@last_navigation_response = nil
|
|
180
|
-
@document_request_id = nil
|
|
181
198
|
clear_frames
|
|
182
199
|
# Network#reset, not #clear: disposing the BrowserContext also
|
|
183
200
|
# destroyed the Network domain and its subscriptions, so we must
|
|
@@ -186,8 +203,23 @@ module Capybara
|
|
|
186
203
|
@network&.reset
|
|
187
204
|
end
|
|
188
205
|
|
|
206
|
+
# Liveness of the CDP transport. Driver#browser checks this to decide
|
|
207
|
+
# whether to respawn a dead browser.
|
|
208
|
+
def alive?
|
|
209
|
+
!client.nil? && !client.closed?
|
|
210
|
+
rescue StandardError
|
|
211
|
+
false
|
|
212
|
+
end
|
|
213
|
+
|
|
189
214
|
def quit
|
|
190
215
|
self.class.untrack(self)
|
|
216
|
+
# Flip Network back to disabled so a later #start re-installs its
|
|
217
|
+
# subscriptions — without this, quit→start reuse of the same
|
|
218
|
+
# instance leaves @enabled true and create_page's network.enable
|
|
219
|
+
# no-ops, silently killing status_code/traffic capture. Guarded on
|
|
220
|
+
# @client: with no client the handlers are already moot and
|
|
221
|
+
# unsubscribe would have nothing to detach from.
|
|
222
|
+
@network&.reset if @client
|
|
191
223
|
begin
|
|
192
224
|
@client&.close
|
|
193
225
|
rescue StandardError
|
|
@@ -216,31 +248,6 @@ module Capybara
|
|
|
216
248
|
@client.command(method, params, session_id: @session_id)
|
|
217
249
|
end
|
|
218
250
|
|
|
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
251
|
def enable_page_events
|
|
245
252
|
return if @page_events_enabled
|
|
246
253
|
|
|
@@ -268,19 +275,6 @@ module Capybara
|
|
|
268
275
|
end
|
|
269
276
|
end
|
|
270
277
|
|
|
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
278
|
def current_url
|
|
285
279
|
evaluate("window.location.href")
|
|
286
280
|
end
|
|
@@ -299,201 +293,20 @@ module Capybara
|
|
|
299
293
|
alias html body
|
|
300
294
|
|
|
301
295
|
# HTTP status of the last document navigation; nil before the first
|
|
302
|
-
# navigation completes.
|
|
303
|
-
#
|
|
296
|
+
# navigation completes. Captured by Network's subscription (installed
|
|
297
|
+
# via network.enable in create_page).
|
|
304
298
|
def status_code
|
|
305
|
-
|
|
299
|
+
network.last_navigation_response&.dig(:status)
|
|
306
300
|
end
|
|
307
301
|
|
|
308
302
|
# Response headers of the last document navigation, wrapped in a Headers
|
|
309
303
|
# instance so `["Content-Type"]` works despite CDP lowercasing keys.
|
|
310
304
|
# Returns an empty Headers (not nil) so callers can chain `[]` safely.
|
|
311
305
|
def response_headers
|
|
312
|
-
raw =
|
|
306
|
+
raw = network.last_navigation_response&.dig(:headers) || {}
|
|
313
307
|
Headers.new.tap { |h| raw.each { |k, v| h[k.to_s.downcase] = v } }
|
|
314
308
|
end
|
|
315
309
|
|
|
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
310
|
# objectId of document.activeElement, or nil if none/document detached.
|
|
498
311
|
def active_element
|
|
499
312
|
result = evaluate_with_ref("document.activeElement")
|
|
@@ -592,22 +405,6 @@ module Capybara
|
|
|
592
405
|
@cookies ||= Cookies.new(self)
|
|
593
406
|
end
|
|
594
407
|
|
|
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 }
|
|
605
|
-
end
|
|
606
|
-
|
|
607
|
-
def clear_console_logs
|
|
608
|
-
@console_logs_mutex.synchronize { @console_logs.clear }
|
|
609
|
-
end
|
|
610
|
-
|
|
611
408
|
# -- Frame Support --
|
|
612
409
|
# `frame_stack` (Array<Node>) is the Capybara `switch_to_frame` stack;
|
|
613
410
|
# it drives where `find` resolves selectors. Stored as Nodes so
|
|
@@ -625,348 +422,39 @@ module Capybara
|
|
|
625
422
|
@frame_stack.clear
|
|
626
423
|
end
|
|
627
424
|
|
|
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.
|
|
425
|
+
# Capybara::Driver::Base resolves frame_url/frame_title via the top
|
|
426
|
+
# execution context, which always reports the parent document. Resolve
|
|
427
|
+
# them through the iframe element's contentWindow / contentDocument so
|
|
428
|
+
# they reflect the active frame.
|
|
429
|
+
def frame_url
|
|
430
|
+
frame = frame_stack.last
|
|
431
|
+
return current_url unless frame
|
|
637
432
|
|
|
638
|
-
|
|
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
|
|
662
|
-
|
|
663
|
-
# `type` is accepted for the error message only: like Selenium (where
|
|
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)
|
|
683
|
-
end
|
|
684
|
-
|
|
685
|
-
private
|
|
686
|
-
|
|
687
|
-
# Pop the first queued dialog whose message matches the requested
|
|
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
|
|
433
|
+
call_function_on(frame.remote_object_id, FRAME_URL_JS)
|
|
698
434
|
end
|
|
699
435
|
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
end
|
|
436
|
+
def frame_title
|
|
437
|
+
frame = frame_stack.last
|
|
438
|
+
return title unless frame
|
|
704
439
|
|
|
705
|
-
|
|
706
|
-
if last_seen_message
|
|
707
|
-
raise Capybara::ModalNotFound,
|
|
708
|
-
"Unable to find #{type} modal with #{text} - found '#{last_seen_message}' instead."
|
|
709
|
-
end
|
|
710
|
-
raise Capybara::ModalNotFound, "Unable to find modal dialog#{" with #{text}" if text}"
|
|
440
|
+
call_function_on(frame.remote_object_id, FRAME_TITLE_JS)
|
|
711
441
|
end
|
|
712
442
|
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
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
|
|
443
|
+
FRAME_URL_JS = "function() { return this.contentWindow.location.href }"
|
|
444
|
+
FRAME_TITLE_JS = "function() { return this.contentDocument.title }"
|
|
445
|
+
private_constant :FRAME_URL_JS, :FRAME_TITLE_JS
|
|
816
446
|
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
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
|
|
447
|
+
# Internal lifecycle steps defined above near their topical groups —
|
|
448
|
+
# calling them out of order corrupts session state, so they are not API.
|
|
449
|
+
private :create_browser_context, :create_page, :clear_session_state,
|
|
450
|
+
:enable_page_events
|
|
827
451
|
|
|
828
|
-
|
|
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
|
|
452
|
+
private
|
|
857
453
|
|
|
858
454
|
def register_auto_scripts
|
|
859
455
|
page_command("Page.addScriptToEvaluateOnNewDocument", source: AutoScripts::JS)
|
|
860
456
|
end
|
|
861
457
|
|
|
862
|
-
def subscribe_to_console_logs
|
|
863
|
-
logger = @options.logger
|
|
864
|
-
return unless logger
|
|
865
|
-
|
|
866
|
-
on("Runtime.consoleAPICalled") do |params|
|
|
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.
|
|
912
|
-
#
|
|
913
|
-
# On Runtime.executionContextsCleared (navigation), unconditionally set
|
|
914
|
-
# the event: if we navigated away mid-busy state, no further idle signal
|
|
915
|
-
# would ever come from the old context, and we'd block for the full
|
|
916
|
-
# timeout. The new context will signal busy again if Turbo is active.
|
|
917
|
-
def subscribe_to_turbo_signals
|
|
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.
|
|
944
|
-
#
|
|
945
|
-
# Caveat: sending `Network.disable` (e.g. through `driver.network.disable`)
|
|
946
|
-
# also silences this handler — they share the same CDP toggle.
|
|
947
|
-
def subscribe_to_navigation_response
|
|
948
|
-
@last_navigation_response = nil
|
|
949
|
-
@document_request_id = nil
|
|
950
|
-
|
|
951
|
-
on("Network.requestWillBeSent") do |params|
|
|
952
|
-
next unless params["type"] == "Document"
|
|
953
|
-
|
|
954
|
-
@document_request_id = params["requestId"]
|
|
955
|
-
@last_navigation_response = nil
|
|
956
|
-
end
|
|
957
|
-
|
|
958
|
-
on("Network.responseReceived") do |params|
|
|
959
|
-
next unless params["requestId"] == @document_request_id
|
|
960
|
-
|
|
961
|
-
@last_navigation_response = {
|
|
962
|
-
status: params.dig("response", "status"),
|
|
963
|
-
headers: params.dig("response", "headers") || {},
|
|
964
|
-
}
|
|
965
|
-
end
|
|
966
|
-
|
|
967
|
-
command("Network.enable")
|
|
968
|
-
end
|
|
969
|
-
|
|
970
458
|
# Track default-execution-context availability via Runtime events.
|
|
971
459
|
# Lightpanda destroys the V8 default context at navigation start (long
|
|
972
460
|
# before frameNavigated fires), then re-creates it once the new page
|
|
@@ -987,153 +475,6 @@ module Capybara
|
|
|
987
475
|
page_command("Runtime.enable")
|
|
988
476
|
end
|
|
989
477
|
|
|
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
478
|
def close_client_silently
|
|
1138
479
|
@client&.close
|
|
1139
480
|
rescue StandardError
|
|
@@ -1166,102 +507,6 @@ module Capybara
|
|
|
1166
507
|
@process.start
|
|
1167
508
|
end
|
|
1168
509
|
|
|
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
510
|
def monotonic_time
|
|
1266
511
|
::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
1267
512
|
end
|