capybara-lightpanda 0.2.2 → 0.4.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.
@@ -43,7 +43,7 @@ module Capybara
43
43
  end
44
44
 
45
45
  def closed?
46
- @status == :closed
46
+ @status == :closed || @status == :error
47
47
  end
48
48
 
49
49
  def open?
@@ -102,10 +102,15 @@ module Capybara
102
102
  end
103
103
 
104
104
  @driver.on(:error) do |event|
105
+ # Do NOT raise here. This callback fires synchronously from
106
+ # @driver.parse(data) inside the reader thread, which sets
107
+ # abort_on_exception = true. Raising DeadBrowserError escapes
108
+ # the reader's narrow IO rescue and aborts the entire Ruby
109
+ # process. Mark the connection dead and let Client#command
110
+ # surface DeadBrowserError on its next dispatch via closed?.
111
+ @logger&.puts("✗ WebSocket error: #{event.message}")
105
112
  @status = :error
106
113
  @messages.close
107
-
108
- raise DeadBrowserError, "WebSocket error: #{event.message}"
109
114
  end
110
115
  end
111
116
 
@@ -152,10 +157,25 @@ module Capybara
152
157
  def parse_message(data)
153
158
  JSON.parse(data, max_nesting: false)
154
159
  rescue JSON::ParserError => e
155
- warn "Failed to parse WebSocket message: #{e.message}"
160
+ warn_parse_failure(e.message)
156
161
 
157
162
  nil
158
163
  end
164
+
165
+ # Dedupe identical parse-failure warnings per WebSocket instance.
166
+ # Lightpanda occasionally emits CDP frames that embed a bare
167
+ # `undefined` token (invalid JSON — see upstream-wishlist.md A41)
168
+ # and a complex page reproduces the same frame on every load,
169
+ # which previously flooded test output with one warn per frame.
170
+ # Surface the first occurrence per unique error so the upstream
171
+ # regression stays visible, then suppress repeats.
172
+ def warn_parse_failure(message)
173
+ @parse_warnings ||= {}
174
+ return if @parse_warnings[message]
175
+
176
+ @parse_warnings[message] = true
177
+ warn "Failed to parse WebSocket message: #{message}"
178
+ end
159
179
  end
160
180
  end
161
181
  end
@@ -71,6 +71,11 @@ module Capybara
71
71
  @ws&.close
72
72
  @message_thread&.join(1) || @message_thread&.kill
73
73
  @subscriber.clear
74
+ # Wake any in-flight callers blocked on `pending.value!(timeout)` so
75
+ # they raise DeadBrowserError immediately (via the `@ws.closed?` check
76
+ # in #command) instead of stalling for the full @options.timeout — a
77
+ # 30s freeze per pending command on a dying browser.
78
+ fail_pending_commands
74
79
  @pendings.clear
75
80
  end
76
81
 
@@ -92,6 +97,14 @@ module Capybara
92
97
  @mutex.synchronize { @command_id += 1 }
93
98
  end
94
99
 
100
+ # Resolve every pending IVar with nil so blocked callers fall through
101
+ # `pending.value!(timeout)` immediately. `try_set` is a no-op if the
102
+ # IVar already carries a real response (race-safe against an in-flight
103
+ # handle_message that landed just before close).
104
+ def fail_pending_commands
105
+ @pendings.each_value { |ivar| ivar.try_set(nil) }
106
+ end
107
+
95
108
  def start_message_thread
96
109
  @message_thread = Thread.new do
97
110
  Thread.current.abort_on_exception = true
@@ -77,7 +77,8 @@ module Capybara
77
77
  find { |cookie| cookie.name == name }
78
78
  end
79
79
 
80
- def set(name:, value:, domain: nil, path: "/", secure: false, http_only: false, expires: nil)
80
+ def set(name:, value:, domain: nil, path: "/", secure: false, http_only: false, # rubocop:disable Metrics/ParameterLists
81
+ same_site: nil, expires: nil)
81
82
  params = {
82
83
  name: name,
83
84
  value: value,
@@ -87,6 +88,10 @@ module Capybara
87
88
  }
88
89
 
89
90
  params[:domain] = domain if domain
91
+ # CDP rejects unknown SameSite values; pass through only the canonical
92
+ # spec strings ("Strict" / "Lax" / "None") so YAML noise from a hand-
93
+ # edited file doesn't reach the browser.
94
+ params[:sameSite] = same_site if %w[Strict Lax None].include?(same_site)
90
95
  params[:expires] = expires.to_i if expires
91
96
 
92
97
  browser.command("Network.setCookie", **params)
@@ -123,7 +128,7 @@ module Capybara
123
128
 
124
129
  # set() takes keyword args, but YAML round-trips give us a hash with the
125
130
  # raw CDP keys (camelCase). Normalize and forward.
