capybara-lightpanda 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +62 -0
  3. data/README.md +14 -1
  4. data/lib/capybara/lightpanda/auto_scripts.rb +30 -2
  5. data/lib/capybara/lightpanda/binary.rb +72 -54
  6. data/lib/capybara/lightpanda/browser/console.rb +190 -0
  7. data/lib/capybara/lightpanda/browser/finder.rb +196 -0
  8. data/lib/capybara/lightpanda/browser/modals.rb +150 -0
  9. data/lib/capybara/lightpanda/browser/navigation.rb +186 -0
  10. data/lib/capybara/lightpanda/browser/runtime.rb +258 -0
  11. data/lib/capybara/lightpanda/browser/selenium_compat.rb +124 -0
  12. data/lib/capybara/lightpanda/browser.rb +158 -813
  13. data/lib/capybara/lightpanda/client/subscriber.rb +2 -0
  14. data/lib/capybara/lightpanda/client/web_socket.rb +19 -21
  15. data/lib/capybara/lightpanda/client.rb +5 -4
  16. data/lib/capybara/lightpanda/downloads.rb +176 -0
  17. data/lib/capybara/lightpanda/driver.rb +124 -32
  18. data/lib/capybara/lightpanda/errors.rb +24 -10
  19. data/lib/capybara/lightpanda/javascripts/attach.js +16 -0
  20. data/lib/capybara/lightpanda/javascripts/banner.js +15 -0
  21. data/lib/capybara/lightpanda/javascripts/errors.js +74 -0
  22. data/lib/capybara/lightpanda/javascripts/predicates.js +116 -0
  23. data/lib/capybara/lightpanda/javascripts/turbo.js +67 -0
  24. data/lib/capybara/lightpanda/keyboard.rb +23 -19
  25. data/lib/capybara/lightpanda/network.rb +103 -18
  26. data/lib/capybara/lightpanda/node.rb +200 -95
  27. data/lib/capybara/lightpanda/options.rb +44 -3
  28. data/lib/capybara/lightpanda/process.rb +154 -29
  29. data/lib/capybara/lightpanda/version.rb +1 -1
  30. data/lib/capybara-lightpanda.rb +1 -0
  31. metadata +14 -3
  32. data/lib/capybara/lightpanda/javascripts/index.js +0 -226
@@ -58,6 +58,8 @@ module Capybara
58
58
  end
59
59
  end
60
60
 
61
+ # Test seam: no production caller, but subscriber_test asserts
62
+ # unsubscribe/clear semantics through it.
61
63
  def subscribed?(event)
62
64
  @mutex.synchronize { @subscriptions.key?(event) && @subscriptions[event].any? }
63
65
  end
@@ -4,6 +4,8 @@ require "json"
4
4
  require "socket"
5
5
  require "websocket/driver"
6
6
 
7
+ require_relative "../utils/attempt"
8
+
7
9
  module Capybara
8
10
  module Lightpanda
9
11
  class Client
@@ -36,7 +38,7 @@ module Capybara
36
38
 
37
39
  @status = :closing
38
40
  @messages.close
39
- @driver&.close
41
+ @driver_mutex.synchronize { @driver&.close }
40
42
  @thread&.join(1) || @thread&.kill
41
43
  @socket&.close
42
44
  @status = :closed
@@ -46,15 +48,10 @@ module Capybara
46
48
  @status == :closed || @status == :error
47
49
  end
48
50
 
49
- def open?
50
- @status == :open
51
- end
52
-
53
51
  def write(data)
54
52
  @socket.write(data)
55
53
  rescue Errno::EPIPE, Errno::ECONNRESET, IOError
56
- @status = :closed
57
- @messages.close
54
+ mark_dead
58
55
  end
59
56
 
60
57
  private
@@ -73,13 +70,9 @@ module Capybara
73
70
  start_reader_thread
74
71
  end
75
72
 
76
- def connect_with_retry(host, port, retries: 10, delay: 0.1)
77
- retries.times do |i|
78
- return TCPSocket.new(host, port)
79
- rescue Errno::ECONNREFUSED
80
- raise if i == retries - 1
81
-
82
- sleep delay
73
+ def connect_with_retry(host, port)
74
+ Utils::Attempt.with_retry(errors: Errno::ECONNREFUSED, max: 10, wait: 0.1) do
75
+ TCPSocket.new(host, port)
83
76
  end
