capybara-lightpanda 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f039526ec34b172b3824416cd6b7679720cabcb75455a1c3c46bbf0cc11b5d7b
4
- data.tar.gz: '087e752b5baa7d09d43541e90654732e042f28535d53248fab9ab2769f656c01'
3
+ metadata.gz: a7ec772a2b4254837394ec6705d94c8759e750f0f2ba7ce12ac4d02bfdd30bf5
4
+ data.tar.gz: a65953b578a45f0f652f0f3dadc2334bfa6db4ae6c6f4d931da4d9730f3656a1
5
5
  SHA512:
6
- metadata.gz: b2430399381dbcb55f54eafbbb9fb81cc3c4ac5956e4bbc16d5dfe70fb1f347f7f8dfdf60044134c87ae739200911d22e986ce9b4d0a37a2f21ebf74887e50f1
7
- data.tar.gz: 500584b0da1e6bd82737e213fdd13e10395334e0c971923e2f7fdcb78758251f36f50361272b66c1838a781bf134b724c7b1cc77761dfb28a9b6cfbd8de65d31
6
+ metadata.gz: 95af3574b27e16d24845f3bcdacc78779c8a036a81d7ab68870de8fd8bc957b8557a2beb9812f85030e7e1e74d994abf2f0d43e116ebb3c87069c52006012429
7
+ data.tar.gz: 20bf49585073a9d06e76da5460aeb143d653de2dab9a7d1a11d55b16603e9d956737147d712403416f3a8818967193e3150ebc7ae04dbf248ab52b3c62a40495
data/CHANGELOG.md CHANGED
@@ -1,5 +1,30 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.9.0] - 2026-06-18
4
+
5
+ ### Fixed
6
+
7
+ - `send_keys(:ctrl, …)` (held or `[:ctrl, "a"]` array form) crashed with `NoMethodError` — `MODIFIERS` advertised `:ctrl` but `KEYS` lacked the entry. Both forms now dispatch Control correctly, and an unknown key symbol raises `ArgumentError` naming the key everywhere.
8
+ - `Driver#headers` no longer reports phantom values after `reset!` — the cached `extra_headers` are cleared with the disposed BrowserContext.
9
+ - A connection reset (RST) during the WebSocket handshake now raises `DeadBrowserError` like the FIN path instead of leaking a raw `Errno::ECONNRESET`.
10
+ - A duplicate CDP response frame for an already-answered command id no longer kills the process (`IVar#try_set` on the message thread).
11
+
12
+ ### Changed
13
+
14
+ - `Browser` is now composed of include-modules (`Browser::Runtime` / `Finder` / `Navigation` / `Modals` / `Console`) — pure code motion, public API unchanged (verified method-for-method by reflection).
15
+ - The Network CDP domain has a single owner: `Network` captures the navigation response behind `Browser#status_code` / `#response_headers`, and traffic tracking is always on (cleared per `reset`, ferrum parity). `driver.network.disable` now visibly owns the caveat that it freezes status tracking.
16
+ - Port-in-use recovery dispatches on a typed `PortInUseError` (subclass of `ProcessTimeoutError`, so existing rescues keep working) instead of matching the error message; HTTP download failures raise `BinaryError` instead of `BinaryNotFoundError`.
17
+ - `Driver#reset!` rescues the gem's error hierarchy (plus `SystemCallError`/`IOError`) instead of `StandardError`, and warns on respawn — programmer errors no longer degrade into a silent browser restart per test.
18
+ - `Node#shadow_root` routes through the guarded `#call` path: reading the shadow root of a detached host now raises `ObsoleteNode` (handled by Capybara's `automatic_reload`) instead of silently returning stale content.
19
+ - Arrays without modifier symbols passed to `send_keys` now type via `insertText`, consistent with plain strings (previously synthesized per-char keyDown/keyUp).
20
+
21
+ - No-args `evaluate_script` / `execute_script` send the expression with `replMode: true` (DevTools-console REPL semantics) instead of wrapping it in an IIFE. Top-level `const`/`let` can now be redeclared across calls *and* state persists between calls, matching what users see in the Chrome console. JS exceptions still raise `JavaScriptError`.
22
+
23
+ ### Removed
24
+
25
+ - Dead internal API swept (pre-1.0 cleanup; none had a caller or documented use): `Binary.run` / `.exec` / `.fetch` / `.version` / `.path` and the `Binary::Result` struct; `Browser#document_node_id`; `Client#ws_url` / `#options` readers; `WebSocket#open?`. Cuprite/Ferrum drop-in surface is deliberately KEPT and documented: `Options#window_size` / `#headless` (accepted, inert) and the never-raised `MouseEventFailed` / `NoSuchPageError` / `StatusError` (peer-taxonomy mirrors so migrated rescue lists keep loading).
26
+ - UPSTREAM_BUGS.md Bug #9 (`requestSubmit()` threw when a listener canceled the SubmitEvent) retired: fixed upstream, verified on the nightly this gem already requires. Contract tests pin both retired bugs in `test/features/upstream_bugs_test.rb`.
27
+
3
28
  ## [0.8.0] - 2026-06-12
4
29
 
5
30
  > **Update Lightpanda before upgrading.** Requires a nightly build ≥ 6736 (published 2026-06-12). The driver refuses to start against older binaries.
@@ -2,9 +2,35 @@
2
2
 
3
3
  module Capybara
4
4
  module Lightpanda
5
+ # Assembles the `_lightpanda` bundle injected once per session via
6
+ # Page.addScriptToEvaluateOnNewDocument (Browser#create_page).
7
+ #
8
+ # The bundle is split across plain-declaration source files in
9
+ # javascripts/ — none of which contain an IIFE or module syntax — so each
10
+ # file is readable in isolation and parses identically as a classic
11
+ # browser script and as a `new Function(...)` body (how the Bun harness in
12
+ # test/js/ loads predicates.js without a build step). This module is the
13
+ # "linker": it concatenates the parts in order and wraps them in the IIFE
14
+ # plus the idempotency guard.
5
15
  module AutoScripts
6
- JS_PATH = File.expand_path("javascripts/index.js", __dir__).freeze
7
- JS = File.read(JS_PATH).freeze
16
+ JS_DIR = File.expand_path("javascripts", __dir__).freeze
17
+
18
+ # Order matters: declarations (turbo, predicates) before the wiring
19
+ # (attach) that reads their names. banner is a leading comment block.
20
+ PARTS = %w[banner.js turbo.js predicates.js attach.js].freeze
21
+
22
+ # The guard short-circuits a repeat run before turbo.js can register its
23
+ # listeners a second time (double-registration would double-count
24
+ # _pendingTurboOps and desync the busy/idle sentinels console.rb reads).
25
+ # window._lightpanda is only set by attach.js (last), so the guard
26
+ # reflects "a previous full run completed".
27
+ JS = begin
28
+ body = PARTS.map { |name| File.read(File.join(JS_DIR, name)) }.join("\n")
29
+ "(function() {\n" \
30
+ "if (window._lightpanda) return;\n" \
31
+ "#{body}\n" \
32
+ "})();\n"
33
+ end.freeze
8
34
  end
9
35
  end
10
36
  end
@@ -9,20 +9,6 @@ require "uri"
9
9
  module Capybara
10
10
  module Lightpanda
11
11
  class Binary
12
- Result = Struct.new(:stdout, :stderr, :status) do
13
- def success?
14
- status.success?
15
- end
16
-
17
- def exit_code
18
- status.exitstatus
19
- end
20
-
21
- def output
22
- stdout.empty? ? stderr : stdout
23
- end
24
- end
25
-
26
12
  GITHUB_RELEASE_URL = "https://github.com/lightpanda-io/browser/releases/download"
27
13
 
28
14
  PLATFORMS = {
@@ -71,10 +57,6 @@ module Capybara
71
57
  yield self
72
58
  end
73
59
 
74
- def path
75
- @path ||= update
76
- end
77
-
78
60
  # Canonical entrypoint: ensure the binary at install_path is current,
79
61
  # download if needed, return its path. Pinned (required_version set)
80
62
  # never re-downloads when present. Unpinned re-downloads when older
@@ -145,7 +127,6 @@ module Capybara
145
127
  end
146
128
 
147
129
  File.delete(path)
148
- @path = nil
149
130
  log("Removed #{path}")
150
131
  path
151
132
  end
@@ -162,30 +143,6 @@ module Capybara
162
143
  nil
163
144
  end
164
145
 
165
- def run(*)
166
- stdout, stderr, status = Open3.capture3(path, *)
167
-
168
- Result.new(stdout: stdout, stderr: stderr, status: status)
169
- rescue Errno::ENOENT
170
- raise BinaryNotFoundError, "Lightpanda binary not found"
171
- end
172
-
173
- def exec(*)
174
- Kernel.exec(path, *)
175
- end
176
-
177
- def fetch(url)
178
- result = run("fetch", "--dump", url)
179
- raise BinaryError, result.stderr unless result.success?
180
-
181
- result.stdout
182
- end
183
-
184
- def version
185
- result = run("version")
186
- result.output.strip
187
- end
188
-
189
146
  def download
190
147
  binary_name = platform_binary
191
148
  tag = required_version || "nightly"
@@ -197,7 +154,6 @@ module Capybara
197
154
 
198
155
  download_file(url, destination)
199
156
  FileUtils.chmod(0o755, destination)
200
- @path = destination
201
157
 
202
158
  destination
203
159
  end
@@ -314,7 +270,7 @@ module Capybara
314
270
  end
315
271
 
316
272
  def follow_redirects(uri, destination, limit = 10)
317
- raise BinaryNotFoundError, "Too many redirects" if limit.zero?
273
+ raise BinaryError, "Too many redirects" if limit.zero?
318
274
 
319
275
  http_start(uri) do |http|
320
276
  request = Net::HTTP::Get.new(uri)
@@ -329,7 +285,7 @@ module Capybara
329
285
  log("Redirected → #{response['location']}")
330
286
  follow_redirects(URI.parse(response["location"]), destination, limit - 1)
331
287
  else
332
- raise BinaryNotFoundError, "Failed to download binary: #{response.code} #{response.message}"
288
+ raise BinaryError, "Failed to download binary: #{response.code} #{response.message}"
333
289
  end
334
290
  end
335
291
  end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Capybara
4
+ module Lightpanda
5
+ class Browser
6
+ # Runtime.consoleAPICalled consumers: the user-facing console_logs
7
+ # ring buffer, the optional IO-logger stream, and the Turbo
8
+ # busy/idle sentinel tracking behind Browser#wait_for_idle.
9
+ module Console
10
+ # Console messages captured from `Runtime.consoleAPICalled` since the
11
+ # last `reset` (Turbo-tracker sentinels excluded). Loose hashes, like
12
+ # Network#traffic: `{type:, text:, timestamp:, args:}` where `type` is
13
+ # the console method name ("log", "error", "warning", ...), `text` joins
14
+ # the arguments' primitive values/descriptions, and `args` keeps the raw
15
+ # CDP RemoteObjects. Lets suites assert on JS console errors
16
+ # (`browser.console_logs.select { |m| m[:type] == "error" }`) the way
17
+ # peer drivers do via custom Ferrum loggers.
18
+ def console_logs
19
+ @console_logs_mutex.synchronize { @console_logs.dup }
20
+ end
21
+
22
+ def clear_console_logs
23
+ @console_logs_mutex.synchronize { @console_logs.clear }
24
+ end
25
+
26
+ private
27
+
28
+ def subscribe_to_console_logs
29
+ logger = @options.logger
30
+ return unless logger
31
+
32
+ on("Runtime.consoleAPICalled") do |params|
33
+ params["args"]&.each do |r|
34
+ value = r["value"]
35
+ next if turbo_sentinel?(value)
36
+
37
+ logger.puts(value)
38
+ end
39
+ end
40
+ end
41
+
42
+ TURBO_SENTINEL_PREFIX = "__lightpanda_turbo_"
43
+ private_constant :TURBO_SENTINEL_PREFIX
44
+
45
+ # The Turbo activity tracker signals busy/idle via console.debug
46
+ # sentinels (see subscribe_to_turbo_signals); every consoleAPICalled
47
+ # consumer must filter them out of user-facing output.
48
+ def turbo_sentinel?(value)
49
+ value.is_a?(String) && value.start_with?(TURBO_SENTINEL_PREFIX)
50
+ end
51
+
52
+ # Oldest entries are dropped past this cap so a chatty page can't grow
53
+ # the buffer unbounded across a long session.
54
+ CONSOLE_LOGS_LIMIT = 1_000
55
+
56
+ # Ring-buffer every console.* call for `Browser#console_logs`. Separate
57
+ # from subscribe_to_console_logs (which streams to an optional IO logger)
58
+ # so capture works without any logger configured. Skips the Turbo
59
+ # activity-tracker sentinels — they're driver plumbing, not page output.
60
+ def subscribe_to_console_capture
61
+ on("Runtime.consoleAPICalled") do |params|
62
+ args = params["args"]
63
+ next unless args.is_a?(Array)
64
+
65
+ first = args.first&.dig("value")
66
+ next if turbo_sentinel?(first)
67
+
68
+ entry = {
69
+ type: params["type"],
70
+ text: args.map { |a| a.fetch("value") { a["description"] }.to_s }.join(" "),
71
+ timestamp: params["timestamp"],
72
+ args: args,
73
+ }
74
+ @console_logs_mutex.synchronize do
75
+ @console_logs << entry
76
+ @console_logs.shift(@console_logs.size - CONSOLE_LOGS_LIMIT) if @console_logs.size > CONSOLE_LOGS_LIMIT
77
+ end
78
+ end
79
+ end
80
+
81
+ # Wire @turbo_event to the JS-side _signalTurbo emissions. The JS calls
82
+ # console.debug('__lightpanda_turbo_busy') / '_idle' on transitions across
83
+ # zero pending ops; Lightpanda forwards those to Runtime.consoleAPICalled.
84
+ # Idle → set the event (wakes any waiter); busy → reset.
85
+ #
86
+ # On Runtime.executionContextsCleared (navigation), unconditionally set
87
+ # the event: if we navigated away mid-busy state, no further idle signal
88
+ # would ever come from the old context, and we'd block for the full
89
+ # timeout. The new context will signal busy again if Turbo is active.
90
+ def subscribe_to_turbo_signals
91
+ on("Runtime.consoleAPICalled") do |params|
92
+ next unless params["args"].is_a?(Array)
93
+
94
+ marker = params["args"].first&.dig("value")
95
+ next unless turbo_sentinel?(marker)
96
+
97
+ case marker
98
+ when "#{TURBO_SENTINEL_PREFIX}busy" then @turbo_event.reset
99
+ when "#{TURBO_SENTINEL_PREFIX}idle" then @turbo_event.set
100
+ end
101
+ end
102
+
103
+ on("Runtime.executionContextsCleared") { @turbo_event.set }
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,196 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Capybara
4
+ module Lightpanda
5
+ class Browser
6
+ # Element finding in the three dispatch contexts (document, node-
7
+ # scoped, iframe) plus the shared XPath/CSS find fragments and
8
+ # InvalidSelector translation.
9
+ module Finder
10
+ # Find elements in the current context (top frame or active frame).
11
+ # Returns an array of remote object ID strings.
12
+ def find(method, selector)
13
+ if @frame_stack.empty?
14
+ find_in_document(method, selector)
15
+ else
16
+ find_in_frame(method, selector)
17
+ end
18
+ end
19
+
20
+ # Find child elements within a specific node.
21
+ # Returns an array of remote object ID strings.
22
+ #
23
+ # Wrapped in `with_default_context_wait` so a click that triggered a
24
+ # navigation immediately before the find (e.g. a fill_in following a
25
+ # link that mutated the DOM) doesn't race against
26
+ # `Runtime.executionContextCreated` and surface as
27
+ # `NoExecutionContextError`. `find_in_document` and `find_in_frame`
28
+ # already use the same wrapper; `find_within` was the odd one out.
29
+ def find_within(remote_object_id, method, selector)
30
+ with_default_context_wait do
31
+ result = call_function_on(remote_object_id, FIND_WITHIN_JS, method, selector, return_by_value: false)
32
+ extract_node_object_ids(result)
33
+ end
34
+ rescue JavaScriptError => e
35
+ raise_invalid_selector(e, method, selector)
36
+ end
37
+
38
+ # Ancestor chain of `remote_object_id` from parentNode up to (but
39
+ # excluding) `document`, returned as an array of remote object IDs.
40
+ # Mirrors Cuprite's JS `parents` helper. Same `with_default_context_wait`
41
+ # wrapping as `find_within` — same race window applies.
42
+ def parents_of(remote_object_id)
43
+ with_default_context_wait do
44
+ result = call_function_on(remote_object_id, PARENTS_JS, return_by_value: false)
45
+ extract_node_object_ids(result)
46
+ end
47
+ end
48
+
49
+ private
50
+
51
+ # Sentinel string thrown from FIND_*_JS when querySelectorAll rejects a
52
+ # malformed selector, so the Ruby side can convert JavaScriptError into
53
+ # Capybara::Lightpanda::InvalidSelector. Cuprite uses a JS subclass for
54
+ # the same purpose; a plain prefixed string keeps our inline JS simple.
55
+ INVALID_SELECTOR_MARKER = "LIGHTPANDA_INVALID_SELECTOR:"
56
+
57
+ # The find algorithms exist in three dispatch contexts — element-scoped
58
+ # (FIND_WITHIN_JS), iframe-scoped (FIND_IN_FRAME_JS), and document-scoped
59
+ # (find_in_document's Runtime.evaluate fast path) — that differ only in
60
+ # how the document/root/selector expressions are derived. Each algorithm
61
+ # is defined ONCE here and instantiated per context via format(), so a
62
+ # fix (e.g. a new XPath error case) can't silently miss a copy.
63
+ #
64
+ # XPath routes through native `Document.evaluate` + `XPathResult`
65
+ # (Lightpanda PR #2305, in nightly >=6109); on parse error we return
66
+ # [] silently to match Capybara's internal XPath generator, which
67
+ # sometimes produces selectors with empty trailing predicates like
68
+ # `(...)[]` that native rejects but `has_element?` expects to behave
69
+ # as "not found" rather than raise InvalidSelector.
70
+ # `XPathResult.ORDERED_NODE_SNAPSHOT_TYPE` is `7` in the spec — inlined
71
+ # so the JS doesn't depend on the enum being defined as a constant.
72
+ XPATH_FIND_FRAGMENT = <<~JS
73
+ try {
74
+ var r = %<doc>s.evaluate(%<selector>s, %<root>s, null, 7, null);
75
+ var nodes = [];
76
+ for (var i = 0; i < r.snapshotLength; i++) nodes.push(r.snapshotItem(i));
77
+ return nodes;
78
+ } catch(e) { return []; }
79
+ JS
80
+
81
+ # For CSS, any throw from querySelectorAll means the selector is
82
+ # malformed — re-throw with the marker prefix so Ruby converts to
83
+ # InvalidSelector.
84
+ CSS_FIND_FRAGMENT = <<~JS.freeze
85
+ try { return Array.from(%<target>s.querySelectorAll(%<selector>s)); }
86
+ catch(e) { throw new Error('#{INVALID_SELECTOR_MARKER}' + %<selector>s); }
87
+ JS
88
+ private_constant :XPATH_FIND_FRAGMENT, :CSS_FIND_FRAGMENT
89
+
90
+ # JS function for finding elements within a node.
91
+ # Works in any execution context (top frame or iframe).
92
+ FIND_WITHIN_JS = <<~JS.freeze
93
+ function(method, selector) {
94
+ if (method === 'xpath') {
95
+ #{format(XPATH_FIND_FRAGMENT, doc: 'this.ownerDocument', root: 'this', selector: 'selector')}
96
+ }
97
+ #{format(CSS_FIND_FRAGMENT, target: 'this', selector: 'selector')}
98
+ }
99
+ JS
100
+ private_constant :FIND_WITHIN_JS
101
+
102
+ # JS function for finding elements in an iframe's contentDocument.
103
+ FIND_IN_FRAME_JS = <<~JS.freeze
104
+ function(method, selector) {
105
+ var doc;
106
+ try { doc = this.contentDocument || (this.contentWindow && this.contentWindow.document); } catch(e) {}
107
+ if (!doc) return [];
108
+ if (method === 'xpath') {
109
+ #{format(XPATH_FIND_FRAGMENT, doc: 'doc', root: 'doc', selector: 'selector')}
110
+ }
111
+ #{format(CSS_FIND_FRAGMENT, target: 'doc', selector: 'selector')}
112
+ }
113
+ JS
114
+ private_constant :FIND_IN_FRAME_JS
115
+
116
+ # Walks `parentNode` from `this` up to (but excluding) `document`,
117
+ # returning the chain as a JS array. Each entry is an element node so
118
+ # `extract_node_object_ids` can wrap them as Lightpanda::Nodes.
119
+ PARENTS_JS = <<~JS
120
+ function() {
121
+ var nodes = [];
122
+ var p = this.parentNode;
123
+ while (p && p !== this.ownerDocument) {
124
+ nodes.push(p);
125
+ p = p.parentNode;
126
+ }
127
+ return nodes;
128
+ }
129
+ JS
130
+ private_constant :PARENTS_JS
131
+
132
+ def find_in_document(method, selector)
133
+ with_default_context_wait do
134
+ # Coerce Symbol selectors (e.g. Capybara warning path lets `have_css(:p)`
135
+ # through) to a string before quoting. Symbol#inspect returns `:p`,
136
+ # which would inject a bare token into the JS source.
137
+ selector_literal = selector.to_s.inspect
138
+ # Same fragments as FIND_WITHIN_JS/FIND_IN_FRAME_JS, instantiated
139
+ # with the selector embedded as a literal: this hot path keeps its
140
+ # single Runtime.evaluate round-trip (no document-handle resolution).
141
+ fragment = if method == "xpath"
142
+ format(XPATH_FIND_FRAGMENT, doc: "document", root: "document", selector: selector_literal)
143
+ else
144
+ format(CSS_FIND_FRAGMENT, target: "document", selector: selector_literal)
145
+ end
146
+ result = evaluate_with_ref("(function() { #{fragment} })()")
147
+ extract_node_object_ids(result)
148
+ end
149
+ rescue JavaScriptError => e
150
+ raise_invalid_selector(e, method, selector)
151
+ end
152
+
153
+ def find_in_frame(method, selector)
154
+ with_default_context_wait do
155
+ frame_node = @frame_stack.last
156
+ result = call_function_on(frame_node.remote_object_id, FIND_IN_FRAME_JS, method, selector,
157
+ return_by_value: false)
158
+ extract_node_object_ids(result)
159
+ end
160
+ rescue JavaScriptError => e
161
+ raise_invalid_selector(e, method, selector)
162
+ end
163
+
164
+ def raise_invalid_selector(js_error, method, selector)
165
+ if js_error.message.include?(INVALID_SELECTOR_MARKER)
166
+ raise InvalidSelector.new("Invalid #{method} selector: #{selector.inspect}", method, selector)
167
+ end
168
+
169
+ raise js_error
170
+ end
171
+
172
+ # Extract individual node objectIds from a remote array reference.
173
+ # `ensure release_object` so the outer array handle is freed even when
174
+ # property walking raises — without this, a transient CDP error during
175
+ # property enumeration leaks one V8 handle per failed find call.
176
+ def extract_node_object_ids(result)
177
+ return [] unless result && result["objectId"]
178
+
179
+ outer_id = result["objectId"]
180
+ begin
181
+ props = get_object_properties(outer_id)
182
+ properties = props["result"] || []
183
+ properties
184
+ .select { |p| p["name"] =~ /\A\d+\z/ }
185
+ .sort_by { |p| p["name"].to_i }
186
+ .filter_map { |p| p.dig("value", "objectId") }
187
+ rescue Error
188
+ []
189
+ ensure
190
+ release_object(outer_id)
191
+ end
192
+ end
193
+ end
194
+ end
195
+ end
196
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Capybara
4
+ module Lightpanda
5
+ class Browser
6
+ # JS dialog handling via Lightpanda's LP.handleJavaScriptDialog
7
+ # pre-arm model (PR #2261): accept/dismiss are sent BEFORE the
8
+ # triggering action; Page.javascriptDialogOpening supplies the text.
9
+ module Modals
10
+ # -- Modal/Dialog Support --
11
+ # Lightpanda's JS dialogs (alert/confirm/prompt) are driven via the
12
+ # `LP.handleJavaScriptDialog` pre-arm model (PR #2261, nightly ≥5900):
13
+ # the client sends `LP.handleJavaScriptDialog {accept, promptText}`
14
+ # BEFORE the action that triggers the dialog, and the response is
15
+ # consumed when the dialog opens. `Page.javascriptDialogOpening` still
16
+ # fires, so we capture the message text for `find_modal`. Single-shot:
17
+ # `pending_dialog_response` is one slot, so a second pre-arm before
18
+ # the first dialog opens overwrites the first.
19
+
20
+ def prepare_modals
21
+ return if @modal_handler_installed
22
+
23
+ enable_page_events
24
+
25
+ on("Page.javascriptDialogOpening") do |params|
26
+ entry = { type: params["type"], message: params["message"] }
27
+ @modal_messages_mutex.synchronize { @modal_messages << entry }
28
+ end
29
+
30
+ @modal_handler_installed = true
31
+ end
32
+
33
+ def accept_modal(_type, text: nil)
34
+ prepare_modals
35
+ params = { accept: true }
36
+ params[:promptText] = text if text
37
+ page_command("LP.handleJavaScriptDialog", **params)
38
+ end
39
+
40
+ def dismiss_modal(_type)
41
+ prepare_modals
42
+ page_command("LP.handleJavaScriptDialog", accept: false)
43
+ end
44
+
45
+ # `type` is accepted for the error message only: like Selenium (where
46
+ # alert/confirm are indistinguishable) and Cuprite (whose dialog handler
47
+ # accepts whatever fires), we deliberately do NOT reject a dialog whose
48
+ # reported type differs from the one Capybara asked for. Real suites
49
+ # wrap `data-confirm` deletes in `accept_alert` (e.g. solidus admin) and
50
+ # expect it to work; only the message text is matched.
51
+ def find_modal(type, text: nil, wait: options.timeout)
52
+ regexp = text.is_a?(Regexp) ? text : (text && Regexp.new(Regexp.escape(text.to_s)))
53
+ last_seen_message = nil
54
+ claimed = nil
55
+ Utils::Wait.until(timeout: wait, interval: 0.05) do
56
+ claimed = pop_modal_message(regexp)
57
+ next true if claimed
58
+
59
+ last_seen_message = peek_last_modal_message || last_seen_message
60
+ false
61
+ end
62
+ claimed[:message]
63
+ rescue TimeoutError
64
+ raise_modal_not_found(type, text, last_seen_message)
65
+ end
66
+
67
+ private
68
+
69
+ # Pop the first queued dialog whose message matches the requested
70
+ # pattern (any dialog when `regexp` is nil). Returns the entry or nil.
71
+ # Serialized with the message-thread writer.
72
+ def pop_modal_message(regexp)
73
+ @modal_messages_mutex.synchronize do
74
+ match = @modal_messages.find do |m|
75
+ regexp.nil? || m[:message].to_s.match?(regexp)
76
+ end
77
+ @modal_messages.delete(match) if match
78
+ match
79
+ end
80
+ end
81
+
82
+ # Most recent dialog message of any type, for diagnostics.
83
+ def peek_last_modal_message
84
+ @modal_messages_mutex.synchronize { @modal_messages.last&.dig(:message) }
85
+ end
86
+
87
+ def raise_modal_not_found(type, text, last_seen_message)
88
+ if last_seen_message
89
+ raise Capybara::ModalNotFound,
90
+ "Unable to find #{type} modal with #{text} - found '#{last_seen_message}' instead."
91
+ end
92
+ raise Capybara::ModalNotFound, "Unable to find modal dialog#{" with #{text}" if text}"
93
+ end
94
+
95
+ private :prepare_modals
96
+ end
97
+ end
98
+ end
99
+ end