126
- def restore_cookie(hash)
131
+ def restore_cookie(hash) # rubocop:disable Metrics/PerceivedComplexity
127
132
  attrs = hash.transform_keys(&:to_s)
128
133
  params = {
129
134
  name: attrs["name"],
@@ -133,6 +138,7 @@ module Capybara
133
138
  http_only: attrs["httpOnly"] || false,
134
139
  }
135
140
  params[:domain] = attrs["domain"] if attrs["domain"]
141
+ params[:same_site] = attrs["sameSite"] if attrs["sameSite"]
136
142
  exp = attrs["expires"]
137
143
  params[:expires] = Time.at(exp) if exp.is_a?(Numeric) && exp.positive?
138
144
  set(**params)
@@ -10,7 +10,7 @@ module Capybara
10
10
 
11
11
  attr_reader :app, :options
12
12
 
13
- delegate %i[current_url title] => :browser
13
+ delegate %i[current_url title status_code response_headers] => :browser
14
14
 
15
15
  def initialize(app, options = {})
16
16
  super()
@@ -31,6 +31,22 @@ module Capybara
31
31
  false
32
32
  end
33
33
 
34
+ # Escape hatch to the underlying Browser for callers that need raw CDP
35
+ # access — e.g. Lightpanda's `LP.*` extensions (`getMarkdown`,
36
+ # `getSemanticTree`, `detectForms`, …) that aren't worth exposing through
37
+ # the Capybara DSL. Mirrors `capybara-playwright-driver`'s
38
+ # `with_playwright_page`. Yields the Browser; returns whatever the block
39
+ # returns.
40
+ #
41
+ # driver.with_lightpanda_browser do |browser|
42
+ # browser.page_command("LP.getMarkdown")
43
+ # end
44
+ def with_lightpanda_browser(&block)
45
+ raise ArgumentError, "block must be given" unless block
46
+
47
+ block.call(browser)
48
+ end
49
+
34
50
  def visit(url)
35
51
  @started = true
36
52
  browser.go_to(url)
@@ -189,8 +205,11 @@ module Capybara
189
205
 
190
206
  def save_screenshot(path, **_options)
191
207
  browser.screenshot(path: path)
192
- rescue BinaryError, BinaryNotFoundError
193
- # Browser can't start (e.g., version too old) don't crash teardown
208
+ rescue BinaryError, BinaryNotFoundError, BrowserError, TimeoutError
209
+ # Browser can't start (version too old), is already dead (DeadBrowserError),
210
+ # the CDP call timed out, or returned any other CDP-level error. Teardown
211
+ # screenshots are best-effort — swallow so the real test failure surfaces
212
+ # instead of a "browser already gone" stack trace.
194
213
  nil
195
214
  end
196
215
 
@@ -221,12 +240,15 @@ module Capybara
221
240
  end
222
241
 
223
242
  # Expanded error list for Capybara retry logic (Cuprite pattern).
243
+ # MouseEventFailed is in Cuprite's list, but Lightpanda has no
244
+ # rendering engine and the gem dispatches clicks through JS — the
245
+ # underlying CDP Input.dispatchMouseEvent path doesn't run, so
246
+ # MouseEventFailed is never raised.
224
247
  def invalid_element_errors
225
248
  [
226
249
  NodeNotFoundError,
227
250
  NoExecutionContextError,
228
251
  ObsoleteNode,
229
- MouseEventFailed,
230
252
  ]
231
253
  end
232
254
 
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Escape hatch onto Capybara::Node::Element so users can reach our driver-level
4
+ # Node (and its remote_object_id, `call`, etc.) without going through
5
+ # `element.base`. Mirrors capybara-playwright-driver's
6
+ # `with_playwright_element_handle` (lib/capybara/playwright/node.rb). The is_a?
7
+ # guard keeps the patch safe when both this gem and another Capybara driver
8
+ # are loaded in the same process.
9
+ module Capybara
10
+ module Lightpanda
11
+ module ElementExtension
12
+ def with_lightpanda_node(&block)
13
+ raise ArgumentError, "block must be given" unless block
14
+ raise "#{base.inspect} is not a Capybara::Lightpanda::Node" unless base.is_a?(Capybara::Lightpanda::Node)
15
+
16
+ block.call(base)
17
+ end
18
+ end
19
+ end
20
+ end
21
+ Capybara::Node::Element.prepend(Capybara::Lightpanda::ElementExtension)
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Capybara
4
+ module Lightpanda
5
+ # Hash subclass that downcases the lookup key. CDP returns response headers
6
+ # with lowercased names ("content-type"), but Capybara callers reach for
7
+ # the canonical casing ("Content-Type"). Mirrors capybara-playwright-driver's
8
+ # Headers class (lib/capybara/playwright/page.rb).
9
+ class Headers < Hash
10
+ def [](key)
11
+ super(key.to_s.downcase)
12
+ end
13
+ end
14
+ end
15
+ end