84
77
  end
85
78
 
@@ -97,8 +90,7 @@ module Capybara
97
90
  end
98
91
 
99
92
  @driver.on(:close) do
100
- @status = :closed
101
- @messages.close
93
+ mark_dead
102
94
  end
103
95
 
104
96
  @driver.on(:error) do |event|
@@ -109,8 +101,7 @@ module Capybara
109
101
  # process. Mark the connection dead and let Client#command
110
102
  # surface DeadBrowserError on its next dispatch via closed?.
111
103
  @logger&.puts("✗ WebSocket error: #{event.message}")
112
- @status = :error
113
- @messages.close
104
+ mark_dead(:error)
114
105
  end
115
106
  end
116
107
 
@@ -127,8 +118,7 @@ module Capybara
127
118
  data = @socket.readpartial(4096)
128
119
  @driver_mutex.synchronize { @driver.parse(data) }
129
120
  rescue Errno::ECONNRESET, Errno::EPIPE, IOError
130
- @status = :closed
131
- @messages.close
121
+ mark_dead
132
122
  break
133
123
  end
134
124
  end
@@ -144,7 +134,7 @@ module Capybara
144
134
  begin
145
135
  data = @socket.readpartial(4096)
146
136
  @driver.parse(data)
147
- rescue EOFError
137
+ rescue Errno::ECONNRESET, Errno::EPIPE, IOError # IOError covers EOFError
148
138
  raise DeadBrowserError, "Connection closed during handshake"
149
139
  end
150
140
  end
@@ -154,6 +144,14 @@ module Capybara
154
144
  raise TimeoutError, "WebSocket handshake timed out after #{@options.handshake_timeout}s"
155
145
  end
156
146
 
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
+
157
155
  def parse_message(data)
158
156
  JSON.parse(data, max_nesting: false)
159
157
  rescue JSON::ParserError => e
@@ -9,10 +9,7 @@ require_relative "client/subscriber"
9
9
  module Capybara
10
10
  module Lightpanda
11
11
  class Client
12
- attr_reader :ws_url, :options
13
-
14
12
  def initialize(ws_url, options)
15
- @ws_url = ws_url
16
13
  @options = options
17
14
  @ws = WebSocket.new(ws_url, options)
18
15
  @command_id = 0
@@ -118,7 +115,11 @@ module Capybara
118
115
  def handle_message(message)
119
116
  if message["id"]
120
117
  pending = @pendings[message["id"]]
121
- pending&.set(message)
118
+ # try_set, not set: a duplicate frame for an already-answered id
119
+ # (Lightpanda emits occasional malformed/duplicate frames — see
120
+ # upstream-wishlist.md A41) would raise MultipleAssignmentError on
121
+ # this thread, and abort_on_exception would kill the whole process.
122
+ pending&.try_set(message)
122
123
  elsif message["method"]
123
124
  @subscriber.dispatch(message["method"], message["params"])
