capybara-lightpanda 0.13.0 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3c435f7a8f49c34c68012f2c34cb4f9766531acbdd61e9e3f70e4ac9395d54e3
4
- data.tar.gz: 11cca4dc77a1b415eff5e0e45796e9f806ee36169c8ad0c672c0947d2281f834
3
+ metadata.gz: d38fc6a0a5cbba01bba3a0a1363d8e0577dc99c2cb8a77b81f407b273a91863b
4
+ data.tar.gz: 21cdea0aeba27736c52f800c22e850d80ea15d3c7bb1c6c9b173421bb227be7d
5
5
  SHA512:
6
- metadata.gz: '07464388df9304ea1f4bf3bbde2c6dfd1c654d787690d47d3d3d62f6f89c22860a24185f14c839979e0d6fb1aebfb030aee97c450894d8da418357341994a4dd'
7
- data.tar.gz: 03d0b12cf3cb0728639faed14706fffce40f1287f65b714f4126d4a4da8063071ac9423e6a1b040e68dfc6473e451d29658944a8bfa4d5db3c462520c0b260c6
6
+ metadata.gz: ec4a2743650df3602c43fc393a82af00c19c5b44d59fab31156aed0b3252e8fb7f3c5f06df1b6e2fe994c9b974a1b21b29707c22c6bff571d4c2b6aca195add4
7
+ data.tar.gz: ba19fbd3c2754eae2b6d3af02584d739eba498b93e0e0ff392f9dc7a9feefdc1af1ff68081cd5b6963ca41c768cf8cebcf3b48b9debd9923e5142b15ac1c9223
data/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.14.1] - 2026-10-12
4
+
5
+ ### Fixed
6
+
7
+ - **A click that loads a new page waits for that page's scripts.** `click` used to return as soon as the new document existed, before its deferred and module scripts had run. Apps load Stimulus, Turbo and Bootstrap that way, so the next action could land on a page whose handlers were not wired yet: a Bootstrap collapse that did not open, a `confirm()` that never showed (`accept_confirm` raised `ModalNotFound`) while the form was submitted anyway, a form posted before its JavaScript was ready. The click now also waits for `DOMContentLoaded`, as Cuprite waits for the load event. `send_keys`, `go_back` and `go_forward` wait the same way when they load a new page. Clicks that don't navigate are as fast as before.
8
+ - **A click on a link that Turbo hands back to the browser waits for the new page.** With Turbo on the page, such a click returned immediately on the page being left, because Turbo reported activity and went idle again before the navigation started.
9
+
10
+ ## [0.14.0] - 2026-10-11
11
+
12
+ ### Fixed
13
+
14
+ - **`evaluate_script` returns `Capybara::Lightpanda::CyclicObject` for a result that references itself, as Cuprite returns `Ferrum::CyclicObject`.** A self-referencing object used to raise `BrowserError: Object reference chain is too long`. So did `evaluate_script("window")` on recent Lightpanda nightlies, and on older builds it came back as a broken element.
15
+ - **`Cookies#load` keeps restoring a file when one cookie is malformed.** Recent Lightpanda nightlies reject a cookie with a `;` or control character in its name or value, as Chrome does, where older builds stored it. A file saved from an older build could hold such a cookie, and `load` stopped at it with a `BrowserError`. It now skips that cookie and names it in its warning, like any other cookie the browser refuses. `Cookies#set` still raises for it.
16
+ - **Clicking a `javascript:` link no longer breaks the page on recent Lightpanda nightlies.** On nightly 10261 and later, clicking `<a href="javascript:void(0)">` (select2, Bootstrap toggles, older jQuery code) left the page unusable: every later `find` or `evaluate_script` raised `NoExecutionContextError` until the next `visit`. The link's code still runs, once, and the page stays.
17
+ - **A click waits for the page it navigates to, even when the server is slow.** `click` used to return after about 50 ms, before a link, `window.location` assignment or form submission had loaded the next page whenever the server took longer than that (a slow action, a redirect). The very next `current_url` or `assert_current_path(..., wait: 0)` then read the page being left. The click now waits for the new page, as in Cuprite. Clicks that don't navigate are as fast as before, and so are `pushState` and anchor clicks. A navigation that fails, such as a refused connection, doesn't make the click wait for the timeout. `send_keys` waits the same way when Enter submits a form.
18
+ - **`go_back` and `go_forward` keep the page when the history entry comes from `pushState`.** They used to reload the document from the server, so the page's JavaScript state was lost and no `popstate` event fired: a "browser back button" test on a step-by-step page could not pass. They now behave like the browser's buttons: the page stays, `popstate` fires and `current_url` is up to date when the call returns. Going back or forward to another page still loads it, and the call waits for it. Lightpanda limit: on Lightpanda 1.0.0 this only applies when the two entries differ by their `#fragment`. When `pushState` changed the path (Turbo Drive, client-side routers), 1.0.0 still reloads the page ([lightpanda-io/browser#3726](https://github.com/lightpanda-io/browser/issues/3726)); nightly builds from 10239 keep it.
19
+ - **Clicking a link inside an iframe navigates the iframe, not the whole page.** Inside `within_frame`, `click_link` used to replace the whole page with the link's target, and a `#fragment` link in a frame did the same. Links with `target="_top"` or `target="_parent"` still leave the frame, as in Chrome.
20
+ - **Scripts run inside the frame within `within_frame`.** `execute_script`, `evaluate_script` and `evaluate_async_script` used to run against the page around the frame, so `document` and `window` were the top page's. They now see the frame's document, as in Chrome.
21
+ - **An element found in an iframe goes stale when that iframe navigates.** It used to keep answering from the old document: empty text, not visible, clicks that did nothing. Capybara never found it again. It is now treated as obsolete, as in Chrome, and Capybara's automatic reload finds the element in the new page.
22
+ - **`select` works in a form that has a field named `disabled`.** On recent Lightpanda nightlies, an `<input name="disabled">` made every option in its form read as disabled, so `select` silently did nothing.
23
+
3
24
  ## [0.13.0] - 2026-10-03
4
25
 
5
26
  > **Update Lightpanda before upgrading.** This release requires Lightpanda **1.0.0** or a nightly build ≥ 9994. The driver refuses anything older and names the version it found. Tagged releases work again: pin `1.0.0` for a reproducible browser.
@@ -33,12 +33,20 @@ module Capybara
33
33
  end
34
34
  alias goto go_to
35
35
 
36
+ # Back/forward go through the page's own History API, like the
37
+ # browser's buttons: an entry created by `pushState` is restored in
38
+ # the same document (JS state kept, `popstate` fired synchronously),
39
+ # while an entry from another document starts a real navigation that
40
+ # `wait_for_idle` sniffs and waits out. CDP's
41
+ # `Page.navigateToHistoryEntry` is not an option — Lightpanda reloads
42
+ # the document even for a `pushState` entry. Both are no-ops at either
43
+ # end of the session history.
36
44
  def back
37
- wait_for_navigation { navigate_history(-1) }
45
+ traverse_history("history.back()")
38
46
  end
39
47
 
40
48
  def forward
41
- wait_for_navigation { navigate_history(+1) }
49
+ traverse_history("history.forward()")
42
50
  end
43
51
 
44
52
  def refresh
@@ -98,23 +106,13 @@ module Capybara
98
106
  await_navigation(&)
99
107
  end
100
108
 
101
- # Step the session history by `offset` (-1 = back, +1 = forward) using
102
- # native CDP. `Page.getNavigationHistory` returns the entry list and
103
- # `currentIndex`; `Page.navigateToHistoryEntry` jumps to the chosen
104
- # entry's `id`. No-op when the offset would step past either end so
105
- # the behavior matches `history.back()` / `history.forward()` on a
106
- # bounded session history.
107
- def navigate_history(offset)
108
- history = page_command("Page.getNavigationHistory")
109
- target_index = history["currentIndex"] + offset
110
- entries = history["entries"]
111
- return if target_index.negative? || target_index >= entries.length
112
-
113
- page_command("Page.navigateToHistoryEntry", entryId: entries[target_index]["id"])
109
+ def traverse_history(expression)
110
+ page_command("Runtime.evaluate", expression: expression)
111
+ wait_for_idle
114
112
  end
115
113
 
116
114
  # Common navigation lifecycle shared by `wait_for_page_load` (fresh
117
- # `Page.navigate`) and `wait_for_navigation` (back / forward / reload).
115
+ # `Page.navigate`) and `wait_for_navigation` (reload).
118
116
  # Subscribes to Page.loadEventFired, runs the trigger, waits briefly for
119
117
  # the event, falls back to readyState polling for the remaining budget.
120
118
  # The handler is unsubscribed via `ensure` so a raising trigger doesn't
@@ -1,7 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "singleton"
4
+
3
5
  module Capybara
4
6
  module Lightpanda
7
+ # Stands in for a script result that has no Ruby representation because it
8
+ # references itself — `window`, or `a.self = a` — so `returnByValue` can't
9
+ # serialize it. Ferrum returns its own `Ferrum::CyclicObject` the same way.
10
+ class CyclicObject
11
+ include Singleton
12
+
13
+ def inspect
14
+ %(#<#{self.class} JavaScript object that cannot be represented in Ruby>)
15
+ end
16
+ end
17
+
5
18
  class Browser
6
19
  # JS evaluation and RemoteObject plumbing: Runtime.evaluate /
7
20
  # callFunctionOn dispatch, result serialization (Ferrum's
@@ -23,7 +36,15 @@ module Capybara
23
36
  # later calls, like the DevTools console) but allows redeclaration.
24
37
  # Completion-value semantics cover a bare expression (`'foo'`), a
25
38
  # `throw` statement, and multi-statement scripts alike.
26
- def evaluate(expression, *args)
39
+ #
40
+ # `frame` (an `<iframe>` Node, `frame_stack.last` inside `within_frame`)
41
+ # runs the script against that frame's globals — see call_with_args.
42
+ # There the script goes through the frame's indirect `eval` rather than
43
+ # REPL mode: same completion-value semantics, `var` still lands on the
44
+ # frame's window, but a top-level `const`/`let` lives only for its call.
45
+ def evaluate(expression, *args, frame: nil)
46
+ return call_with_args(FRAME_EVAL_JS, [expression], frame: frame) if frame && args.empty?
47
+
27
48
  if args.empty?
28
49
  response = page_command("Runtime.evaluate", expression: expression, returnByValue: false,
29
50
  awaitPromise: true, replMode: true)
@@ -33,7 +54,7 @@ module Capybara
33
54
  end
34
55
 
35
56
  wrapped = "function() { return #{expression} }"
36
- call_with_args(wrapped, args)
57
+ call_with_args(wrapped, args, frame: frame)
37
58
  end
38
59
 
39
60
  # Execute JS without returning a value.
@@ -43,7 +64,12 @@ module Capybara
43
64
  # on JS exceptions so silent failures don't mask test bugs (the
44
65
  # previous fast path swallowed them because `awaitPromise: false` was
45
66
  # checked but `exceptionDetails` was not).
46
- def execute(expression, *args)
67
+ def execute(expression, *args, frame: nil)
68
+ if frame && args.empty?
69
+ call_with_args(FRAME_EXECUTE_JS, [expression], return_by_value: true, frame: frame)
70
+ return nil
71
+ end
72
+
47
73
  if args.empty?
48
74
  response = page_command("Runtime.evaluate", expression: expression, returnByValue: false,
49
75
  awaitPromise: false, replMode: true)
@@ -52,7 +78,7 @@ module Capybara
52
78
  end
53
79
 
54
80
  wrapped = "function() { #{expression} }"
55
- call_with_args(wrapped, args, return_by_value: false)
81
+ call_with_args(wrapped, args, return_by_value: false, frame: frame)
56
82
  nil
57
83
  end
58
84
 
@@ -77,7 +103,7 @@ module Capybara
77
103
  # Evaluate async JS with a callback. The user's script receives
78
104
  # the callback as its last argument (`arguments[arguments.length - 1]`),
79
105
  # matching Capybara's evaluate_async_script contract.
80
- def evaluate_async(expression, *args, wait: @options.timeout)
106
+ def evaluate_async(expression, *args, wait: @options.timeout, frame: nil)
81
107
  timeout_ms = (wait * 1000).to_i
82
108
  wrapped = <<~JS
83
109
  function() {
@@ -92,7 +118,7 @@ module Capybara
92
118
  });
93
119
  }
94
120
  JS
95
- call_with_args(wrapped, args)
121
+ call_with_args(wrapped, args, frame: frame)
96
122
  end
97
123
 
98
124
  # Evaluate JS and return a RemoteObject reference (for DOM nodes, arrays).
@@ -142,6 +168,19 @@ module Capybara
142
168
  # itself timed out / failed in transport.
143
169
  end
144
170
 
171
+ # Compiles the declaration (first argument) with the frame's `eval` and
172
+ # applies it to the rest. `this` is the `<iframe>` element.
173
+ FRAME_APPLY_JS = <<~JS
174
+ function(declaration) {
175
+ var fn = this.contentWindow.eval('(' + declaration + ')');
176
+ return fn.apply(null, Array.prototype.slice.call(arguments, 1));
177
+ }
178
+ JS
179
+ # Indirect eval: global code in the frame, like a classic script.
180
+ FRAME_EVAL_JS = "function(source) { return (0, eval)(source); }"
181
+ # No return, so a script ending in a pending Promise isn't awaited.
182
+ FRAME_EXECUTE_JS = "function(source) { (0, eval)(source); }"
183
+
145
184
  private
146
185
 
147
186
  def serialize_argument(arg)
@@ -167,17 +206,32 @@ module Capybara
167
206
  # When `return_by_value: false` (the default) the return value is unwrapped via
168
207
  # `unwrap_call_result` so that DOM nodes come back as `{ "__lightpanda_node__" => ... }`
169
208
  # hashes the Driver can wrap as Capybara nodes.
170
- def call_with_args(function_declaration, args, return_by_value: false)
171
- # document_object_id returns a fresh RemoteObject handle every call.
172
- # Release it on the way out so long-running shared-spec sessions don't
173
- # accumulate orphaned V8 handles between resets.
174
- doc_oid = document_object_id
209
+ #
210
+ # With `frame`, the function is compiled by the frame's own `eval`, so
211
+ # its free names (`window`, `document`, `location`) are the frame's, and
212
+ # called from the `<iframe>` element. Running it in the frame's own
213
+ # execution context instead would not do: Lightpanda runs every
214
+ # callFunctionOn in the top context, so node handles found inside a
215
+ # frame belong to that context and can't be passed into another
216
+ # ("Argument should belong to the same JavaScript world").
217
+ def call_with_args(function_declaration, args, return_by_value: false, frame: nil)
218
+ arguments = args.map { |a| serialize_argument(a) }
219
+ if frame
220
+ target = frame.remote_object_id
221
+ arguments.unshift({ value: function_declaration })
222
+ function_declaration = FRAME_APPLY_JS
223
+ else
224
+ # document_object_id returns a fresh RemoteObject handle every call.
225
+ # Release it on the way out so long-running shared-spec sessions
226
+ # don't accumulate orphaned V8 handles between resets.
227
+ target = doc_oid = document_object_id
228
+ end
175
229
  params = {
176
- objectId: doc_oid,
230
+ objectId: target,
177
231
  functionDeclaration: function_declaration,
178
232
  returnByValue: return_by_value,
179
233
  awaitPromise: true,
180
- arguments: args.map { |a| serialize_argument(a) },
234
+ arguments: arguments,
181
235
  }
182
236
  response = page_command("Runtime.callFunctionOn", **params)
183
237
  raise_on_js_error!("call_with_args", function_declaration, response)
@@ -216,6 +270,11 @@ module Capybara
216
270
  # Re-fetch a remote object as JSON-serializable value for plain objects/arrays.
217
271
  # Cheaper than walking properties and good enough for shared specs. Releases
218
272
  # the original handle so long-lived sessions don't accumulate leaked objectIds.
273
+ #
274
+ # V8 refuses to return a cyclic object by value. Through 1.0.0 every
275
+ # Zig-backed object, `window` included, came back as a node; since
276
+ # upstream #3831 (build 10255) `window` lands here and raised. Both it
277
+ # and a plain self-referencing object now return CyclicObject.
219
278
  def serialize_remote_object(object_id)
220
279
  json = page_command(
221
280
  "Runtime.callFunctionOn",
@@ -224,10 +283,17 @@ module Capybara
224
283
  returnByValue: true
225
284
  )
226
285
  handle_evaluate_response(json, "function() { return this }")
286
+ rescue BrowserError => e
287
+ raise unless CYCLIC_VALUE_ERRORS.any? { |message| e.message.to_s.include?(message) }
288
+
289
+ CyclicObject.instance
227
290
  ensure
228
291
  release_object(object_id)
229
292
  end
230
293
 
294
+ CYCLIC_VALUE_ERRORS = ["Object reference chain is too long", "Object couldn't be returned by value"].freeze
295
+ private_constant :CYCLIC_VALUE_ERRORS
296
+
231
297
  # Walk an array's own indexed properties via `Runtime.getProperties`,
232
298
  # unwrapping each element through the regular result pipeline so that
233
299
  # DOM-node entries surface as `{ "__lightpanda_node__" => ... }` instead
@@ -85,9 +85,10 @@ module Capybara
85
85
  # Selenium's `execute_async_script`: the script receives a completion
86
86
  # callback as its last argument. Same contract as
87
87
  # Driver#evaluate_async_script, minus the DOM-node unwrapping — callers
88
- # on this path (axe-core et al.) hand back plain JSON.
88
+ # on this path (axe-core et al.) hand back plain JSON. Runs in the
89
+ # current frame, as Selenium's does.
89
90
  def execute_async_script(script, *)
90
- evaluate_async(script.to_s.strip, *)
91
+ evaluate_async(script.to_s.strip, *, frame: frame_stack.last)
91
92
  end
92
93
 
93
94
  # Selenium's raw-CDP escape hatch, as used by
@@ -166,6 +166,8 @@ module Capybara
166
166
  subscribe_to_console_logs
167
167
  subscribe_to_console_capture
168
168
  subscribe_to_execution_context
169
+ subscribe_to_navigation
170
+ subscribe_to_document_ready
169
171
  subscribe_to_turbo_signals
170
172
  # Network owns the Network.* domain: enabling installs traffic
171
173
  # tracking AND the navigation-response capture behind status_code.
@@ -423,24 +425,39 @@ module Capybara
423
425
  # (title, current_url, …) without racing the navigation lifecycle.
424
426
  #
425
427
  # Sniff window: the action returns synchronously, but the CDP events
426
- # signalling its async fallout (Runtime.executionContextsCleared for
427
- # full nav; the turbo sentinel for Turbo) arrive later on the dispatch
428
- # thread. We poll briefly for either signal — if neither fires within
429
- # the window, assume the action was inert and exit fast.
428
+ # signalling its async fallout arrive later on the dispatch thread. We
429
+ # poll briefly for either of them — a main-frame navigation start (see
430
+ # subscribe_to_navigation) or Runtime.executionContextsCleared — and if
431
+ # none fires within the window, assume the action was inert and exit
432
+ # fast. Lightpanda announces a navigation ~5 ms after the click but only
433
+ # swaps the execution context once the response lands, so watching the
434
+ # context alone returned on the old document whenever the server took
435
+ # longer than the window. The turbo sentinel does not end the sniff
436
+ # (it is awaited after): Turbo goes busy then idle within a few ms on a
437
+ # link it hands back to the browser, before the navigation start lands.
430
438
  SNIFF_WINDOW = 0.05
431
439
  private_constant :SNIFF_WINDOW
432
440
 
433
441
  def wait_for_idle
434
442
  prior_context_iteration = @default_context_event.iteration
443
+ prior_navigation_iteration = @navigation_event.iteration
435
444
  sniff_deadline = monotonic_time + SNIFF_WINDOW
445
+ navigating = false
436
446
  loop do
447
+ navigating = @navigation_event.iteration > prior_navigation_iteration
448
+ break if navigating
437
449
  break if @default_context_event.iteration > prior_context_iteration
438
- break unless @turbo_event.set?
439
450
  break if monotonic_time > sniff_deadline
440
451
 
441
452
  sleep 0.001
442
453
  end
443
454
 
455
+ # Only a navigation this action started is awaited: one left pending
456
+ # by an earlier load must not make every later click pay the timeout.
457
+ if navigating
458
+ @navigation_event.wait(@options.timeout)
459
+ wait_for_document_ready
460
+ end
444
461
  @default_context_event.wait(@options.timeout)
445
462
  @turbo_event.wait(@options.timeout)
446
463
  check_unhandled_modal!
@@ -607,6 +624,111 @@ module Capybara
607
624
  page_command("Runtime.enable")
608
625
  end
609
626
 
627
+ # Track cross-document navigations of the main frame, from the moment
628
+ # they start until the frame stops loading. Same-document ones
629
+ # (pushState, fragment) emit Page.navigatedWithinDocument instead and
630
+ # never reset the event, so they don't make wait_for_idle block; nor do
631
+ # iframe navigations (other frameId). The main frame shares the
632
+ # target's id.
633
+ #
634
+ # It ends at whichever comes first: the main frame's new default
635
+ # execution context (the new document committed — enough even when
636
+ # the load event never comes, lightpanda-io/browser#1801) or
637
+ # Page.frameStoppedLoading. A navigation whose request fails
638
+ # (connection refused, DNS) emits neither — only Network.loadingFailed for the
639
+ # document request — so that ends it too, keyed by the requestId the
640
+ # main-frame Document request was sent with. Needs Page.enable, which
641
+ # the first go_to turns on; a page that was never visited has no
642
+ # navigation to click into.
643
+ def subscribe_to_navigation
644
+ @navigation_event = Utils::Event.new
645
+ @navigation_event.set
646
+ @navigation_request_id = nil
647
+
648
+ on("Page.frameStartedNavigating") do |params|
649
+ @navigation_event.reset if main_frame_navigation_start?(params)
650
+ end
651
+ on("Network.requestWillBeSent") do |params|
652
+ @navigation_request_id = params["requestId"] if main_frame_document_request?(params)
653
+ end
654
+ on("Page.frameStoppedLoading") do |params|
655
+ @navigation_event.set if params["frameId"] == @target_id
656
+ end
657
+ on("Runtime.executionContextCreated") do |params|
658
+ aux = params.dig("context", "auxData") || {}
659
+ @navigation_event.set if aux["isDefault"] && aux["frameId"] == @target_id
660
+ end
661
+ on("Network.loadingFailed") do |params|
662
+ @navigation_event.set if params["requestId"] == @navigation_request_id
663
+ end
664
+ end
665
+
666
+ # The new document's context exists before its deferred and module
667
+ # scripts have run: this event marks the end of that phase, at
668
+ # Page.domContentEventFired or anything later that proves it is over
669
+ # (load, the frame stopping, the document request failing).
670
+ def subscribe_to_document_ready
671
+ @document_ready_event = Utils::Event.new
672
+ @document_ready_event.set
673
+
674
+ on("Page.frameStartedNavigating") do |params|
675
+ @document_ready_event.reset if main_frame_navigation_start?(params)
676
+ end
677
+ on("Page.domContentEventFired") { @document_ready_event.set }
678
+ on("Page.loadEventFired") { @document_ready_event.set }
679
+ on("Page.frameStoppedLoading") do |params|
680
+ @document_ready_event.set if params["frameId"] == @target_id
681
+ end
682
+ on("Network.loadingFailed") do |params|
683
+ @document_ready_event.set if params["requestId"] == @navigation_request_id
684
+ end
685
+ end
686
+
687
+ # After a click that navigated, the next action must not run before the
688
+ # new page's JS has wired its handlers: Stimulus, Bootstrap's data-api
689
+ # and Turbo load as module scripts, which run after the document commits
690
+ # and before DOMContentLoaded. Cuprite waits for the load event. The
691
+ # event is awaited briefly, then document.readyState is polled for the
692
+ # rest of the budget, as #go_to does (lightpanda-io/browser#1801: page
693
+ # events may never come). Never raises: a page that never settles costs
694
+ # the timeout once, then the action returns as it did before.
695
+ DOCUMENT_READY_EVENT_WAIT = 2
696
+ private_constant :DOCUMENT_READY_EVENT_WAIT
697
+
698
+ def wait_for_document_ready
699
+ deadline = monotonic_time + @options.timeout
700
+ return if @document_ready_event.wait([DOCUMENT_READY_EVENT_WAIT, @options.timeout].min)
701
+
702
+ remaining = deadline - monotonic_time
703
+ return unless remaining.positive?
704
+
705
+ Utils::Wait.until(timeout: remaining, interval: 0.1) do
706
+ @document_ready_event.set? || @client.closed? || document_complete?
707
+ end
708
+ rescue TimeoutError
709
+ nil
710
+ end
711
+
712
+ def document_complete?
713
+ response = @client.command(
714
+ "Runtime.evaluate",
715
+ { expression: "document.readyState", returnByValue: true },
716
+ session_id: @session_id,
717
+ timeout: 2
718
+ )
719
+ response.dig("result", "value") == "complete"
720
+ rescue Error
721
+ false
722
+ end
723
+
724
+ def main_frame_navigation_start?(params)
725
+ params["frameId"] == @target_id && !params["navigationType"].to_s.match?(/same/i)
726
+ end
727
+
728
+ def main_frame_document_request?(params)
729
+ params["type"] == "Document" && params["frameId"] == @target_id
730
+ end
731
+
610
732
  def close_client_silently
611
733
  @client&.close
612
734
  rescue StandardError
@@ -83,7 +83,10 @@ module Capybara
83
83
  # Lightpanda answers `{success: false}` and drops the cookie for an
84
84
  # invalid combination — notably `same_site: "None"` without `secure`
85
85
  # since upstream #3529 (build 9877), Chrome's rule — so callers that
86
- # need it to stick should check the result.
86
+ # need it to stick should check the result. A malformed field (a `;` or
87
+ # control character in the name or value, surrounding whitespace) raises
88
+ # BrowserError instead: since upstream #3791 (build 10212) the browser
89
+ # answers `-32602 Sanitizing cookie failed`, as Chrome does.
87
90
  def set(name:, value:, domain: nil, path: "/", secure: false, http_only: false, # rubocop:disable Metrics/ParameterLists, Naming/PredicateMethod
88
91
  same_site: nil, expires: nil)
89
92
  params = {
@@ -126,8 +129,10 @@ module Capybara
126
129
  # CDP requires either domain or url for each cookie; entries from `store`
127
130
  # already include domain, so they round-trip cleanly. Returns true on
128
131
  # success (intentionally not a predicate — mirrors ferrum's API). A
129
- # cookie the browser refuses is reported on stderr rather than raised,
130
- # so one bad entry doesn't abort restoring the rest.
132
+ # cookie the browser refuses — `{success: false}`, or a malformed field
133
+ # it rejects outright — is reported on stderr rather than raised, so one
134
+ # bad entry doesn't abort restoring the rest. Builds before 10212 stored
135
+ # malformed cookies, so files saved from them can hold such entries.
131
136
  def load(path = "cookies.yml") # rubocop:disable Naming/PredicateMethod
132
137
  cookies = YAML.load_file(path)
133
138
  rejected = cookies.reject { |c| restore_cookie(c) }.map { |c| c.transform_keys(&:to_s)["name"] }
@@ -153,9 +158,22 @@ module Capybara
153
158
  params[:same_site] = same_site if same_site
154
159
  exp = attrs["expires"]
155
160
  params[:expires] = Time.at(exp) if exp.is_a?(Numeric) && exp.positive?
161
+ set_or_refuse(**params)
162
+ end
163
+
164
+ def set_or_refuse(**params)
156
165
  set(**params)
166
+ rescue BrowserError => e
167
+ raise unless e.code == INVALID_PARAMS
168
+
169
+ false
157
170
  end
158
171
 
172
+ # Chrome's (and, since upstream #3791, Lightpanda's) answer to a cookie
173
+ # whose fields fail sanitizing.
174
+ INVALID_PARAMS = -32_602
175
+ private_constant :INVALID_PARAMS
176
+
159
177
  # Lightpanda below build 9877 reported every cookie without a SameSite
160
178
  # attribute as "None", so a file stored then is full of insecure
161
179
  # SameSite=None cookies — which the browser now rejects (Chrome's rule,
@@ -69,7 +69,7 @@ module Capybara
69
69
 
70
70
  def active_element
71
71
  oid = browser.active_element
72
- oid && Node.new(self, oid)
72
+ oid && build_node(oid)
73
73
  end
74
74
 
75
75
  # Capybara's Session#send_keys routes to Driver#send_keys; Cuprite's pattern
@@ -80,25 +80,28 @@ module Capybara
80
80
 
81
81
  def find_xpath(selector)
82
82
  object_ids = browser.find("xpath", selector)
83
- object_ids.map { |oid| Node.new(self, oid) }
83
+ object_ids.map { |oid| build_node(oid) }
84
84
  end
85
85
 
86
86
  def find_css(selector)
87
87
  object_ids = browser.find("css", selector)
88
- object_ids.map { |oid| Node.new(self, oid) }
88
+ object_ids.map { |oid| build_node(oid) }
89
89
  end
90
90
 
91
+ # Inside `within_frame` these run in the frame's document, as in Chrome.
91
92
  def evaluate_script(script, *args)
92
- unwrap_script_result(browser.evaluate(script.strip, *native_args(args)))
93
+ unwrap_script_result(browser.evaluate(script.strip, *native_args(args),
94
+ frame: browser.frame_stack.last))
93
95
  end
94
96
 
95
97
  def execute_script(script, *args)
96
- browser.execute(script.strip, *native_args(args))
98
+ browser.execute(script.strip, *native_args(args), frame: browser.frame_stack.last)
97
99
  nil
98
100
  end
99
101
 
100
102
  def evaluate_async_script(script, *args)
101
- unwrap_script_result(browser.evaluate_async(script.strip, *native_args(args)))
103
+ unwrap_script_result(browser.evaluate_async(script.strip, *native_args(args),
104
+ frame: browser.frame_stack.last))
102
105
  end
103
106
 
104
107
  # -- Network Inspection --
@@ -369,6 +372,12 @@ module Capybara
369
372
 
370
373
  private
371
374
 
375
+ # A node found while a frame is switched to lives in that frame's
376
+ # document, which Node#call checks for a frame navigation.
377
+ def build_node(oid)
378
+ Node.new(self, oid, frame_scoped: browser.frame_stack.any?)
379
+ end
380
+
372
381
  # Every window method takes a handle because Capybara's Window objects
373
382
  # carry one; with a single window the only valid value is the current
374
383
  # target id. Reject anything else rather than silently operating on the
@@ -414,7 +423,7 @@ module Capybara
414
423
  when Array then value.map { |v| unwrap_script_result(v) }
415
424
  when Hash
416
425
  if value.size == 1 && value.key?(Browser::NODE_MARKER)
417
- Node.new(self, value[Browser::NODE_MARKER])
426
+ build_node(value[Browser::NODE_MARKER])
418
427
  else
419
428
  value.transform_values { |v| unwrap_script_result(v) }
420
429
  end
@@ -68,7 +68,9 @@ function isDisabled(el) {
68
68
  if ((el.tagName || '').toUpperCase() === 'OPTION') {
69
69
  var p = el.parentElement;
70
70
  while (p) {
71
- if (p.disabled) return true;
71
+ // `=== true`: a <form>'s `disabled` is named field access, so
72
+ // `<input name="disabled">` makes `form.disabled` that input.
73
+ if (p.disabled === true) return true;
72
74
  p = p.parentElement;
73
75
  }
74
76
  }
@@ -8,9 +8,13 @@ module Capybara
8
8
 
9
9
  attr_reader :remote_object_id
10
10
 
11
- def initialize(driver, remote_object_id)
12
- super
11
+ # `frame_scoped`: the node lives in an iframe's document — it was found
12
+ # while a frame was switched to, or from another frame-scoped node. See
13
+ # #wrap_with_attached_guard for what that changes.
14
+ def initialize(driver, remote_object_id, frame_scoped: false)
15
+ super(driver, remote_object_id)
13
16
  @remote_object_id = remote_object_id
17
+ @frame_scoped = frame_scoped
14
18
  end
15
19
 
16
20
  # Capybara::Driver::Node#native returns the constructor's second argument,
@@ -265,12 +269,9 @@ module Capybara
265
269
  # absence then showed up as `current_url` being read against the
266
270
  # outgoing document (test/features/keyboard_activation_test.rb).
267
271
  #
268
- # Caveat, shared with #click: `wait_for_idle` only sniffs for ~50 ms
269
- # (Browser::SNIFF_WINDOW) and watches Runtime.executionContextsCleared,
270
- # which Lightpanda emits when the *response* lands, not when the request
271
- # starts. A navigation whose server round-trip exceeds that window is
272
- # therefore still not awaited here — use Capybara's waiting matchers
273
- # (`have_current_path`) for those, as you would after a click.
272
+ # `wait_for_idle` sniffs for ~50 ms (Browser::SNIFF_WINDOW) for the
273
+ # main frame to *start* navigating, then waits for the new document —
274
+ # so a slow server round-trip is awaited, as after a click.
274
275
  def send_keys(*)
275
276
  call(SEND_KEYS_FOCUS_JS)
276
277
  driver.browser.keyboard.type(*)
@@ -320,17 +321,17 @@ module Capybara
320
321
  # returned as Lightpanda::Node wrappers. Mirrors Cuprite's `Node#parents`.
321
322
  def parents
322
323
  oids = driver.browser.parents_of(@remote_object_id)
323
- oids.map { |oid| self.class.new(driver, oid) }
324
+ oids.map { |oid| self.class.new(driver, oid, frame_scoped: @frame_scoped) }
324
325
  end
325
326
 
326
327
  def find_xpath(selector)
327
328
  object_ids = driver.browser.find_within(@remote_object_id, "xpath", selector)
328
- object_ids.map { |oid| self.class.new(driver, oid) }
329
+ object_ids.map { |oid| self.class.new(driver, oid, frame_scoped: @frame_scoped) }
329
330
  end
330
331
 
331
332
  def find_css(selector)
332
333
  object_ids = driver.browser.find_within(@remote_object_id, "css", selector)
333
- object_ids.map { |oid| self.class.new(driver, oid) }
334
+ object_ids.map { |oid| self.class.new(driver, oid, frame_scoped: @frame_scoped) }
334
335
  end
335
336
 
336
337
  # Equality compares the underlying DOM node via backendNodeId, the only
@@ -515,7 +516,9 @@ module Capybara
515
516
  # instead of returning stale data from V8's still-live reference. After
516
517
  # a DOM mutation like `replaceWith`, the cached objectId still resolves
517
518
  # to the detached node, so reads succeed quietly and Capybara's
518
- # automatic_reload never re-runs the original query.
519
+ # automatic_reload never re-runs the original query. A frame-scoped node
520
+ # whose document has lost its window is obsolete too (see
521
+ # DISCARDED_DOCUMENT_CHECK).
519
522
  def call(function_declaration, *args, return_by_value: true)
520
523
  guarded = wrap_with_attached_guard(function_declaration)
521
524
  driver.browser.with_default_context_wait do
@@ -531,12 +534,21 @@ module Capybara
531
534
  end
532
535
 
533
536
  OBSOLETE_NODE_MARKER = "LIGHTPANDA_OBSOLETE_NODE"
534
- private_constant :OBSOLETE_NODE_MARKER
537
+
538
+ # When an iframe navigates, Chrome destroys the old document's context,
539
+ # so calls on its nodes fail and Capybara re-finds them. Lightpanda keeps
540
+ # the context alive: the node stays `isConnected` and answers from the
541
+ # discarded document, whose `defaultView` is null. Only for frame-scoped
542
+ # nodes: a script-built document (DOMParser, createHTMLDocument) has no
543
+ # window either, and its nodes stay usable. `|| this` covers a Document
544
+ # node, whose ownerDocument is null.
545
+ DISCARDED_DOCUMENT_CHECK = " || (this.ownerDocument || this).defaultView === null"
546
+ private_constant :OBSOLETE_NODE_MARKER, :DISCARDED_DOCUMENT_CHECK
535
547
 
536
548
  def wrap_with_attached_guard(function_declaration)
537
549
  <<~JS
538
550
  function() {
539
- if (!this.isConnected) throw new Error(#{OBSOLETE_NODE_MARKER.inspect});
551
+ if (!this.isConnected#{DISCARDED_DOCUMENT_CHECK if @frame_scoped}) throw new Error(#{OBSOLETE_NODE_MARKER.inspect});
540
552
  return (#{function_declaration}).apply(this, arguments);
541
553
  }
542
554
  JS
@@ -629,22 +641,36 @@ module Capybara
629
641
  var notCancelled = hit.dispatchEvent(clickEvt);
630
642
  if (!notCancelled || clickEvt.defaultPrevented) return;
631
643
  var tag = hit.tagName;
644
+ // A `javascript:` link never navigates: the click's own activation
645
+ // behavior has already run its code. Assigning it to location.href
646
+ // makes Lightpanda request `http://javascript:…`, and since upstream
647
+ // #3843 (build 10261) that failed navigation leaves the page with no
648
+ // execution context — `href="javascript:void(0)"` toggles (select2,
649
+ // Bootstrap) killed the page (lightpanda-io/browser#3898).
650
+ if (tag === 'A' && /^\\s*javascript:/i.test(hit.getAttribute('href') || '')) return;
632
651
  if (tag === 'A' && hit.href && hit.target !== '_blank') {
652
+ // Navigate the link's own window, never the bare `window`:
653
+ // Lightpanda runs callFunctionOn in the top document's context
654
+ // even for a node inside an iframe, so `window` would be the top
655
+ // window and a link clicked in a frame replaced the whole page.
656
+ var own = hit.ownerDocument.defaultView || window;
657
+ var target = (hit.target || '').toLowerCase();
658
+ var win = (target === '_top' && own.top) || (target === '_parent' && own.parent) || own;
633
659
  // Same-document fragment-only navigation: just update hash (or do
634
660
  // nothing if identical). Mirrors Chrome — assigning location.href
635
661
  // to a same-document URL on Lightpanda triggers a real navigation
636
662
  // tick that cancels pending setTimeout callbacks and clears form
637
663
  // values, which breaks any test driving DOM updates from a click
638
664
  // handler on `<a href="#...">`.
639
- var dest = new URL(hit.href, document.baseURI);
640
- var here = new URL(window.location.href);
665
+ var dest = new URL(hit.href, hit.ownerDocument.baseURI);
666
+ var here = new URL(win.location.href);
641
667
  if (dest.origin === here.origin && dest.pathname === here.pathname &&
642
668
  dest.search === here.search) {
643
669
  if (dest.hash !== here.hash) {
644
- window.location.hash = dest.hash;
670
+ win.location.hash = dest.hash;
645
671
  }
646
672
  } else {
647
- window.location.href = hit.href;
673
+ win.location.href = hit.href;
648
674
  }
649
675
  }
650
676
  }
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Capybara
4
4
  module Lightpanda
5
- VERSION = "0.13.0"
5
+ VERSION = "0.14.1"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: capybara-lightpanda
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.13.0
4
+ version: 0.14.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Navid Emad