capybara-lightpanda 0.11.0 → 0.13.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 +40 -0
- data/README.md +1 -1
- data/lib/capybara/lightpanda/browser/console.rb +17 -4
- data/lib/capybara/lightpanda/browser.rb +83 -7
- data/lib/capybara/lightpanda/client/web_socket.rb +44 -21
- data/lib/capybara/lightpanda/client.rb +25 -6
- data/lib/capybara/lightpanda/cookies.rb +27 -5
- data/lib/capybara/lightpanda/javascripts/attach.js +1 -0
- data/lib/capybara/lightpanda/javascripts/predicates.js +38 -1
- data/lib/capybara/lightpanda/keyboard.rb +23 -5
- data/lib/capybara/lightpanda/network.rb +31 -4
- data/lib/capybara/lightpanda/node.rb +74 -10
- data/lib/capybara/lightpanda/options.rb +6 -7
- data/lib/capybara/lightpanda/process.rb +144 -55
- data/lib/capybara/lightpanda/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3c435f7a8f49c34c68012f2c34cb4f9766531acbdd61e9e3f70e4ac9395d54e3
|
|
4
|
+
data.tar.gz: 11cca4dc77a1b415eff5e0e45796e9f806ee36169c8ad0c672c0947d2281f834
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: '07464388df9304ea1f4bf3bbde2c6dfd1c654d787690d47d3d3d62f6f89c22860a24185f14c839979e0d6fb1aebfb030aee97c450894d8da418357341994a4dd'
|
|
7
|
+
data.tar.gz: 03d0b12cf3cb0728639faed14706fffce40f1287f65b714f4126d4a4da8063071ac9423e6a1b040e68dfc6473e451d29658944a8bfa4d5db3c462520c0b260c6
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,45 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.13.0] - 2026-10-03
|
|
4
|
+
|
|
5
|
+
> **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.
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
|
|
9
|
+
- **Cross-origin requests follow CORS, as in Chrome.** Lightpanda 1.0.0 enforces CORS by default, and the driver doesn't turn it off, so a `fetch` or XHR your app sends from `127.0.0.1` to `localhost` (or to another port) is blocked there just as Chrome would block it. Suites already green on Cuprite or Selenium are unaffected. A blocked request is listed in `network.traffic` with an `:error` and no response, and it no longer stalls `wait_for_network_idle` (see Fixed).
|
|
10
|
+
- **`Cookies#set` returns whether the browser accepted the cookie.** Lightpanda now refuses `SameSite=None` without `Secure`. `set` returns `false` in that case instead of dropping the cookie silently. `Cookies#load` repairs cookie files saved by older versions, which marked every cookie `SameSite=None`, and warns about any cookie it still can't restore.
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- **Clicking a field focuses it again.** On current Lightpanda builds, `click` followed by `send_keys` sent the keys to the page body instead of the clicked field. Clicking a field, a label or a contenteditable region now focuses it, and clicking empty space blurs the focused element, as in Chrome. Capybara's `active_element` specs now pass.
|
|
15
|
+
- **`send_keys` appends to a field's existing value.** On a field that was pre-filled and not yet focused, `send_keys("x")` produced `"xfoo"` instead of `"foox"`. Typing now starts at the end of the value, as in chromedriver. If you need to type at a specific position, focus the field and place the caret yourself first.
|
|
16
|
+
- **Backspace, Delete, Tab, Escape and the arrow keys reach the page.** Previously only keys that type a character were delivered, so these keys did nothing. They now edit fields, move the caret and extend the selection with Shift. A `beforeinput` handler can cancel an edit, which input-mask libraries rely on.
|
|
17
|
+
- **Ctrl/Cmd shortcuts no longer type a character.** `send_keys([:ctrl, "a"])` used to insert a stray `a`. Your shortcut handlers still receive the key.
|
|
18
|
+
- **`select` never picks a disabled option, and picks the right one when values repeat.** When several options shared the same value, the first one was always selected.
|
|
19
|
+
- **Failed requests no longer stall `wait_for_network_idle`.** A request that failed without a response (blocked by CORS, refused connection) stayed pending until the timeout expired.
|
|
20
|
+
- **`console_logs` shows objects.** `console.log({a: 1})`, arrays and DOM elements used to appear as empty text. They now appear as a short description.
|
|
21
|
+
- **A browser crash no longer takes your test process down with it.** If the connection died unexpectedly, the error could be raised somewhere unrelated in your test, or abort a server before its cleanup ran. Commands now fail cleanly with `DeadBrowserError`, waiting commands are released immediately, and each crashed browser releases its connection instead of keeping it open until garbage collection.
|
|
22
|
+
- **`matchMedia` listeners fire when you resize the window.** `resize_window_to` now runs the JavaScript breakpoint handlers. Stylesheet `@media` rules still only apply after the next `visit`.
|
|
23
|
+
|
|
24
|
+
## [0.12.0] - 2026-08-28
|
|
25
|
+
|
|
26
|
+
> **Update Lightpanda before upgrading.** This release requires a Lightpanda nightly build ≥ 8875, and the rolling `nightly` tag now carries it (8982 as of 2026-08-28), so updating the browser is all it takes. **No tagged Lightpanda release is new enough yet**: 0.3.7 is below the floor, so version pinning stays unavailable until Lightpanda ships its next release.
|
|
27
|
+
|
|
28
|
+
### Added
|
|
29
|
+
|
|
30
|
+
- **The keyboard activates things.** Pressing Enter on a button, link or submit input — and Space on a button, checkbox or radio — now does what it does in a real browser: the control activates, forms submit, checkboxes toggle. Previously `send_keys(:enter)` and `send_keys(:space)` were silently ignored on those controls, so keyboard-accessibility specs passed by doing nothing. Printable keys also emit `keypress` now, and a handler that cancels `keypress` correctly suppresses Enter's implicit form submission.
|
|
31
|
+
- **A closed `<dialog>` is properly hidden.** Text and controls inside a `<dialog>` that has never been opened no longer match Capybara's default visibility filter. Suites using the `<dialog>`-as-confirmation idiom (Turbo, Spree 5's admin) could previously "find" and click a confirm button that was not on screen — passing where Chrome would have raised `ElementNotFound`.
|
|
32
|
+
- **Uppercase inline styles hide elements.** `style="display: NONE"` and `style="visibility: HIDDEN"` are now treated as hidden, matching the CSS spec's case-insensitive keywords. Legacy templates and CMS output that upcase keywords no longer read as visible.
|
|
33
|
+
- **`ws_url:` accepts `localhost`.** Connecting to an externally-managed Lightpanda over `ws://localhost:PORT/` works; previously only an IP literal such as `ws://127.0.0.1:PORT/` was accepted by the browser's handshake.
|
|
34
|
+
|
|
35
|
+
### Fixed
|
|
36
|
+
|
|
37
|
+
- **Keyboard-triggered navigation is waited for.** Now that Enter activates a submit button, pressing it starts a real navigation — but `send_keys` returned without waiting for it, so the very next `current_url` (or any non-waiting assertion) could still report the previous page. Keystrokes now settle the page the same way a click does. As with a click, a navigation whose server round-trip is slow still needs one of Capybara's waiting matchers, such as `have_current_path`.
|
|
38
|
+
|
|
39
|
+
- **Iframes keep working when the browser stops loading them by default.** Lightpanda build 8946 turns off `<iframe>` and Web Worker loading unless asked: the parser still puts the iframe in the DOM, but no frame is ever created, so `within_frame` and `switch_to_frame` find nothing and report no cause. The driver now asks for both on every session, which holds today's behavior across the change and needs no newer browser — the request is understood by every build the gem supports. Nothing to configure. If you run the rolling nightly, take this before your browser passes 8946.
|
|
40
|
+
|
|
41
|
+
- **A browser you manage yourself is version-checked like one the gem spawns.** `ws_url:` was the only way into the driver that skipped the version floor: a Lightpanda too old for the gem connected happily, then failed further along as a CDP error naming neither the version nor the option that let it through. The driver now reads the version over CDP at connect and refuses anything below the floor, with the same message the spawn path produces. An endpoint that cannot report a version at all — a Lightpanda older than the command, or a Chrome pointed at by mistake — is refused for the same reason. `page.driver.browser.version`, `.nightly_build` and `.release` report the browser on this path now instead of returning `nil`.
|
|
42
|
+
|
|
3
43
|
## [0.11.0] - 2026-08-25
|
|
4
44
|
|
|
5
45
|
> **Update Lightpanda before upgrading.** This release requires a Lightpanda nightly build ≥ 8796 (2026-08-25 or newer). **No tagged Lightpanda release is new enough yet**: 0.3.7 is below the floor and is now refused, so version pinning is unavailable until Lightpanda ships its next release — CI runs on the rolling nightly in the meantime.
|
data/README.md
CHANGED
|
@@ -57,7 +57,7 @@ driven_by :lightpanda
|
|
|
57
57
|
> Lightpanda is a headless agentic browser, not a layout engine. External `<link rel="stylesheet">` **are** fetched and applied (the gem enables this by default), and `@media` / `window.matchMedia()` evaluate against the `window_size` you configure — so a mobile-only CTA gated by `@media (max-width: …)` resolves at the width you ask for. What's missing is *layout*: nothing reflows, `getBoundingClientRect` stays synthetic, and there is no real scroll. Specs that assert on pixel geometry, scrolling, or screenshots — plus the two that catch people out, a second browser tab and a menu revealed purely by CSS `:hover` — should stay on Cuprite (or whichever full-browser driver you were already using). The [per-spec dual-driver setup](https://navidemad.github.io/capybara-lightpanda/docs/#dual-per-spec) routes that minority to Cuprite and the structural majority to Lightpanda for speed.
|
|
58
58
|
|
|
59
59
|
> [!TIP]
|
|
60
|
-
> For reproducible CI, pin the browser: `Capybara::Lightpanda::Binary.required_version = "0.
|
|
60
|
+
> For reproducible CI, pin the browser: `Capybara::Lightpanda::Binary.required_version = "1.0.0"` (the tagged release the gem's current floor is pinned to — nightly build 9994 is that release's commit). Without a pin the driver tracks Lightpanda's rolling `nightly` tag, which moves under you. See [Pinning the browser version](https://navidemad.github.io/capybara-lightpanda/docs/#pinning).
|
|
61
61
|
|
|
62
62
|
## Credits
|
|
63
63
|
|
|
@@ -59,14 +59,27 @@ module Capybara
|
|
|
59
59
|
|
|
60
60
|
on("Runtime.consoleAPICalled") do |params|
|
|
61
61
|
params["args"]&.each do |r|
|
|
62
|
-
|
|
63
|
-
next if driver_sentinel?(value)
|
|
62
|
+
next if driver_sentinel?(r["value"])
|
|
64
63
|
|
|
65
|
-
logger.puts(
|
|
64
|
+
logger.puts(console_arg_text(r))
|
|
66
65
|
end
|
|
67
66
|
end
|
|
68
67
|
end
|
|
69
68
|
|
|
69
|
+
# One console argument as text. Since upstream #3504 (build 9414)
|
|
70
|
+
# Lightpanda sends `value` for primitives only — an object, array or
|
|
71
|
+
# DOM node arrives as a RemoteObject with a `description` (sometimes
|
|
72
|
+
# empty), a `className` and a `type`. Reading `value` alone printed
|
|
73
|
+
# objects as blank lines in the logger and DOM nodes as "" in
|
|
74
|
+
# console_logs; fall through to the first non-empty descriptor,
|
|
75
|
+
# the way DevTools labels an object it can't preview.
|
|
76
|
+
def console_arg_text(arg)
|
|
77
|
+
return arg["value"].to_s if arg.key?("value")
|
|
78
|
+
|
|
79
|
+
[arg["unserializableValue"], arg["description"], arg["className"], arg["type"]]
|
|
80
|
+
.find { |v| v.is_a?(String) && !v.empty? }.to_s
|
|
81
|
+
end
|
|
82
|
+
|
|
70
83
|
TURBO_SENTINEL_PREFIX = "__lightpanda_turbo_"
|
|
71
84
|
private_constant :TURBO_SENTINEL_PREFIX
|
|
72
85
|
|
|
@@ -117,7 +130,7 @@ module Capybara
|
|
|
117
130
|
|
|
118
131
|
entry = {
|
|
119
132
|
type: params["type"],
|
|
120
|
-
text: args.map { |a| a
|
|
133
|
+
text: args.map { |a| console_arg_text(a) }.join(" "),
|
|
121
134
|
timestamp: params["timestamp"],
|
|
122
135
|
args: args,
|
|
123
136
|
}
|
|
@@ -73,28 +73,34 @@ module Capybara
|
|
|
73
73
|
end
|
|
74
74
|
end
|
|
75
75
|
|
|
76
|
-
# Lightpanda
|
|
77
|
-
# parsed
|
|
78
|
-
# the
|
|
76
|
+
# Lightpanda version string (e.g. "1.0.0-nightly.8925+a7bda0ea5") and its
|
|
77
|
+
# parsed build number. Learned from `lightpanda version` at Process
|
|
78
|
+
# startup on the spawn path, and from the CDP `LP.version` command when
|
|
79
|
+
# connecting to an externally-managed browser via ws_url — so these are
|
|
80
|
+
# populated either way, and a caller inspecting them does not have to
|
|
81
|
+
# know which path built the session.
|
|
79
82
|
def version
|
|
80
|
-
@process&.version
|
|
83
|
+
@process&.version || @remote_version
|
|
81
84
|
end
|
|
82
85
|
|
|
83
86
|
# Set on the nightly/dev channel only; nil for a tagged release, which
|
|
84
87
|
# carries no build counter. `release` is its mirror image — exactly one of
|
|
85
|
-
# the two is non-nil once the
|
|
88
|
+
# the two is non-nil once the session is up.
|
|
86
89
|
def nightly_build
|
|
87
|
-
@process&.nightly_build
|
|
90
|
+
@process&.nightly_build || @remote_nightly_build
|
|
88
91
|
end
|
|
89
92
|
|
|
90
93
|
def release
|
|
91
|
-
@process&.release
|
|
94
|
+
@process&.release || @remote_release
|
|
92
95
|
end
|
|
93
96
|
|
|
94
97
|
def initialize(options = {})
|
|
95
98
|
@options = Options.new(options)
|
|
96
99
|
@process = nil
|
|
97
100
|
@client = nil
|
|
101
|
+
@remote_version = nil
|
|
102
|
+
@remote_nightly_build = nil
|
|
103
|
+
@remote_release = nil
|
|
98
104
|
@target_id = nil
|
|
99
105
|
@session_id = nil
|
|
100
106
|
@browser_context_id = nil
|
|
@@ -121,6 +127,7 @@ module Capybara
|
|
|
121
127
|
|
|
122
128
|
if @options.ws_url?
|
|
123
129
|
@client = Client.new(@options.ws_url, @options)
|
|
130
|
+
check_remote_version
|
|
124
131
|
else
|
|
125
132
|
@process = Process.new(@options)
|
|
126
133
|
@process.start
|
|
@@ -152,6 +159,9 @@ module Capybara
|
|
|
152
159
|
attach_result = @client.command("Target.attachToTarget", { targetId: @target_id, flatten: true })
|
|
153
160
|
@session_id = attach_result["sessionId"]
|
|
154
161
|
|
|
162
|
+
# Must precede the first navigation: sub-resource loading is a
|
|
163
|
+
# per-BrowserContext setting read while the document loads.
|
|
164
|
+
configure_loading
|
|
155
165
|
@turbo_event.set
|
|
156
166
|
subscribe_to_console_logs
|
|
157
167
|
subscribe_to_console_capture
|
|
@@ -498,6 +508,34 @@ module Capybara
|
|
|
498
508
|
|
|
499
509
|
private
|
|
500
510
|
|
|
511
|
+
# Keep iframes and workers loading.
|
|
512
|
+
#
|
|
513
|
+
# Upstream #3305 (build 8946) turned `Config.LoadResources` into a
|
|
514
|
+
# four-field struct defaulting every sub-resource to false, so
|
|
515
|
+
# `Frame.iframeAddedCallback` now returns early unless `iframe` is set:
|
|
516
|
+
# the parser still registers the `<iframe>` in the DOM, but no child
|
|
517
|
+
# frame is ever created. That silently breaks `switch_to_frame` and
|
|
518
|
+
# Capybara's `within_frame` — core driver API, not an optional extra —
|
|
519
|
+
# and there is no CLI opt-in the gem can use below 8946, because the
|
|
520
|
+
# `--load-resources` *value* `iframe` only parses from that build.
|
|
521
|
+
#
|
|
522
|
+
# The `subFrame`/`worker` params predate #3305 and simply write a
|
|
523
|
+
# different field after it, which makes this the one form that spans the
|
|
524
|
+
# whole supported range: a no-op below 8946 where both already defaulted
|
|
525
|
+
# on, load-bearing above it, and no MINIMUM_NIGHTLY_BUILD bump either way.
|
|
526
|
+
#
|
|
527
|
+
# Workers are included to hold the gem's behavior still across the flip
|
|
528
|
+
# rather than to satisfy a feature we ship: a worker that silently never
|
|
529
|
+
# runs is the same debugging trap as an iframe that silently never loads.
|
|
530
|
+
#
|
|
531
|
+
# Not rescued on purpose. LP.configureLoading predates the floor by a
|
|
532
|
+
# wide margin, so a failure here means the endpoint is not a Lightpanda
|
|
533
|
+
# we understand, and swallowing it would restore the exact silence this
|
|
534
|
+
# call exists to prevent.
|
|
535
|
+
def configure_loading
|
|
536
|
+
page_command("LP.configureLoading", subFrame: true, worker: true)
|
|
537
|
+
end
|
|
538
|
+
|
|
501
539
|
def register_auto_scripts
|
|
502
540
|
page_command("Page.addScriptToEvaluateOnNewDocument", source: AutoScripts::JS)
|
|
503
541
|
end
|
|
@@ -590,6 +628,44 @@ module Capybara
|
|
|
590
628
|
end
|
|
591
629
|
end
|
|
592
630
|
|
|
631
|
+
# Binary.update_hint would be a lie here: it prints a curl into a path the
|
|
632
|
+
# gem controls, and under ws_url the browser is somebody else's process on
|
|
633
|
+
# possibly another host.
|
|
634
|
+
REMOTE_UPDATE_HINT = "the gem does not manage that browser — " \
|
|
635
|
+
"update the Lightpanda serving ws_url yourself."
|
|
636
|
+
private_constant :REMOTE_UPDATE_HINT
|
|
637
|
+
|
|
638
|
+
# The spawn path runs `lightpanda version` before the browser is even up
|
|
639
|
+
# (Process#check_minimum_version). An externally-managed browser has no
|
|
640
|
+
# binary to shell out to, so the same floor is enforced over CDP instead:
|
|
641
|
+
# `LP.version` returns the identical string the CLI prints, and
|
|
642
|
+
# Process.check_version! is the one parser both channels share.
|
|
643
|
+
#
|
|
644
|
+
# Without this, `ws_url:` was the one way into the driver that skipped the
|
|
645
|
+
# floor entirely — an old browser connected happily and then failed later
|
|
646
|
+
# as an unrelated-looking CDP error, with nothing naming the real cause.
|
|
647
|
+
#
|
|
648
|
+
# A browser that cannot answer `LP.version` is refused rather than assumed
|
|
649
|
+
# new enough, matching the spawn path's rule that an unidentifiable binary
|
|
650
|
+
# never passes. Two things land here: a Lightpanda predating the LP domain,
|
|
651
|
+
# and a Chrome someone pointed ws_url at by mistake — the second being easy
|
|
652
|
+
# to do and previously diagnosable only by watching later commands fail.
|
|
653
|
+
#
|
|
654
|
+
# Only #start calls this. #reconnect deliberately does not: it re-dials the
|
|
655
|
+
# same endpoint, whose version cannot have changed, and the crash-recovery
|
|
656
|
+
# path should not spend a round-trip re-asking.
|
|
657
|
+
def check_remote_version
|
|
658
|
+
result = @client.command("LP.version")
|
|
659
|
+
@remote_version, @remote_nightly_build, @remote_release =
|
|
660
|
+
Process.check_version!(result["version"]) { REMOTE_UPDATE_HINT }
|
|
661
|
+
rescue BrowserError, TimeoutError => e
|
|
662
|
+
raise BinaryError,
|
|
663
|
+
"Could not read the Lightpanda version at #{@options.ws_url} " \
|
|
664
|
+
"(LP.version failed: #{e.message}). ws_url: must point at a " \
|
|
665
|
+
"Lightpanda CDP endpoint new enough to answer LP.version; the gem " \
|
|
666
|
+
"cannot verify a browser it does not manage."
|
|
667
|
+
end
|
|
668
|
+
|
|
593
669
|
def restart_process_if_dead
|
|
594
670
|
return unless @process && !@process.alive?
|
|
595
671
|
|
|
@@ -33,14 +33,19 @@ module Capybara
|
|
|
33
33
|
@driver_mutex.synchronize { @driver.text(message) }
|
|
34
34
|
end
|
|
35
35
|
|
|
36
|
+
# The socket is closed even when the connection is already dead. A
|
|
37
|
+
# browser that dies first makes the reader thread #mark_dead the
|
|
38
|
+
# connection (status :closed or :error) without closing the socket,
|
|
39
|
+
# and an early return here then left the TCPSocket — one descriptor
|
|
40
|
+
# per crashed browser — open until GC finalized it (ferrum #639).
|
|
36
41
|
def close
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
42
|
+
unless @status == :closed
|
|
43
|
+
@status = :closing
|
|
44
|
+
@messages.close
|
|
45
|
+
@driver_mutex.synchronize { @driver&.close }
|
|
46
|
+
@thread&.join(1) || @thread&.kill
|
|
47
|
+
end
|
|
48
|
+
close_socket
|
|
44
49
|
@status = :closed
|
|
45
50
|
end
|
|
46
51
|
|
|
@@ -54,8 +59,24 @@ module Capybara
|
|
|
54
59
|
mark_dead
|
|
55
60
|
end
|
|
56
61
|
|
|
62
|
+
# Single home for the "dead implies queue closed" invariant: every
|
|
63
|
+
# path that gives up on the connection must close @messages so the
|
|
64
|
+
# Client message thread's blocking pop returns. Public because the
|
|
65
|
+
# Client calls it when its own message thread dies — either transport
|
|
66
|
+
# thread dying means the connection is dead.
|
|
67
|
+
def mark_dead(status = :closed)
|
|
68
|
+
@status = status
|
|
69
|
+
@messages.close
|
|
70
|
+
end
|
|
71
|
+
|
|
57
72
|
private
|
|
58
73
|
|
|
74
|
+
def close_socket
|
|
75
|
+
@socket&.close unless @socket.nil? || @socket.closed?
|
|
76
|
+
rescue IOError, SystemCallError
|
|
77
|
+
# Already torn down by the peer; nothing left to release.
|
|
78
|
+
end
|
|
79
|
+
|
|
59
80
|
def connect
|
|
60
81
|
uri = URI.parse(@url)
|
|
61
82
|
|
|
@@ -95,19 +116,25 @@ module Capybara
|
|
|
95
116
|
|
|
96
117
|
@driver.on(:error) do |event|
|
|
97
118
|
# Do NOT raise here. This callback fires synchronously from
|
|
98
|
-
# @driver.parse(data) inside the reader thread
|
|
99
|
-
#
|
|
100
|
-
#
|
|
101
|
-
# process. Mark the connection dead and let Client#command
|
|
102
|
-
# surface DeadBrowserError on its next dispatch via closed?.
|
|
119
|
+
# @driver.parse(data) inside the reader thread. Mark the
|
|
120
|
+
# connection dead and let Client#command surface DeadBrowserError
|
|
121
|
+
# on its next dispatch via closed?.
|
|
103
122
|
@logger&.puts("✗ WebSocket error: #{event.message}")
|
|
104
123
|
mark_dead(:error)
|
|
105
124
|
end
|
|
106
125
|
end
|
|
107
126
|
|
|
127
|
+
# The reader must never take the host process down. With
|
|
128
|
+
# abort_on_exception an error the narrow IO rescue doesn't expect
|
|
129
|
+
# (Errno::ETIMEDOUT is a SystemCallError, not an IOError) was re-raised
|
|
130
|
+
# on the main thread wherever it happened to be: a spec failure blamed
|
|
131
|
+
# on an unrelated call, or a server dying with its `ensure` never
|
|
132
|
+
# reaching browser.quit. Any error here means the connection is dead —
|
|
133
|
+
# report it and let the next command raise DeadBrowserError
|
|
134
|
+
# (mirrors ferrum #632).
|
|
108
135
|
def start_reader_thread
|
|
109
136
|
@thread = Thread.new do
|
|
110
|
-
Thread.current.abort_on_exception =
|
|
137
|
+
Thread.current.abort_on_exception = false
|
|
111
138
|
|
|
112
139
|
loop do
|
|
113
140
|
break if @status == :closed || @status == :closing
|
|
@@ -120,6 +147,10 @@ module Capybara
|
|
|
120
147
|
rescue Errno::ECONNRESET, Errno::EPIPE, IOError
|
|
121
148
|
mark_dead
|
|
122
149
|
break
|
|
150
|
+
rescue StandardError => e
|
|
151
|
+
warn "Capybara::Lightpanda: WebSocket reader died: #{e.class}: #{e.message}"
|
|
152
|
+
mark_dead(:error)
|
|
153
|
+
break
|
|
123
154
|
end
|
|
124
155
|
end
|
|
125
156
|
end
|
|
@@ -144,14 +175,6 @@ module Capybara
|
|
|
144
175
|
raise TimeoutError, "WebSocket handshake timed out after #{@options.handshake_timeout}s"
|
|
145
176
|
end
|
|
146
177
|
|
|
147
|
-
# Single home for the "dead implies queue closed" invariant: every
|
|
148
|
-
# path that gives up on the connection must close @messages so the
|
|
149
|
-
# Client message thread's blocking pop returns.
|
|
150
|
-
def mark_dead(status = :closed)
|
|
151
|
-
@status = status
|
|
152
|
-
@messages.close
|
|
153
|
-
end
|
|
154
|
-
|
|
155
178
|
def parse_message(data)
|
|
156
179
|
JSON.parse(data, max_nesting: false)
|
|
157
180
|
rescue JSON::ParserError => e
|
|
@@ -102,12 +102,31 @@ module Capybara
|
|
|
102
102
|
@pendings.each_value { |ivar| ivar.try_set(nil) }
|
|
103
103
|
end
|
|
104
104
|
|
|
105
|
+
# The message thread must never take the host process down: with
|
|
106
|
+
# abort_on_exception, anything handle_message raised was re-raised on
|
|
107
|
+
# the main thread mid-whatever-it-was-doing. An unexpected error here
|
|
108
|
+
# means the connection is dead — mark it so and stop (ferrum #632).
|
|
109
|
+
#
|
|
110
|
+
# The `ensure` is also the fail-fast for a connection that dies
|
|
111
|
+
# *underneath* us: the WebSocket's mark_dead closes @messages, pop
|
|
112
|
+
# returns nil, the loop ends, and every blocked caller is released to
|
|
113
|
+
# raise DeadBrowserError instead of waiting out its full timeout
|
|
114
|
+
# (ferrum #630). Ordering is what makes that work — mark_dead flips the
|
|
115
|
+
# status before it closes the queue, so by the time the waiters wake,
|
|
116
|
+
# `@ws.closed?` is already true.
|
|
105
117
|
def start_message_thread
|
|
106
118
|
@message_thread = Thread.new do
|
|
107
|
-
Thread.current.abort_on_exception =
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
119
|
+
Thread.current.abort_on_exception = false
|
|
120
|
+
|
|
121
|
+
begin
|
|
122
|
+
while (message = @ws.messages.pop)
|
|
123
|
+
handle_message(message)
|
|
124
|
+
end
|
|
125
|
+
rescue StandardError => e
|
|
126
|
+
warn "Capybara::Lightpanda: CDP message thread died: #{e.class}: #{e.message}"
|
|
127
|
+
@ws.mark_dead(:error)
|
|
128
|
+
ensure
|
|
129
|
+
fail_pending_commands
|
|
111
130
|
end
|
|
112
131
|
end
|
|
113
132
|
end
|
|
@@ -117,8 +136,8 @@ module Capybara
|
|
|
117
136
|
pending = @pendings[message["id"]]
|
|
118
137
|
# try_set, not set: a duplicate frame for an already-answered id
|
|
119
138
|
# (Lightpanda emits occasional malformed/duplicate frames — see
|
|
120
|
-
# upstream-wishlist.md A41) would raise MultipleAssignmentError
|
|
121
|
-
#
|
|
139
|
+
# upstream-wishlist.md A41) would raise MultipleAssignmentError and
|
|
140
|
+
# kill the connection for a frame we should simply ignore.
|
|
122
141
|
pending&.try_set(message)
|
|
123
142
|
elsif message["method"]
|
|
124
143
|
@subscriber.dispatch(message["method"], message["params"])
|
|
@@ -79,7 +79,12 @@ module Capybara
|
|
|
79
79
|
# Ferrum/Cuprite spelling: `browser.cookies["session_id"]`.
|
|
80
80
|
alias [] get
|
|
81
81
|
|
|
82
|
-
|
|
82
|
+
# Returns whether the browser accepted the cookie (ferrum parity).
|
|
83
|
+
# Lightpanda answers `{success: false}` and drops the cookie for an
|
|
84
|
+
# invalid combination — notably `same_site: "None"` without `secure`
|
|
85
|
+
# since upstream #3529 (build 9877), Chrome's rule — so callers that
|
|
86
|
+
# need it to stick should check the result.
|
|
87
|
+
def set(name:, value:, domain: nil, path: "/", secure: false, http_only: false, # rubocop:disable Metrics/ParameterLists, Naming/PredicateMethod
|
|
83
88
|
same_site: nil, expires: nil)
|
|
84
89
|
params = {
|
|
85
90
|
name: name,
|
|
@@ -96,7 +101,8 @@ module Capybara
|
|
|
96
101
|
params[:sameSite] = same_site if %w[Strict Lax None].include?(same_site)
|
|
97
102
|
params[:expires] = expires.to_i if expires
|
|
98
103
|
|
|
99
|
-
browser.command("Network.setCookie", **params)
|
|
104
|
+
result = browser.command("Network.setCookie", **params)
|
|
105
|
+
!(result.is_a?(Hash) && result["success"] == false)
|
|
100
106
|
end
|
|
101
107
|
|
|
102
108
|
def remove(name:, domain: nil, path: "/")
|
|
@@ -119,10 +125,13 @@ module Capybara
|
|
|
119
125
|
# Load cookies from a YAML file produced by `store` and re-set them.
|
|
120
126
|
# CDP requires either domain or url for each cookie; entries from `store`
|
|
121
127
|
# already include domain, so they round-trip cleanly. Returns true on
|
|
122
|
-
# success (intentionally not a predicate — mirrors ferrum's API).
|
|
128
|
+
# 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.
|
|
123
131
|
def load(path = "cookies.yml") # rubocop:disable Naming/PredicateMethod
|
|
124
132
|
cookies = YAML.load_file(path)
|
|
125
|
-
cookies.
|
|
133
|
+
rejected = cookies.reject { |c| restore_cookie(c) }.map { |c| c.transform_keys(&:to_s)["name"] }
|
|
134
|
+
warn "Capybara::Lightpanda: the browser refused cookies from #{path}: #{rejected.join(', ')}" if rejected.any?
|
|
126
135
|
true
|
|
127
136
|
end
|
|
128
137
|
|
|
@@ -140,11 +149,24 @@ module Capybara
|
|
|
140
149
|
http_only: attrs["httpOnly"] || false,
|
|
141
150
|
}
|
|
142
151
|
params[:domain] = attrs["domain"] if attrs["domain"]
|
|
143
|
-
|
|
152
|
+
same_site = restored_same_site(attrs)
|
|
153
|
+
params[:same_site] = same_site if same_site
|
|
144
154
|
exp = attrs["expires"]
|
|
145
155
|
params[:expires] = Time.at(exp) if exp.is_a?(Numeric) && exp.positive?
|
|
146
156
|
set(**params)
|
|
147
157
|
end
|
|
158
|
+
|
|
159
|
+
# Lightpanda below build 9877 reported every cookie without a SameSite
|
|
160
|
+
# attribute as "None", so a file stored then is full of insecure
|
|
161
|
+
# SameSite=None cookies — which the browser now rejects (Chrome's rule,
|
|
162
|
+
# upstream #3529). Leaving SameSite unset restores what the cookie
|
|
163
|
+
# actually was: unspecified, i.e. Lax-by-default.
|
|
164
|
+
def restored_same_site(attrs)
|
|
165
|
+
same_site = attrs["sameSite"]
|
|
166
|
+
return nil if same_site == "None" && !attrs["secure"]
|
|
167
|
+
|
|
168
|
+
same_site
|
|
169
|
+
end
|
|
148
170
|
end
|
|
149
171
|
end
|
|
150
172
|
end
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
// separately because checkVisibility's defaults (per spec) ignore it.
|
|
18
18
|
//
|
|
19
19
|
// External `<link rel=stylesheet>` ARE fetched and applied — the gem
|
|
20
|
-
// always passes
|
|
20
|
+
// always passes `--load-resources stylesheet` (Lightpanda PR #2487,
|
|
21
21
|
// guaranteed by the build floor) — so stylesheet-driven `display:none`
|
|
22
22
|
// participates in this check. The remaining gap is viewport emulation:
|
|
23
23
|
// `@media` rules and `matchMedia()` evaluate against the hardcoded
|
|
@@ -92,6 +92,43 @@ function isContentEditable(el) {
|
|
|
92
92
|
return false;
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
+
// The element a real pointer press on `el` would focus: the nearest focusable
|
|
96
|
+
// inclusive ancestor, or null when the press lands on nothing focusable (Chrome
|
|
97
|
+
// then moves focus to the body). Lightpanda's handleClick stopped focusing
|
|
98
|
+
// controls in upstream #3702 (build 9968), and an untrusted `mousedown` — the
|
|
99
|
+
// only kind CLICK_JS can dispatch — never runs the focus default, so the
|
|
100
|
+
// driver has to apply it itself or `click` then `page.send_keys` types nowhere.
|
|
101
|
+
function pointerFocusTarget(el) {
|
|
102
|
+
function isEditingHost(n) {
|
|
103
|
+
if (!n.hasAttribute || !n.hasAttribute('contenteditable')) return false;
|
|
104
|
+
return (n.getAttribute('contenteditable') || '').toLowerCase() !== 'false';
|
|
105
|
+
}
|
|
106
|
+
function isFocusable(n) {
|
|
107
|
+
if (isDisabled(n)) return false;
|
|
108
|
+
switch ((n.tagName || '').toUpperCase()) {
|
|
109
|
+
case 'INPUT': return (n.getAttribute('type') || '').toLowerCase() !== 'hidden';
|
|
110
|
+
case 'SELECT': case 'TEXTAREA': case 'BUTTON': case 'IFRAME': case 'SUMMARY': return true;
|
|
111
|
+
case 'A': case 'AREA': return n.hasAttribute('href');
|
|
112
|
+
}
|
|
113
|
+
return n.hasAttribute('tabindex');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
for (var n = el; n && n.nodeType === 1; n = n.parentElement) {
|
|
117
|
+
// A press on a <label> lands on its control (the label's activation
|
|
118
|
+
// behavior); the label itself is never focusable.
|
|
119
|
+
if ((n.tagName || '').toUpperCase() === 'LABEL' && n.control) {
|
|
120
|
+
return isFocusable(n.control) ? n.control : null;
|
|
121
|
+
}
|
|
122
|
+
if (isEditingHost(n)) {
|
|
123
|
+
// Focus goes to the outermost element of a contiguous editable region.
|
|
124
|
+
while (n.parentElement && isEditingHost(n.parentElement)) n = n.parentElement;
|
|
125
|
+
return n;
|
|
126
|
+
}
|
|
127
|
+
if (isFocusable(n)) return n;
|
|
128
|
+
}
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
|
|
95
132
|
// Capybara's "visible text" = WebDriver getText semantics, which native
|
|
96
133
|
// innerText alone doesn't provide at two edges: a non-rendered element must
|
|
97
134
|
// yield "" (innerText returns textContent per the HTML getter's step 1), and a
|
|
@@ -171,19 +171,37 @@ module Capybara
|
|
|
171
171
|
release_modifiers(active_mods)
|
|
172
172
|
end
|
|
173
173
|
|
|
174
|
+
# A chord with Ctrl or Meta is a command, not text: Chrome inserts
|
|
175
|
+
# nothing for Ctrl+A, it selects. Since upstream #3542 (build 9580)
|
|
176
|
+
# Lightpanda inserts whatever the keyDown's `text` carries, so sending
|
|
177
|
+
# `text` with Ctrl held typed a stray "a" into the field. The keyDown
|
|
178
|
+
# still names the key, so shortcut handlers (`e.ctrlKey && e.key ===
|
|
179
|
+
# "a"`) fire exactly as before.
|
|
180
|
+
COMMAND_MODIFIERS = %i[ctrl control meta command].freeze
|
|
181
|
+
private_constant :COMMAND_MODIFIERS
|
|
182
|
+
|
|
174
183
|
def dispatch_modified_char(char, modifier_value, modifiers)
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
184
|
+
key = modifiers.include?(:shift) ? self.class.shifted(char) : char
|
|
185
|
+
definition = { key: key, unmodifiedText: char }
|
|
186
|
+
definition[:text] = key unless modifiers.intersect?(COMMAND_MODIFIERS)
|
|
187
|
+
send_key_event("keyDown", definition, modifiers: modifier_value)
|
|
188
|
+
send_key_event("keyUp", { key: key }, modifiers: modifier_value)
|
|
178
189
|
end
|
|
179
190
|
|
|
180
191
|
def key_definition(key)
|
|
181
192
|
KEYS.fetch(key) { raise ArgumentError, "Unknown key: #{key.inspect}" }
|
|
182
193
|
end
|
|
183
194
|
|
|
195
|
+
# Always `keyDown`, never `rawKeyDown`. Chrome's convention (Ferrum,
|
|
196
|
+
# Puppeteer) is rawKeyDown for keys that carry no `text`, but Lightpanda's
|
|
197
|
+
# Input.dispatchKeyEvent drops rawKeyDown on the floor as a
|
|
198
|
+
# Chrome-internal type (input.zig: `if (params.type == .rawKeyDown)
|
|
199
|
+
# return;`). Under that convention every text-less key — Backspace,
|
|
200
|
+
# Delete, Tab, Escape, the arrows — silently never reached the page,
|
|
201
|
+
# while Enter and Space (which have `text`) did. Lightpanda synthesizes
|
|
202
|
+
# its own keypress from a keyDown, so there is nothing to double-fire.
|
|
184
203
|
def raw_dispatch(definition, modifiers: 0)
|
|
185
|
-
|
|
186
|
-
send_key_event(type, definition, modifiers: modifiers)
|
|
204
|
+
send_key_event("keyDown", definition, modifiers: modifiers)
|
|
187
205
|
send_key_event("keyUp", definition, modifiers: modifiers)
|
|
188
206
|
end
|
|
189
207
|
|
|
@@ -20,7 +20,7 @@ module Capybara
|
|
|
20
20
|
@traffic = []
|
|
21
21
|
@traffic_mutex = Mutex.new
|
|
22
22
|
@enabled = false
|
|
23
|
-
@request_handler = @response_handler = nil
|
|
23
|
+
@request_handler = @response_handler = @failure_handler = nil
|
|
24
24
|
@last_navigation_response = nil
|
|
25
25
|
@document_request_id = nil
|
|
26
26
|
end
|
|
@@ -111,10 +111,10 @@ module Capybara
|
|
|
111
111
|
browser.page_command("Network.setExtraHTTPHeaders", headers: {})
|
|
112
112
|
end
|
|
113
113
|
|
|
114
|
-
# Count of in-flight requests (
|
|
114
|
+
# Count of in-flight requests (no response and no failure recorded yet).
|
|
115
115
|
# Cheap predicate-friendly accessor (ferrum parity).
|
|
116
116
|
def pending_connections
|
|
117
|
-
@traffic_mutex.synchronize { @traffic.count { |t| t
|
|
117
|
+
@traffic_mutex.synchronize { @traffic.count { |t| open?(t) } }
|
|
118
118
|
end
|
|
119
119
|
|
|
120
120
|
# True when no more than `connections` requests are in-flight.
|
|
@@ -158,8 +158,10 @@ module Capybara
|
|
|
158
158
|
def subscribe
|
|
159
159
|
@request_handler = build_request_handler
|
|
160
160
|
@response_handler = build_response_handler
|
|
161
|
+
@failure_handler = build_failure_handler
|
|
161
162
|
browser.on("Network.requestWillBeSent", &@request_handler)
|
|
162
163
|
browser.on("Network.responseReceived", &@response_handler)
|
|
164
|
+
browser.on("Network.loadingFailed", &@failure_handler)
|
|
163
165
|
end
|
|
164
166
|
|
|
165
167
|
# Redirects follow Chrome's shape (Lightpanda since #3175, build ≥8602):
|
|
@@ -184,6 +186,7 @@ module Capybara
|
|
|
184
186
|
method: params.dig("request", "method"),
|
|
185
187
|
timestamp: params["timestamp"],
|
|
186
188
|
response: nil,
|
|
189
|
+
error: nil,
|
|
187
190
|
}
|
|
188
191
|
@traffic_mutex.synchronize do
|
|
189
192
|
if (redirect = params["redirectResponse"]) && (previous = last_open_entry(params["requestId"]))
|
|
@@ -214,9 +217,31 @@ module Capybara
|
|
|
214
217
|
end
|
|
215
218
|
end
|
|
216
219
|
|
|
220
|
+
# A request that never gets a response — refused connection, CORS
|
|
221
|
+
# block, abort — ends with Network.loadingFailed instead. Without this
|
|
222
|
+
# handler its entry stayed open forever, so pending_connections never
|
|
223
|
+
# reached zero and every wait_for_idle burned its full timeout. Routine
|
|
224
|
+
# since Lightpanda enforces CORS by default (upstream #3654, build 9883:
|
|
225
|
+
# a blocked fetch reports errorText "CorsBlocked"). Ferrum closes its
|
|
226
|
+
# exchange on the same event.
|
|
227
|
+
def build_failure_handler
|
|
228
|
+
lambda do |params|
|
|
229
|
+
@traffic_mutex.synchronize do
|
|
230
|
+
request = last_open_entry(params["requestId"])
|
|
231
|
+
next unless request
|
|
232
|
+
|
|
233
|
+
request[:error] = params["errorText"].to_s
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
217
238
|
# Caller holds @traffic_mutex.
|
|
218
239
|
def last_open_entry(request_id)
|
|
219
|
-
@traffic.reverse_each.find { |t| t[:request_id] == request_id && t
|
|
240
|
+
@traffic.reverse_each.find { |t| t[:request_id] == request_id && open?(t) }
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def open?(entry)
|
|
244
|
+
entry[:response].nil? && entry[:error].nil?
|
|
220
245
|
end
|
|
221
246
|
|
|
222
247
|
def response_summary(response)
|
|
@@ -231,8 +256,10 @@ module Capybara
|
|
|
231
256
|
def unsubscribe
|
|
232
257
|
browser.off("Network.requestWillBeSent", @request_handler) if @request_handler
|
|
233
258
|
browser.off("Network.responseReceived", @response_handler) if @response_handler
|
|
259
|
+
browser.off("Network.loadingFailed", @failure_handler) if @failure_handler
|
|
234
260
|
@request_handler = nil
|
|
235
261
|
@response_handler = nil
|
|
262
|
+
@failure_handler = nil
|
|
236
263
|
end
|
|
237
264
|
end
|
|
238
265
|
end
|
|
@@ -256,9 +256,25 @@ module Capybara
|
|
|
256
256
|
raise Capybara::UnselectNotAllowed, "Cannot unselect option from single select box."
|
|
257
257
|
end
|
|
258
258
|
|
|
259
|
+
# The trailing `wait_for_idle` mirrors #click, and for the same reason.
|
|
260
|
+
# Since upstream #3264 (build >= 8842) `Input.dispatchKeyEvent` builds a
|
|
261
|
+
# *trusted* KeyboardEvent, so Enter on a submit input / <a href> and
|
|
262
|
+
# Space on a button or checkbox synthesize a real activation click —
|
|
263
|
+
# which means a keystroke can now start a navigation. Before #3264 it
|
|
264
|
+
# could not, which is why this settle step was historically absent; its
|
|
265
|
+
# absence then showed up as `current_url` being read against the
|
|
266
|
+
# outgoing document (test/features/keyboard_activation_test.rb).
|
|
267
|
+
#
|
|
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.
|
|
259
274
|
def send_keys(*)
|
|
260
|
-
call(
|
|
275
|
+
call(SEND_KEYS_FOCUS_JS)
|
|
261
276
|
driver.browser.keyboard.type(*)
|
|
277
|
+
driver.browser.wait_for_idle
|
|
262
278
|
end
|
|
263
279
|
|
|
264
280
|
def tag_name
|
|
@@ -593,7 +609,21 @@ module Capybara
|
|
|
593
609
|
// Cancelling mousedown suppresses focus/text-selection in a real
|
|
594
610
|
// browser but never the click, so the click below is dispatched
|
|
595
611
|
// unconditionally.
|
|
596
|
-
hit.dispatchEvent(new EventCtor('mousedown', { bubbles: true, cancelable: true }));
|
|
612
|
+
var pressed = hit.dispatchEvent(new EventCtor('mousedown', { bubbles: true, cancelable: true }));
|
|
613
|
+
// mousedown's default action moves focus. Ours is untrusted, so the
|
|
614
|
+
// browser won't do it (and since upstream #3702 its click handling
|
|
615
|
+
// doesn't either): focus what the press landed on, or blur the
|
|
616
|
+
// current field when it landed on nothing focusable — Chrome's
|
|
617
|
+
// behavior, and what `click` then `page.send_keys` relies on.
|
|
618
|
+
if (pressed && window._lightpanda) {
|
|
619
|
+
var doc = hit.ownerDocument;
|
|
620
|
+
var focusTarget = _lightpanda.pointerFocusTarget(hit);
|
|
621
|
+
if (focusTarget) {
|
|
622
|
+
if (doc.activeElement !== focusTarget) focusTarget.focus();
|
|
623
|
+
} else if (doc.activeElement && doc.activeElement !== doc.body && doc.activeElement.blur) {
|
|
624
|
+
doc.activeElement.blur();
|
|
625
|
+
}
|
|
626
|
+
}
|
|
597
627
|
hit.dispatchEvent(new EventCtor('mouseup', { bubbles: true, cancelable: true }));
|
|
598
628
|
var clickEvt = new EventCtor('click', { bubbles: true, cancelable: true });
|
|
599
629
|
var notCancelled = hit.dispatchEvent(clickEvt);
|
|
@@ -902,8 +932,46 @@ module Capybara
|
|
|
902
932
|
}
|
|
903
933
|
JS
|
|
904
934
|
|
|
935
|
+
# Focuses the element keys are about to be typed into, WebDriver's
|
|
936
|
+
# "Element Send Keys" way (what chromedriver's focus script does): an
|
|
937
|
+
# element that did NOT already have focus gets its caret after its
|
|
938
|
+
# content. Since upstream #3424 (build 9234) `Input.insertText` inserts
|
|
939
|
+
# at the caret, and a value seeded by the `value=` attribute leaves the
|
|
940
|
+
# caret at 0 — without this, `send_keys("x")` on a pre-filled field
|
|
941
|
+
# typed "xfoo" where Chrome types "foox". An element that already has
|
|
942
|
+
# focus keeps its caret, so `page.send_keys` mid-edit continues in place.
|
|
943
|
+
# A non-focusable target (focus() is a no-op on those since upstream
|
|
944
|
+
# #3592, build 9646) must not leave the previous field focused, or its
|
|
945
|
+
# keys land there: blur it so they go to the body, as in Chrome.
|
|
946
|
+
SEND_KEYS_FOCUS_JS = <<~JS
|
|
947
|
+
function() {
|
|
948
|
+
var doc = this.ownerDocument;
|
|
949
|
+
if (doc.activeElement === this) return;
|
|
950
|
+
this.focus();
|
|
951
|
+
if (doc.activeElement !== this) {
|
|
952
|
+
if (doc.activeElement && doc.activeElement !== doc.body && doc.activeElement.blur) doc.activeElement.blur();
|
|
953
|
+
return;
|
|
954
|
+
}
|
|
955
|
+
if (typeof this.setSelectionRange === 'function') {
|
|
956
|
+
// Throws on types without a selection (email, number, …) — those
|
|
957
|
+
// have no caret to place.
|
|
958
|
+
try {
|
|
959
|
+
var end = String(this.value || '').length;
|
|
960
|
+
this.setSelectionRange(end, end);
|
|
961
|
+
} catch (e) {}
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
JS
|
|
965
|
+
|
|
966
|
+
# A disabled option (or one in a disabled <optgroup>/<select>) is not
|
|
967
|
+
# selectable by a user, and Capybara's shared spec expects selecting one
|
|
968
|
+
# to be a no-op (rack_test: `return if disabled?`). Both IDL routes —
|
|
969
|
+
# `sel.value =` and `option.selected = true` — select it per spec;
|
|
970
|
+
# Lightpanda's `value` getter used to skip disabled options and hide that,
|
|
971
|
+
# until upstream #3502 (build 9424).
|
|
905
972
|
SELECT_OPTION_JS = <<~JS
|
|
906
973
|
function() {
|
|
974
|
+
if (_lightpanda.isDisabled(this)) return;
|
|
907
975
|
var sel = this.parentElement;
|
|
908
976
|
while (sel && (sel.tagName || '').toUpperCase() !== 'SELECT') sel = sel.parentElement;
|
|
909
977
|
if (!sel) {
|
|
@@ -913,14 +981,10 @@ module Capybara
|
|
|
913
981
|
// that path itself; just no-op here.
|
|
914
982
|
return;
|
|
915
983
|
}
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
// `option.selected`, so mirror what a real browser does and
|
|
921
|
-
// route the change through the parent's `value`.
|
|
922
|
-
sel.value = this.value;
|
|
923
|
-
}
|
|
984
|
+
// Selects exactly this option — not the first one sharing its
|
|
985
|
+
// value — and, in a single <select>, deselects the rest (upstream
|
|
986
|
+
// #3375, guaranteed by the floor).
|
|
987
|
+
this.selected = true;
|
|
924
988
|
sel.dispatchEvent(new Event('input', {bubbles: true}));
|
|
925
989
|
sel.dispatchEvent(new Event('change', {bubbles: true}));
|
|
926
990
|
}
|
|
@@ -47,13 +47,12 @@ module Capybara
|
|
|
47
47
|
# a runaway confirm cancels the action and the spec can still pass. false
|
|
48
48
|
# (default, Cuprite parity) warns on stderr; true raises
|
|
49
49
|
# UnhandledModalError from the action that opened it.
|
|
50
|
-
# load_images:
|
|
51
|
-
# fetched over the network (upstream #3230, build >= 8834
|
|
52
|
-
# default, matching Lightpanda's default of
|
|
53
|
-
# entirely. When on, image requests ride the
|
|
54
|
-
# other — they show in `network.traffic` and
|
|
55
|
-
# `wait_for_network_idle`.
|
|
56
|
-
# UnknownOption at boot, so only enable against binaries that have it.
|
|
50
|
+
# load_images: adds `image` to the `--load-resources` value so `<img>`
|
|
51
|
+
# elements are fetched over the network (upstream #3230, build >= 8834,
|
|
52
|
+
# in the floor). Off by default, matching Lightpanda's default of
|
|
53
|
+
# skipping image fetches entirely. When on, image requests ride the
|
|
54
|
+
# Network domain like any other — they show in `network.traffic` and
|
|
55
|
+
# count toward `wait_for_network_idle`.
|
|
57
56
|
attr_accessor :host, :port, :timeout, :handshake_timeout, :process_timeout,
|
|
58
57
|
:window_size, :browser_path, :headless, :logger, :save_path,
|
|
59
58
|
:raise_on_unhandled_modal, :load_images
|
|
@@ -63,10 +63,10 @@ module Capybara
|
|
|
63
63
|
# returns spec-correct booleans. Lets _lightpanda.isVisible detect
|
|
64
64
|
# inline-@media-gated hides via el.checkVisibility() without any
|
|
65
65
|
# gem-side workaround),
|
|
66
|
-
# PR #2487 (css: external <link rel="stylesheet"> fetch behind
|
|
67
|
-
#
|
|
68
|
-
#
|
|
69
|
-
#
|
|
66
|
+
# PR #2487 (css: external <link rel="stylesheet"> fetch behind a CLI
|
|
67
|
+
# flag — build_args passes it unconditionally, so the floor MUST include
|
|
68
|
+
# the build that introduced it; since #3305 (8946) the spelling is
|
|
69
|
+
# `--load-resources stylesheet`, see the 2026-09-06 bump),
|
|
70
70
|
# PR #2498 (StyleManager: author display rule beats UA [hidden] — fixes
|
|
71
71
|
# the Stimulus/Alpine dropdown ElementNotFound),
|
|
72
72
|
# PR #2635 (dom: DOM.setFileInputFiles backs input.files + fires change
|
|
@@ -201,7 +201,70 @@ module Capybara
|
|
|
201
201
|
# this bump — and drag events carry integer clientX/Y (the shared spec
|
|
202
202
|
# asserting that runs un-skipped). Build 8796 = the #3259 merge
|
|
203
203
|
# (341a01570), which is also the 2026-08-25 nightly cut.
|
|
204
|
-
|
|
204
|
+
#
|
|
205
|
+
# 2026-08-26 bump 8796 -> 8875, four fixes the gem now asserts on:
|
|
206
|
+
# #3264 (8842) CDP Input.dispatchKeyEvent builds a *trusted*
|
|
207
|
+
# KeyboardEvent, so frame/user_input.zig fires `keypress` on
|
|
208
|
+
# printable/Enter keydowns and synthesizes a trusted PointerEvent
|
|
209
|
+
# click for Enter on <button>/<a href>/input[submit|button|reset|
|
|
210
|
+
# image] and Space-keyup on those plus checkbox/radio. That click is
|
|
211
|
+
# a real activation event (PointerEvent.Proto = MouseEvent), so
|
|
212
|
+
# Node#send_keys(:enter) submits/navigates and send_keys(:space)
|
|
213
|
+
# toggles a checkbox — both were no-ops below 8842. No gem code
|
|
214
|
+
# drives it; Keyboard never sends CDP `type: "char"`, so nothing
|
|
215
|
+
# double-fires. Pinned by test/features/keyboard_activation_test.rb.
|
|
216
|
+
# #3269 (8868) adds `dialog:not([open]) { display: none }` to the
|
|
217
|
+
# UA-stylesheet truth checkVisibility() and getComputedStyle().display
|
|
218
|
+
# share, so a closed <dialog> AND its subtree read as non-visible.
|
|
219
|
+
# Below 8868 Capybara matched text and controls inside a never-opened
|
|
220
|
+
# dialog. Pinned by test/features/upstream_bugs_test.rb (Bug #4).
|
|
221
|
+
# #3270 (8857) inline-style keyword matching in checkVisibility /
|
|
222
|
+
# getComputedStyle is case-insensitive for display / visibility /
|
|
223
|
+
# opacity / pointer-events, so `style="display: NONE"` hides.
|
|
224
|
+
# Feeds _lightpanda.isVisible. Pinned by visibility_keywords_test.rb.
|
|
225
|
+
# #3256 (8875) the CDP WS handshake accepts the exact lowercase
|
|
226
|
+
# `Host: localhost:<port>` form (bare `localhost`, `LOCALHOST:<port>`
|
|
227
|
+
# and `localhost.evil.com:<port>` still 403). Below 8875 only an IP
|
|
228
|
+
# literal passed, so a user-supplied `ws_url:` had to say 127.0.0.1.
|
|
229
|
+
# Pinned by test/features/ws_url_host_test.rb.
|
|
230
|
+
# Build 8875 = the #3256 merge (f2169836e).
|
|
231
|
+
#
|
|
232
|
+
# 2026-09-06 bump 8875 -> 9058. The binding constraint is the lockstep
|
|
233
|
+
# with MINIMUM_RELEASE below: 9058 is exactly `git rev-list --count
|
|
234
|
+
# 0.4.0`, the newest tagged release, so this is as high as the nightly
|
|
235
|
+
# floor can go without naming a release that does not exist. Two fixes
|
|
236
|
+
# the gem now asserts on ride along:
|
|
237
|
+
# #3298 (8937) Backspace/Delete through CDP Input.dispatchKeyEvent
|
|
238
|
+
# actually edit an <input>/<textarea>: a trusted `beforeinput`
|
|
239
|
+
# (cancelable) then `input` with inputType deleteContentBackward /
|
|
240
|
+
# deleteContentForward, via the text_entry.zig mixin. Below 8937 the
|
|
241
|
+
# keys were dead, so Node#send_keys(:backspace) silently did nothing.
|
|
242
|
+
# Pinned by test/features/keyboard_editing_test.rb.
|
|
243
|
+
# #3305 (8946) renamed --enable-external-stylesheets to
|
|
244
|
+
# `--load-resources stylesheet` and --log-filter-scopes to
|
|
245
|
+
# --log-filter; build_args now passes the new spelling, which is a
|
|
246
|
+
# fatal UnknownOption below 8946. (The same PR flipped iframes and
|
|
247
|
+
# workers off by default — Browser#configure_loading re-enables them
|
|
248
|
+
# per BrowserContext, and did so before this bump.)
|
|
249
|
+
#
|
|
250
|
+
# 2026-10-03 bump 9058 -> 9994 (= release 1.0.0, the new lockstep cap).
|
|
251
|
+
# Retired or ungated by it:
|
|
252
|
+
# #3375 (9071) option.selected deselects its siblings — SELECT_OPTION_JS
|
|
253
|
+
# dropped its `sel.value =` detour, which selected the FIRST option
|
|
254
|
+
# sharing a value rather than the one Capybara resolved.
|
|
255
|
+
# #3378 (9207) matchMedia `change` fires on a viewport override —
|
|
256
|
+
# viewport_test.rb's pin runs un-gated.
|
|
257
|
+
# #3414 / #3420 / #3422 / #3424 (9217-9234, ours) trusted
|
|
258
|
+
# `beforeinput` is cancelable; `.value =` moves the caret to the end;
|
|
259
|
+
# a child-text <textarea> can be selected; arrows/Home/End move the
|
|
260
|
+
# caret — the `send_keys should send special characters` skip is
|
|
261
|
+
# gone and keyboard_editing_test.rb pins all four.
|
|
262
|
+
# Behavior changes the floor now guarantees, all compensated or
|
|
263
|
+
# Chrome-parity: #3654 (9883) CORS enforced by default (blocked requests
|
|
264
|
+
# end in Network.loadingFailed, which Network consumes); #3529 (9877)
|
|
265
|
+
# SameSite=None without Secure refused (Cookies#set reports it);
|
|
266
|
+
# #3702 (9968) click no longer focuses (CLICK_JS focuses itself).
|
|
267
|
+
MINIMUM_NIGHTLY_BUILD = Gem::Version.new("9994")
|
|
205
268
|
|
|
206
269
|
# Second, equivalent floor for the *release* channel.
|
|
207
270
|
#
|
|
@@ -210,21 +273,66 @@ module Capybara
|
|
|
210
273
|
# bare "0.3.6" carrying no commit counter at all, and MINIMUM_NIGHTLY_BUILD
|
|
211
274
|
# has nothing to compare against. Releases are cut from the same trunk, so
|
|
212
275
|
# a release is acceptable exactly when its own commit count clears the
|
|
213
|
-
# nightly floor.
|
|
214
|
-
# floor
|
|
215
|
-
#
|
|
216
|
-
#
|
|
217
|
-
# trunk already past build 8834, so it clears the floor by construction.
|
|
218
|
-
# Until upstream tags it, the release channel is deliberately unusable
|
|
219
|
-
# (nightly-only), reported with the standard too-old error. When 0.3.8
|
|
220
|
-
# ships: verify `git rev-list --count 0.3.8` >= 8796 and update the
|
|
221
|
-
# lockstep table in process_test.rb.
|
|
276
|
+
# nightly floor. 1.0.0 (2026-10-02) is build 9994 — exactly the nightly
|
|
277
|
+
# floor, which is no accident: the floor was raised *to* the release so
|
|
278
|
+
# both channels gate the same commit. (0.4.1 = 9463 and 0.4.0 = 9058
|
|
279
|
+
# predate fixes the floor exists for.)
|
|
222
280
|
#
|
|
223
281
|
# INVARIANT: every MINIMUM_NIGHTLY_BUILD bump must also move this to the
|
|
224
282
|
# first release containing that build (`git rev-list --count <tag>` in the
|
|
225
283
|
# browser repo tells you). Leaving it behind would let the release channel
|
|
226
284
|
# silently accept a binary the nightly channel rejects.
|
|
227
|
-
MINIMUM_RELEASE = Gem::Version.new("0.
|
|
285
|
+
MINIMUM_RELEASE = Gem::Version.new("1.0.0")
|
|
286
|
+
|
|
287
|
+
class << self
|
|
288
|
+
# `lightpanda version` prints one of two shapes, and the gem supports
|
|
289
|
+
# both so a suite can either track nightly or pin a reproducible
|
|
290
|
+
# release:
|
|
291
|
+
#
|
|
292
|
+
# "1.0.0-nightly.8285+de85a51d" rolling nightly (also "dev.NNNN" for
|
|
293
|
+
# a locally compiled tree — same
|
|
294
|
+
# `git rev-list --count HEAD` counter,
|
|
295
|
+
# different label). Checked against
|
|
296
|
+
# MINIMUM_NIGHTLY_BUILD.
|
|
297
|
+
# "0.3.5" a tagged release: bare semver, no
|
|
298
|
+
# build metadata. Checked against
|
|
299
|
+
# MINIMUM_RELEASE.
|
|
300
|
+
#
|
|
301
|
+
# The release form is matched anchored — a bare semver and nothing else
|
|
302
|
+
# — so a stray "1.2.3" inside some other string can never be mistaken
|
|
303
|
+
# for a release. Anything that matches neither shape stays a hard
|
|
304
|
+
# failure: a version we cannot identify is never assumed to be new
|
|
305
|
+
# enough.
|
|
306
|
+
#
|
|
307
|
+
# Lives on the class, not the instance, because there are two ways to
|
|
308
|
+
# learn a version and only one of them involves a Process: the spawn
|
|
309
|
+
# path shells out to the binary, while an externally-managed browser
|
|
310
|
+
# (`ws_url:`) is asked over CDP. `LP.version` returns the identical
|
|
311
|
+
# string the CLI prints, so both channels share this one parser rather
|
|
312
|
+
# than drifting apart — the drift being how `ws_url:` shipped with no
|
|
313
|
+
# floor check at all.
|
|
314
|
+
#
|
|
315
|
+
# Returns [version, nightly_build, release]; exactly one of the latter
|
|
316
|
+
# two is non-nil. The update hint is yielded rather than passed so the
|
|
317
|
+
# caller's hint-building only runs on the failure path.
|
|
318
|
+
def check_version!(raw)
|
|
319
|
+
version = raw.to_s.strip
|
|
320
|
+
|
|
321
|
+
if (build = version[/(?:nightly|dev)\.(\d+)/, 1])
|
|
322
|
+
nightly_build = Gem::Version.new(build)
|
|
323
|
+
return [version, nightly_build, nil] if nightly_build >= MINIMUM_NIGHTLY_BUILD
|
|
324
|
+
elsif (tag = version[/\A\d+\.\d+\.\d+\z/])
|
|
325
|
+
release = Gem::Version.new(tag)
|
|
326
|
+
return [version, nil, release] if release >= MINIMUM_RELEASE
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
raise BinaryError,
|
|
330
|
+
"Lightpanda #{version} is too old. " \
|
|
331
|
+
"This gem requires nightly build >= #{MINIMUM_NIGHTLY_BUILD} " \
|
|
332
|
+
"or release >= #{MINIMUM_RELEASE}. " \
|
|
333
|
+
"Update: #{yield}"
|
|
334
|
+
end
|
|
335
|
+
end
|
|
228
336
|
|
|
229
337
|
attr_reader :pid, :ws_url, :version, :nightly_build, :release
|
|
230
338
|
|
|
@@ -273,38 +381,12 @@ module Capybara
|
|
|
273
381
|
|
|
274
382
|
private
|
|
275
383
|
|
|
276
|
-
#
|
|
277
|
-
#
|
|
278
|
-
#
|
|
279
|
-
# "1.0.0-nightly.8285+de85a51d" rolling nightly (also "dev.NNNN" for a
|
|
280
|
-
# locally compiled tree — same
|
|
281
|
-
# `git rev-list --count HEAD` counter,
|
|
282
|
-
# different label). Checked against
|
|
283
|
-
# MINIMUM_NIGHTLY_BUILD.
|
|
284
|
-
# "0.3.5" a tagged release: bare semver, no build
|
|
285
|
-
# metadata. Checked against MINIMUM_RELEASE.
|
|
286
|
-
#
|
|
287
|
-
# The release form is matched anchored — a bare semver and nothing else —
|
|
288
|
-
# so a stray "1.2.3" inside some other string can never be mistaken for a
|
|
289
|
-
# release. Anything that matches neither shape stays a hard failure: a
|
|
290
|
-
# binary we cannot identify is never assumed to be new enough.
|
|
384
|
+
# Shells the floor check out to the binary. The ws_url path can't — see
|
|
385
|
+
# Browser#check_remote_version, which feeds the same parser from CDP.
|
|
291
386
|
def check_minimum_version(binary_path)
|
|
292
387
|
stdout, = Open3.capture3(binary_path, "version")
|
|
293
|
-
@version =
|
|
294
|
-
|
|
295
|
-
if (build = @version[/(?:nightly|dev)\.(\d+)/, 1])
|
|
296
|
-
@nightly_build = Gem::Version.new(build)
|
|
297
|
-
return if @nightly_build >= MINIMUM_NIGHTLY_BUILD
|
|
298
|
-
elsif (tag = @version[/\A\d+\.\d+\.\d+\z/])
|
|
299
|
-
@release = Gem::Version.new(tag)
|
|
300
|
-
return if @release >= MINIMUM_RELEASE
|
|
301
|
-
end
|
|
302
|
-
|
|
303
|
-
raise BinaryError,
|
|
304
|
-
"Lightpanda #{@version} is too old. " \
|
|
305
|
-
"This gem requires nightly build >= #{MINIMUM_NIGHTLY_BUILD} " \
|
|
306
|
-
"or release >= #{MINIMUM_RELEASE}. " \
|
|
307
|
-
"Update: #{Binary.update_hint(binary_path)}"
|
|
388
|
+
@version, @nightly_build, @release =
|
|
389
|
+
self.class.check_version!(stdout) { Binary.update_hint(binary_path) }
|
|
308
390
|
rescue Errno::ENOENT
|
|
309
391
|
# Binary not runnable — let attempt_start handle it
|
|
310
392
|
end
|
|
@@ -369,10 +451,6 @@ module Capybara
|
|
|
369
451
|
@options.port.to_s,
|
|
370
452
|
"--log_level",
|
|
371
453
|
"info",
|
|
372
|
-
# External stylesheet fetch (PR #2487, build >= 6353 — enforced by the
|
|
373
|
-
# floor). Always on so linked CSS contributes to checkVisibility /
|
|
374
|
-
# getComputedStyle; see .claude/rules/lightpanda-io.md limitation #6.
|
|
375
|
-
"--enable-external-stylesheets",
|
|
376
454
|
# Raise the inbound CDP WebSocket message cap from Lightpanda's 1 MiB
|
|
377
455
|
# default (Config.zig `cdp_max_message_size`) to 100 MiB, matching
|
|
378
456
|
# Chrome's inbound DevTools buffer. An inbound message over the cap is
|
|
@@ -387,12 +465,23 @@ module Capybara
|
|
|
387
465
|
"--cdp-max-message-size",
|
|
388
466
|
(100 * 1024 * 1024).to_s,
|
|
389
467
|
]
|
|
390
|
-
#
|
|
391
|
-
#
|
|
392
|
-
#
|
|
393
|
-
#
|
|
394
|
-
#
|
|
395
|
-
|
|
468
|
+
# Sub-resource loading is one comma-separated `--load-resources` value
|
|
469
|
+
# (Config.LoadResources is a packed struct the CLI parser fills from a
|
|
470
|
+
# comma list — the flag is NOT repeatable). Every kind defaults OFF
|
|
471
|
+
# since #3305 (build 8946).
|
|
472
|
+
# stylesheet — always on (PR #2487, build >= 6353) so linked CSS
|
|
473
|
+
# contributes to checkVisibility / getComputedStyle; see
|
|
474
|
+
# .claude/rules/lightpanda-io.md limitation #5. The spelling
|
|
475
|
+
# `--load-resources stylesheet` is a fatal UnknownOption below 8946,
|
|
476
|
+
# which the floor clears.
|
|
477
|
+
# image — opt-in (upstream #3230, build >= 8834): images never feed
|
|
478
|
+
# the DOM predicates, so the default spends no bandwidth on them.
|
|
479
|
+
# See Options#load_images.
|
|
480
|
+
# iframe / worker — deliberately NOT here. Browser#configure_loading
|
|
481
|
+
# re-enables both over CDP (LP.configureLoading) per BrowserContext.
|
|
482
|
+
resources = ["stylesheet"]
|
|
483
|
+
resources << "image" if @options.load_images
|
|
484
|
+
base.push("--load-resources", resources.join(","))
|
|
396
485
|
extra = ENV.fetch("LIGHTPANDA_EXTRA_ARGS", "").split
|
|
397
486
|
base + extra
|
|
398
487
|
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.
|
|
4
|
+
version: 0.13.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Navid Emad
|
|
@@ -129,7 +129,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
129
129
|
- !ruby/object:Gem::Version
|
|
130
130
|
version: '0'
|
|
131
131
|
requirements: []
|
|
132
|
-
rubygems_version: 4.0.
|
|
132
|
+
rubygems_version: 4.0.20
|
|
133
133
|
specification_version: 4
|
|
134
134
|
summary: Capybara driver for the Lightpanda headless browser
|
|
135
135
|
test_files: []
|