124
125
  end
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Capybara
6
+ module Lightpanda
7
+ # File-download tracker built on `Browser.setDownloadBehavior` (upstream PR
8
+ # #2722, build >= 7545, guaranteed by MINIMUM_NIGHTLY_BUILD). When a
9
+ # navigation response carries `Content-Disposition: attachment`, Lightpanda
10
+ # streams the body to disk under `downloadPath` and — with
11
+ # `eventsEnabled: true` — emits `Browser.downloadWillBegin` /
12
+ # `Browser.downloadProgress`. We mirror those into a completed-files list +
13
+ # a blocking `wait`, the way ferrum's Downloads does.
14
+ #
15
+ # IMPORTANT: the trigger is `Content-Disposition: attachment`, NOT MIME
16
+ # type. A `text/csv` (or any) response without that header is rendered as a
17
+ # normal navigation, not downloaded — which is why Capybara's MIME-triggered
18
+ # `:download` shared spec stays in capybara_skip.
19
+ #
20
+ # Structure deliberately mirrors Network (same browser ref, same mutex +
21
+ # subscribe/unsubscribe lifecycle, same reset-after-disposeBrowserContext
22
+ # contract) so the two trackers behave identically under create_page/reset.
23
+ class Downloads
24
+ attr_reader :browser, :path
25
+
26
+ # How long #wait gives a download to BEGIN (the downloadWillBegin frame
27
+ # can arrive a beat after the click that triggers it, once the click's
28
+ # own wait_for_idle has already returned). Once a download has begun we
29
+ # wait the full `timeout` for it to finish.
30
+ DOWNLOAD_BEGIN_GRACE = 1.0
31
+
32
+ def initialize(browser)
33
+ @browser = browser
34
+ @path = nil
35
+ @enabled = false
36
+ @mutex = Mutex.new
37
+ @pending = {} # guid => on-disk basename (from suggestedFilename)
38
+ @files = [] # absolute paths of completed downloads, in order
39
+ @started = 0 # monotonic count of downloads begun this session
40
+ @will_handler = nil
41
+ @progress_handler = nil
42
+ end
43
+
44
+ # Opt into downloads, writing completed files under `save_path`. No-op
45
+ # when `save_path` is blank — downloads then stay at Lightpanda's `deny`
46
+ # default. Connection-scoped (browser.command) like Network.enable;
47
+ # Browser#create_page calls this after the context is loaded (the CDP
48
+ # method is a no-op without one). Subscribe BEFORE the wire toggle so no
49
+ # event can slip past, and roll the handlers back if the command fails.
50
+ def enable(save_path)
51
+ return if @enabled || save_path.to_s.empty?
52
+
53
+ @path = File.expand_path(save_path.to_s)
54
+ FileUtils.mkdir_p(@path)
55
+ subscribe
56
+ begin
57
+ browser.command("Browser.setDownloadBehavior",
58
+ behavior: "allow", downloadPath: @path, eventsEnabled: true)
59
+ rescue StandardError
60
+ unsubscribe
61
+ @path = nil
62
+ raise
63
+ end
64
+ @enabled = true
65
+ end
66
+
67
+ # Absolute paths of downloads completed this session (newest last).
68
+ def files
69
+ @mutex.synchronize { @files.dup }
70
+ end
71
+
72
+ # Monotonic count of downloads that have begun this session. Used by
73
+ # #wait to detect "a download started since I was called" even after the
74
+ # event queue has drained and @pending is empty again.
75
+ def started_count
76
+ @mutex.synchronize { @started }
77
+ end
78
+
79
+ def in_progress?
80
+ @mutex.synchronize { @pending.any? }
81
+ end
82
+
83
+ # Block until in-flight downloads finish, then return the completed-file
84
+ # list. Non-raising, matching Network#wait_for_idle.
85
+ #
86
+ # The triggering click returns (its wait_for_idle settles on the
87
+ # navigation's network response) slightly BEFORE the downloadWillBegin
88
+ # frame is processed, so a naive `wait until !in_progress?` would see an
89
+ # empty queue and return before the download even registered. Instead:
90
+ # give a download up to DOWNLOAD_BEGIN_GRACE to begin (tracked by the
91
+ # monotonic @started counter, immune to the queue already having drained),
92
+ # then wait up to `timeout` for every in-flight download to finish.
93
+ def wait(timeout: 5)
94
+ baseline = started_count
95
+ clock = -> { ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) }
96
+ hard_deadline = clock.call + timeout
97
+ begin_deadline = clock.call + [timeout, DOWNLOAD_BEGIN_GRACE].min
98
+
99
+ loop do
100
+ began = started_count > baseline
101
+ done = (began ? !in_progress? : clock.call >= begin_deadline) || clock.call >= hard_deadline
102
+ return files if done
103
+
104
+ sleep 0.02
105
+ end
106
+ end
107
+
108
+ def clear
109
+ @mutex.synchronize do
110
+ @files.clear
111
+ @pending.clear
112
+ @started = 0
113
+ end
114
+ end
115
+
116
+ # Wipe local state after Target.disposeBrowserContext, which drops both
117
+ # the subscriptions and the per-connection download config — leaving
118
+ # @enabled true would no-op the next #enable. Mirrors Network#reset.
119
+ def reset
120
+ unsubscribe
121
+ clear
122
+ @enabled = false
123
+ @path = nil
124
+ end
125
+
126
+ private
127
+
128
+ def subscribe
129
+ @will_handler = build_will_handler
130
+ @progress_handler = build_progress_handler
131
+ browser.on("Browser.downloadWillBegin", &@will_handler)
132
+ browser.on("Browser.downloadProgress", &@progress_handler)
133
+ end
134
+
135
+ # downloadWillBegin carries {guid, url, suggestedFilename}. behavior
136
+ # "allow" writes the file under the (path-stripped) suggested name, so
137
+ # remember guid => basename to resolve the absolute path on completion.
138
+ def build_will_handler
139
+ lambda do |params|
140
+ guid = params["guid"]
141
+ name = File.basename(params["suggestedFilename"].to_s)
142
+ next if guid.nil? || name.empty?
143
+
144
+ @mutex.synchronize do
145
+ @pending[guid] = name
146
+ @started += 1
147
+ end
148
+ end
149
+ end
150
+
151
+ # downloadProgress carries {guid, state}. On "completed" the file is on
152
+ # disk at downloadPath/basename; "canceled" just drops the pending entry.
153
+ def build_progress_handler
154
+ lambda do |params|
155
+ guid = params["guid"]
156
+ case params["state"]
157
+ when "completed"
158
+ @mutex.synchronize do
159
+ name = @pending.delete(guid)
160
+ @files << File.join(@path, name) if name && @path
161
+ end
162
+ when "canceled"
163
+ @mutex.synchronize { @pending.delete(guid) }
164
+ end
165
+ end
166
+ end
167
+
168
+ def unsubscribe
169
+ browser.off("Browser.downloadWillBegin", @will_handler) if @will_handler
170
+ browser.off("Browser.downloadProgress", @progress_handler) if @progress_handler
171
+ @will_handler = nil
172
+ @progress_handler = nil
173
+ end
174
+ end
175
+ end
176
+ end
@@ -10,7 +10,7 @@ module Capybara
10
10
 
11
11
  attr_reader :app, :options
12
12
 
13
- delegate %i[current_url title status_code response_headers] => :browser
13
+ delegate %i[current_url title status_code response_headers frame_url frame_title] => :browser
14
14
 
15
15
  def initialize(app, options = {})
16
16
  super()
@@ -26,9 +26,7 @@ module Capybara
26
26
  end
27
27
 
28
28
  def browser_alive?
29
- @browser.client && !@browser.client.closed?
30
- rescue StandardError
31
- false
29
+ !@browser.nil? && @browser.alive?
32
30
  end
33
31
 
34
32
  # Escape hatch to the underlying Browser for callers that need raw CDP
@@ -119,6 +117,22 @@ module Capybara
119
117
  network.wait_for_idle(timeout: timeout, connections: connections)
120
118
  end
121
119
 
120
+ # -- Downloads --
121
+ # Files downloaded since the session started (absolute paths). Capture is
122
+ # on whenever a destination exists (the :save_path driver option, else
123
+ # Capybara.save_path) and the server sends `Content-Disposition:
124
+ # attachment`. Ferrum/Cuprite expose downloads on the driver too.
125
+
126
+ def downloads
127
+ browser.downloads.files
128
+ end
129
+
130
+ # Block until in-flight downloads finish (or timeout); returns the file
131
+ # list. Click the download trigger first, then call this.
132
+ def wait_for_download(timeout: 5)
133
+ browser.downloads.wait(timeout: timeout)
134
+ end
135
+
122
136
  # -- Cookie Management --
123
137
 
124
138
  def set_cookie(name, value, **options)
@@ -160,42 +174,97 @@ module Capybara
160
174
  end
161
175
  end
162
176
 
163
- # Capybara::Driver::Base falls back to running these via the top
164
- # execution context, which always reports the parent document. Resolve
165
- # them through the iframe element's contentWindow / contentDocument so
166
- # they reflect the active frame.
167
- def frame_url
168
- frame = browser.frame_stack.last
169
- return browser.current_url unless frame
177
+ # -- Window Support --
178
+ # Single-window driver. Lightpanda's BrowserContext is 1:1:1 with
179
+ # Session/Page/target and rejects a second Target.createTarget with
180
+ # TargetAlreadyLoaded (upstream #1962, maintainer-owned), so there is
181
+ # exactly one window and its handle is the CDP target id.
182
+ #
183
+ # Resizing drives Emulation.setDeviceMetricsOverride, so it is real for
184
+ # window.innerWidth/innerHeight and for what `matchMedia` reports. Two
185
+ # limits worth knowing before writing a responsive spec:
186
+ #
187
+ # 1. It is not layout. Element geometry stays synthetic, so a resize
188
+ # changes which CSS branch applies, never where anything sits.
189
+ # 2. `@media` rules do NOT re-resolve for the document already on
190
+ # screen — Lightpanda fixes the cascade at parse time and a metrics
191
+ # change doesn't invalidate it. So resize, THEN visit:
192
+ #
193
+ # page.current_window.resize_to(375, 667)
194
+ # visit "/pricing" # parses under the new metrics
195
+ # assert_selector "#mobile-cta"
196
+ #
197
+ # Resizing without re-visiting leaves `matchMedia` and the rendered
198
+ # branch disagreeing. See Browser#set_viewport for the verification.
199
+
200
+ def current_window_handle
201
+ browser.target_id
202
+ end
203
+
204
+ def window_handles
205
+ [browser.target_id]
206
+ end
207
+
208
+ def switch_to_window(handle)
209
+ return if handle == browser.target_id
210
+
211
+ raise NoSuchPageError, "Window #{handle.inspect} does not exist. " \
212
+ "Lightpanda supports a single window per session."
213
+ end
214
+
215
+ # Capybara's Window#current? rescues this to decide whether a handle is
216
+ # still live, so it must be a class, not a raise.
217
+ def no_such_window_error
218
+ NoSuchPageError
219
+ end
220
+
221
+ def window_size(handle)
222
+ assert_current_window!(handle)
223
+ browser.viewport_size
224
+ end
225
+
226
+ def resize_window_to(handle, width, height)
227
+ assert_current_window!(handle)
228
+ browser.set_viewport(width, height)
229
+ end
170
230
 
171
- browser.call_function_on(frame.remote_object_id,
172
- "function() { return this.contentWindow.location.href }")
231
+ # No window manager and no layout, so "as large as the screen" has no
232
+ # meaning beyond the configured size. Both reset to it rather than
233
+ # raising, because suites call maximize defensively in setup and a
234
+ # raise there would take out the example before it starts.
235
+ def maximize_window(handle)
236
+ assert_current_window!(handle)
237
+ browser.set_viewport
173
238
  end
174
239
 
175
- def frame_title
176
- frame = browser.frame_stack.last
177
- return browser.title unless frame
240
+ alias fullscreen_window maximize_window
178
241
 
179
- browser.call_function_on(frame.remote_object_id,
180
- "function() { return this.contentDocument.title }")
242
+ def open_new_window(_kind = :tab)
243
+ raise Capybara::NotSupportedByDriverError,
244
+ "Lightpanda serves a single target per CDP connection (upstream lightpanda-io/browser#1962), " \
245
+ "so a second window cannot be opened. Drive the second page in its own Capybara session instead."
246
+ end
247
+
248
+ def close_window(_handle)
249
+ raise Capybara::NotSupportedByDriverError,
250
+ "Lightpanda has a single window per session; closing it would end the session. " \
251
+ "Use Driver#reset! to start a fresh one."
181
252
  end
182
253
 
183
254
  # -- Modal/Dialog Support --
184
255
 
256
+ # find_modal owns the wait default (browser.options.timeout) — pass
257
+ # wait only when the caller overrode it.
185
258
  def accept_modal(type, **options, &block)
186
259
  browser.accept_modal(type, text: options[:with])
187
260
  block&.call
188
- browser.find_modal(type,
189
- text: options[:text],
190
- wait: options.fetch(:wait, browser.options.timeout))
261
+ browser.find_modal(type, **{ text: options[:text], wait: options[:wait] }.compact)
191
262
  end
192
263
 
193
264
  def dismiss_modal(type, **options, &block)
194
265
  browser.dismiss_modal(type)
195
266
  block&.call
196
- browser.find_modal(type,
197
- text: options[:text],
198
- wait: options.fetch(:wait, browser.options.timeout))
267
+ browser.find_modal(type, **{ text: options[:text], wait: options[:wait] }.compact)
199
268
  end
200
269
 
201
270
  # -- Screenshots --
@@ -220,8 +289,11 @@ module Capybara
220
289
  alias render save_screenshot
221
290
 
222
291
  # -- Headers (Cuprite-compatible driver surface) --
223
- # Delegates to Network, which lazily enables the Network domain and
224
- # remembers the headers across reset. Cuprite exposes these on the
292
+ # Delegates to Network, which lazily enables the Network domain. The
293
+ # overrides do NOT survive reset!: disposing the BrowserContext takes
294
+ # setExtraHTTPHeaders with it, so Network#reset drops its cached copy
295
+ # rather than report headers the browser stopped sending (pinned by
296
+ # network_test.rb "clears extra_headers"). Cuprite exposes these on the
225
297
  # driver, and real suites call them there (page.driver.headers = ...).
226
298
 
227
299
  def headers
@@ -241,12 +313,20 @@ module Capybara
241
313
  # Thin Cuprite-style wrapper. The interesting work — disposing the
242
314
  # BrowserContext (cookies, storage, all targets) and starting a fresh
243
315
  # one — happens in Browser#reset.
316
+ #
317
+ # Rescue is the gem hierarchy plus raw IO escapees only — NOT
318
+ # StandardError: reset! runs between every test, so a blanket rescue
319
+ # turns programmer errors (e.g. a NoMethodError in browser.rb) into a
320
+ # silent quit-and-respawn on every example with zero signal. The warn
321
+ # keeps repeated respawns visible.
244
322
  def reset!
245
323
  browser.reset
246
- @started = false
247
- rescue StandardError
324
+ rescue Error, SystemCallError, IOError => e
325
+ warn "[capybara-lightpanda] reset! failed (#{e.class}: #{e.message}); respawning browser"
248
326
  @browser&.quit
249
327
  @browser = nil
328
+ ensure
329
+ @started = false
250
330
  end
251
331
 
252
332
  def quit
@@ -289,6 +369,17 @@ module Capybara
289
369
 
290
370
  private
291
371
 
372
+ # Every window method takes a handle because Capybara's Window objects
373
+ # carry one; with a single window the only valid value is the current
374
+ # target id. Reject anything else rather than silently operating on the
375
+ # wrong window.
376
+ def assert_current_window!(handle)
377
+ return if handle.nil? || handle == browser.target_id
378
+
379
+ raise NoSuchPageError, "Window #{handle.inspect} does not exist. " \
380
+ "Lightpanda supports a single window per session."
381
+ end
382
+
292
383
  # Unwrap arguments before sending to the browser. Capybara::Node::Element wraps
293
384
  # our Lightpanda::Node — pull `.base` out so `serialize_argument` can build
294
385
  # `{objectId: …}` for the CDP payload. Cuprite's `native_args` pattern.
@@ -315,14 +406,15 @@ module Capybara
315
406
  end
316
407
 
317
408
  # Walk through evaluate-script results turning DOM-node markers (the
318
- # `{ "__lightpanda_node__" => "..." }` hashes produced by `Browser#unwrap_call_result`)
319
- # into Lightpanda::Node instances so Capybara can wrap them as elements.
409
+ # `{ Browser::NODE_MARKER => "..." }` hashes produced by
410
+ # `Browser#unwrap_call_result`) into Lightpanda::Node instances so
411
+ # Capybara can wrap them as elements.
320
412
  def unwrap_script_result(value)
321
413
  case value
322
414
  when Array then value.map { |v| unwrap_script_result(v) }
323
415
  when Hash
324
- if value.size == 1 && value.key?("__lightpanda_node__")
325
- Node.new(self, value["__lightpanda_node__"])
416
+ if value.size == 1 && value.key?(Browser::NODE_MARKER)
417
+ Node.new(self, value[Browser::NODE_MARKER])
326
418
  else
327
419
  value.transform_values { |v| unwrap_script_result(v) }
328
420
  end
@@ -5,11 +5,19 @@ module Capybara
5
5
  class Error < StandardError; end
6
6
 
7
7
  class ProcessTimeoutError < Error; end
8
+ # Subclass so external `rescue ProcessTimeoutError` keeps catching it;
9
+ # Process#start dispatches on the class instead of message matching.
10
+ class PortInUseError < ProcessTimeoutError; end
8
11
  class BinaryNotFoundError < Error; end
9
12
  class BinaryError < Error; end
10
13
  class UnsupportedPlatformError < Error; end
11
14
 
12
15
  class TimeoutError < Error; end
16
+ # A JS dialog opened with no accept_modal/dismiss_modal pre-arm in flight
17
+ # and the driver was configured with `raise_on_unhandled_modal: true`.
18
+ # Lightpanda has already applied its silent default by the time this is
19
+ # raised (confirm → cancel, prompt → null, alert → dismissed).
20
+ class UnhandledModalError < Error; end
13
21
 
14
22
  # Base class for any error originating from a CDP response or live browser
15
23
  # state. Lets callers `rescue BrowserError` to catch the whole CDP family
@@ -88,6 +96,22 @@ module Capybara
88
96
  end
89
97
  end
90
98
 
99
+ class InvalidSelector < Error
100
+ attr_reader :method, :selector
101
+
102
+ def initialize(message, method = nil, selector = nil)
103
+ @method = method
104
+ @selector = selector
105
+ super(message)
106
+ end
107
+ end
108
+
109
+ # --- Cuprite/Ferrum drop-in compatibility surface ---
110
+ # This gem never raises the three classes below (no coordinate-based
111
+ # mouse path, no multi-page API, no page-status tracking). They mirror
112
+ # the peer taxonomy (Cuprite's MouseEventFailed, Ferrum's
113
+ # NoSuchPageError/StatusError) so suites migrating from those drivers
114
+ # don't NameError on rescue lists that reference them.
91
115
  class MouseEventFailed < BrowserError
92
116
  attr_reader :node, :selector, :position
93
117
 
@@ -103,16 +127,6 @@ module Capybara
103
127
  end
104
128
  end
105
129
 
106
- class InvalidSelector < Error
107
- attr_reader :method, :selector
108
-
109
- def initialize(message, method = nil, selector = nil)
110
- @method = method
111
- @selector = selector
112
- super(message)
113
- end
114
- end
115
-
116
130
  class NoSuchPageError < Error; end
117
131
  class StatusError < Error; end
118
132
  end
@@ -0,0 +1,16 @@
1
+ // --- Public surface ---
2
+ // The one place that names window._lightpanda. References the function/var
3
+ // declarations from turbo.js and predicates.js (hoisted into the same IIFE
4
+ // scope by AutoScripts). Runs last in the concatenation order.
5
+ window._lightpanda = {
6
+ turbo: {
7
+ pending: function() { return _pendingTurboOps; },
8
+ idle: function() { return _pendingTurboOps <= 0; }
9
+ },
10
+
11
+ isVisible: isVisible,
12
+ isObscured: isObscured,
13
+ isDisabled: isDisabled,
14
+ isContentEditable: isContentEditable,
15
+ visibleText: visibleText
16
+ };
@@ -0,0 +1,15 @@
1
+ // _lightpanda auto-injected bundle. Assembled by AutoScripts (auto_scripts.rb)
2
+ // from the sibling source files in this directory and registered once per
3
+ // session via Page.addScriptToEvaluateOnNewDocument, so it runs in every
4
+ // document — top frame and every iframe.
5
+ //
6
+ // Each source file is a plain sequence of declarations/statements: no IIFE,
7
+ // no `export`/`import`/`require`. AutoScripts wraps them in the IIFE and the
8
+ // `window._lightpanda` idempotency guard. Keeping the files free of module
9
+ // syntax means they parse identically as a classic browser script and as a
10
+ // `new Function(...)` body — which is exactly how the Bun harness (test/js/)
11
+ // loads predicates.js to test them without a build step.
12
+ //
13
+ // Concatenation order (set in auto_scripts.rb): banner, turbo, predicates,
14
+ // attach. `attach.js` reads the names defined by `turbo.js`/`predicates.js`,
15
+ // so it must come last.