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.
@@ -69,6 +69,23 @@ module Capybara
69
69
  shift: 8,
70
70
  }.freeze
71
71
 
72
+ # US-layout shifted variants of the printable ASCII characters that
73
+ # don't just upcase. Used by dispatch_modified_char so
74
+ # send_keys([:shift, "1"]) types "!" instead of "1". Letters fall
75
+ # through to String#upcase via the default branch.
76
+ SHIFT_MAP = {
77
+ "1" => "!", "2" => "@", "3" => "#", "4" => "$", "5" => "%",
78
+ "6" => "^", "7" => "&", "8" => "*", "9" => "(", "0" => ")",
79
+ "-" => "_", "=" => "+", "[" => "{", "]" => "}", "\\" => "|",
80
+ ";" => ":", "'" => "\"", "," => "<", "." => ">", "/" => "?",
81
+ "`" => "~",
82
+ }.freeze
83
+ private_constant :SHIFT_MAP
84
+
85
+ def self.shifted(char)
86
+ SHIFT_MAP[char] || char.upcase
87
+ end
88
+
72
89
  def initialize(browser)
73
90
  @browser = browser
74
91
  end
@@ -155,7 +172,7 @@ module Capybara
155
172
  end
156
173
 
157
174
  def dispatch_modified_char(char, modifier_value, modifiers)
158
- text = modifiers.include?(:shift) ? char.upcase : char
175
+ text = modifiers.include?(:shift) ? self.class.shifted(char) : char
159
176
  send_key_event("keyDown", { key: text, text: text, unmodifiedText: char }, modifiers: modifier_value)
160
177
  send_key_event("keyUp", { key: text }, modifiers: modifier_value)
161
178
  end
@@ -8,6 +8,7 @@ module Capybara
8
8
  def initialize(browser)
9
9
  @browser = browser
10
10
  @traffic = []
11
+ @traffic_mutex = Mutex.new
11
12
  @enabled = false
12
13
  @request_handler = nil
13
14
  @response_handler = nil
@@ -35,11 +36,11 @@ module Capybara
35
36
  end
36
37
 
37
38
  def traffic
38
- @traffic.dup
39
+ @traffic_mutex.synchronize { @traffic.dup }
39
40
  end
40
41
 
41
42
  def clear
42
- @traffic.clear
43
+ @traffic_mutex.synchronize { @traffic.clear }
43
44
  end
44
45
 
45
46
  # Wipe local state without sending Network.disable. Called by
@@ -50,61 +51,89 @@ module Capybara
50
51
  # cleared the Subscriber first.
51
52
  def reset
52
53
  unsubscribe
53
- @traffic.clear
54
+ @traffic_mutex.synchronize { @traffic.clear }
54
55
  @enabled = false
55
56
  end
56
57
 
58
+ # Setting extra headers also lazily enables the Network domain. Without
59
+ # this, headers were silently ignored until the caller separately ran
60
+ # `network.enable` (or `wait_for_network_idle`). Cuprite/Ferrum parity.
57
61
  def headers=(headers)
62
+ enable
58
63
  @extra_headers = headers
59
64
  browser.page_command("Network.setExtraHTTPHeaders", headers: headers)
60
65
  end
61
66
 
62
67
  def add_headers(headers)
68
+ enable
63
69
  @extra_headers = (@extra_headers || {}).merge(headers)
64
70
  browser.page_command("Network.setExtraHTTPHeaders", headers: @extra_headers)
65
71
  end
66
72
 
67
73
  def clear_headers
74
+ enable
68
75
  @extra_headers = {}
69
76
  browser.page_command("Network.setExtraHTTPHeaders", headers: {})
70
77
  end
71
78
 
72
- def wait_for_idle(timeout: 5, connections: 0) # rubocop:disable Naming/PredicateMethod
73
- started_at = Time.now
74
-
75
- while Time.now - started_at < timeout
76
- pending = @traffic.count { |t| t[:response].nil? }
77
- return true if pending <= connections
79
+ # Count of in-flight requests (those with no response yet recorded).
80
+ # Cheap predicate-friendly accessor (ferrum parity).
81
+ def pending_connections
82
+ @traffic_mutex.synchronize { @traffic.count { |t| t[:response].nil? } }
83
+ end
78
84
 
79
- sleep 0.1
80
- end
85
+ # True when no more than `connections` requests are in-flight.
86
+ def idle?(connections = 0)
87
+ pending_connections <= connections
88
+ end
81
89
 
90
+ def wait_for_idle(timeout: 5, connections: 0)
91
+ wait_for_idle!(timeout: timeout, connections: connections)
92
+ rescue TimeoutError
82
93
  false
83
94
  end
84
95
 
96
+ # Raising variant of #wait_for_idle (ferrum parity). Returns true on
97
+ # success, raises TimeoutError on timeout so callers that treat the
98
+ # idle wait as a precondition don't have to remember to check a bool.
99
+ def wait_for_idle!(timeout: 5, connections: 0) # rubocop:disable Naming/PredicateMethod
100
+ Utils::Wait.until(
101
+ timeout: timeout,
102
+ interval: 0.1,
103
+ message: "Network did not become idle within #{timeout}s " \
104
+ "(pending=#{pending_connections}, allowed=#{connections})"
105
+ ) { idle?(connections) }
106
+ true
107
+ end
108
+
85
109
  private
86
110
 
87
111
  def subscribe
112
+ # CDP events arrive on the message thread while wait_for_idle /
113
+ # pending_connections read from the main thread; serialize all
114
+ # @traffic mutations and reads through @traffic_mutex.
88
115
  @request_handler = lambda do |params|
89
- @traffic << {
116
+ entry = {
90
117
  request_id: params["requestId"],
91
118
  url: params.dig("request", "url"),
92
119
  method: params.dig("request", "method"),
93
120
  timestamp: params["timestamp"],
94
121
  response: nil,
95
122
  }
123
+ @traffic_mutex.synchronize { @traffic << entry }
96
124
  end
97
125
 
98
126
  @response_handler = lambda do |params|
99
- request = @traffic.find { |t| t[:request_id] == params["requestId"] }
100
-
101
- next unless request
102
-
103
- request[:response] = {
104
- status: params.dig("response", "status"),
105
- headers: params.dig("response", "headers"),
106
- mime_type: params.dig("response", "mimeType"),
107
- }
127
+ @traffic_mutex.synchronize do
128
+ request = @traffic.find { |t| t[:request_id] == params["requestId"] }
129
+ next unless request
130
+
131
+ request[:response] = {
132
+ status: params.dig("response", "status"),
133
+ headers: params.dig("response", "headers"),
134
+ mime_type: params.dig("response", "mimeType"),
135
+ }
136
+ end
108
137
  end
109
138
 
110
139
  browser.on("Network.requestWillBeSent", &@request_handler)
@@ -14,12 +14,10 @@ module Capybara
14
14
  end
15
15
 
16
16
  def text
17
- ensure_connected
18
17
  call("function() { return this.textContent }")
19
18
  end
20
19
 
21
20
  def all_text
22
- ensure_connected
23
21
  filter_text(call("function() { return this.textContent }"))
24
22
  end
25
23
 
@@ -28,7 +26,6 @@ module Capybara
28
26
  # that fail VISIBLE_JS, and emit newlines around block-display elements
29
27
  # (the part of innerText behavior we still need).
30
28
  def visible_text
31
- ensure_connected
32
29
  call(VISIBLE_TEXT_JS).to_s
33
30
  .gsub(/\A[[:space:]&&[^\u00A0]]+/, "")
34
31
  .gsub(/[[:space:]&&[^\u00A0]]+\z/, "")
@@ -116,6 +113,17 @@ module Capybara
116
113
  call("function() { this.dispatchEvent(new MouseEvent('mouseover', {bubbles: true, cancelable: true})) }")
117
114
  end
118
115
 
116
+ # Lightpanda has no rendering engine — `window.scrollTo` / `scrollIntoView`
117
+ # are no-ops at the browser level, and `getBoundingClientRect` reflects
118
+ # logical-DOM geometry rather than scroll-aware viewport coords. So
119
+ # there's nothing to scroll. Silently succeed so callers like
120
+ # `session.scroll_to(find('#thing'))` (Selenium-flavoured specs leaning
121
+ # on real layout) don't crash with NotImplementedError; assertions that
122
+ # depend on post-scroll visibility are already gated by the cuprite
123
+ # fallback in dual-driver setups.
124
+ def scroll_to(*); end
125
+ def scroll_by(*); end
126
+
119
127
  # Dispatch an arbitrary DOM event by name. Mirrors Cuprite's Node#trigger
120
128
  # — picks the right Event constructor for known mouse/focus/form names
121
129
  # and falls back to a generic Event for everything else (so callers can
@@ -199,6 +207,13 @@ module Capybara
199
207
  call(GET_PATH_JS)
200
208
  end
201
209
 
210
+ # Ancestor chain from `parentNode` up to (but not including) `document`,
211
+ # returned as Lightpanda::Node wrappers. Mirrors Cuprite's `Node#parents`.
212
+ def parents
213
+ oids = driver.browser.parents_of(@remote_object_id)
214
+ oids.map { |oid| self.class.new(driver, oid) }
215
+ end
216
+
202
217
  def find_xpath(selector)
203
218
  object_ids = driver.browser.find_within(@remote_object_id, "xpath", selector)
204
219
  object_ids.map { |oid| self.class.new(driver, oid) }
@@ -243,19 +258,6 @@ module Capybara
243
258
 
244
259
  private
245
260
 
246
- # Capybara's `automatic_reload` re-runs the original query when an element
247
- # access raises one of the driver's `invalid_element_errors`. After a DOM
248
- # mutation like `replaceWith`, our cached objectId still resolves to the
249
- # detached node, so reads succeed (with stale data) and the auto-reload
250
- # never fires. Detect detachment via `isConnected` and raise so the
251
- # synchronize-loop notices and triggers a re-find.
252
- def ensure_connected
253
- connected = call("function() { return this.isConnected }")
254
- return if connected
255
-
256
- raise ObsoleteNode.new(self, "Node is no longer attached to the document")
257
- end
258
-
259
261
  def implicit_submit
260
262
  call(IMPLICIT_SUBMIT_JS)
261
263
  driver.browser.wait_for_idle
@@ -277,6 +279,10 @@ module Capybara
277
279
  call(SET_VALUE_JS, format_time_value(value))
278
280
  when "datetime-local"
279
281
  call(SET_VALUE_JS, format_datetime_value(value))
282
+ when "month"
283
+ call(SET_VALUE_JS, format_month_value(value))
284
+ when "week"
285
+ call(SET_VALUE_JS, format_week_value(value))
280
286
  else
281
287
  fill_text_input(type, value.to_s)
282
288
  end
@@ -316,6 +322,22 @@ module Capybara
316
322
  value.to_time.strftime("%Y-%m-%dT%H:%M")
317
323
  end
318
324
 
325
+ def format_month_value(value)
326
+ return value.to_s if value.is_a?(String) || !value.respond_to?(:to_date)
327
+
328
+ value.to_date.strftime("%Y-%m")
329
+ end
330
+
331
+ # ISO 8601 week-of-year, "%G" giving the ISO week-numbering year so that
332
+ # the last days of December that belong to week 1 of the next year are
333
+ # rendered with the correct year. Matches Cuprite's `Node#set` for week
334
+ # inputs and what the user would type into a `<input type=week>` field.
335
+ def format_week_value(value)
336
+ return value.to_s if value.is_a?(String) || !value.respond_to?(:to_date)
337
+
338
+ value.to_date.strftime("%G-W%V")
339
+ end
340
+
319
341
  # `maxlength` only constrains user typing, not direct value assignment, but
320
342
  # Selenium-style drivers truncate to match what a user would have ended up
321
343
  # with. Honor it explicitly so Capybara-shared specs behave the same.
@@ -345,24 +367,41 @@ module Capybara
345
367
  # JS bodies may reference `_lightpanda.*` helpers — they're registered via
346
368
  # Page.addScriptToEvaluateOnNewDocument in every document (top frame and
347
369
  # iframes alike), so the namespace is available wherever `this` lives.
370
+ #
371
+ # The supplied declaration is wrapped with an `isConnected` guard so a
372
+ # detached node raises ObsoleteNode (in Driver#invalid_element_errors)
373
+ # instead of returning stale data from V8's still-live reference. After
374
+ # a DOM mutation like `replaceWith`, the cached objectId still resolves
375
+ # to the detached node, so reads succeed quietly and Capybara's
376
+ # automatic_reload never re-runs the original query.
348
377
  def call(function_declaration, *args)
378
+ guarded = wrap_with_attached_guard(function_declaration)
349
379
  driver.browser.with_default_context_wait do
350
- driver.browser.call_function_on(@remote_object_id, function_declaration, *args)
380
+ driver.browser.call_function_on(@remote_object_id, guarded, *args)
351
381
  end
352
382
  rescue JavaScriptError => e
383
+ if e.message.include?(OBSOLETE_NODE_MARKER)
384
+ raise ObsoleteNode.new(self, "Node is no longer attached to the document")
385
+ end
386
+
353
387
  case e.class_name
354
388
  when "InvalidSelector"
355
389
  raise InvalidSelector.new(e.message, nil, args.first)
356
390
  else
357
391
  raise
358
392
  end
359
- rescue BrowserError => e
360
- case e.message
361
- when /MouseEventFailed/i
362
- raise MouseEventFailed.new(self, e.response&.dig("message"))
363
- else
364
- raise
365
- end
393
+ end
394
+
395
+ OBSOLETE_NODE_MARKER = "LIGHTPANDA_OBSOLETE_NODE"
396
+ private_constant :OBSOLETE_NODE_MARKER
397
+
398
+ def wrap_with_attached_guard(function_declaration)
399
+ <<~JS
400
+ function() {
401
+ if (!this.isConnected) throw new Error(#{OBSOLETE_NODE_MARKER.inspect});
402
+ return (#{function_declaration}).apply(this, arguments);
403
+ }
404
+ JS
366
405
  end
367
406
 
368
407
  # We dispatch a `MouseEvent` (not a generic `Event`) because Turbo's link
@@ -372,15 +411,14 @@ module Capybara
372
411
  # the manual default action below, which does a full-page navigation
373
412
  # instead of a frame swap.
374
413
  #
375
- # For submit buttons (`<button type=submit>`, `<input type=submit>`,
376
- # `<input type=image>`): route through `form.requestSubmit(this)` so the
377
- # browser dispatches a real `SubmitEvent` with submitter set, honors the
378
- # submitter's `formaction` / `formmethod` / `formenctype`, and includes
379
- # the submitter's name/value in the form data. A manual
380
- # `dispatchEvent(new Event('submit'))` + `form.submit()` would lose all of
381
- # that and break Turbo Drive / Hotwire form handling. We can't rely on
382
- # the synthetic click's default action because synthetic events don't
383
- # trigger the implicit form-submission default action per DOM spec.
414
+ # Submit buttons (`<input type=submit>`, `<input type=image>`,
415
+ # `<button type=submit>`): native click on the dispatched MouseEvent
416
+ # already runs the form-submission default action via Frame.submitForm
417
+ # in Lightpanda (extension of PR #2312 for image to all submit
418
+ # variants). A manual `form.requestSubmit(this)` here would fire a
419
+ # second SubmitEvent and double-submit the form observed on nightly
420
+ # 6167 as duplicate `turbo:submit-start` events; the first request
421
+ # gets aborted by the second and Turbo never renders the response.
384
422
  CLICK_JS = <<~JS
385
423
  function() {
386
424
  var EventCtor = (typeof MouseEvent !== 'undefined') ? MouseEvent : Event;
@@ -388,24 +426,7 @@ module Capybara
388
426
  var notCancelled = this.dispatchEvent(clickEvt);
389
427
  if (!notCancelled || clickEvt.defaultPrevented) return;
390
428
  var tag = this.tagName;
391
- var type = (this.type || '').toLowerCase();
392
- var isSubmitButton =
393
- (tag === 'BUTTON' && (type === 'submit' || type === '')) ||
394
- (tag === 'INPUT' && (type === 'submit' || type === 'image'));
395
- if (isSubmitButton && this.form) {
396
- // Lightpanda raises a JsException from requestSubmit when a
397
- // bubble-phase listener (e.g. Turbo's submitBubbled) calls
398
- // preventDefault + stopImmediatePropagation on the SubmitEvent.
399
- // Per HTML spec a cancelled submission should be a silent no-op.
400
- // Log unexpected errors via console.warn so they remain
401
- // diagnosable (LIGHTPANDA_DEBUG surfaces console output) instead
402
- // of silently swallowing future regressions.
403
- try {
404
- this.form.requestSubmit(this);
405
- } catch (e) {
406
- try { console.warn('[capybara-lightpanda] requestSubmit threw:', e && e.message ? e.message : e); } catch (_) {}
407
- }
408
- } else if (tag === 'A' && this.href && this.target !== '_blank') {
429
+ if (tag === 'A' && this.href && this.target !== '_blank') {
409
430
  // Same-document fragment-only navigation: just update hash (or do
410
431
  // nothing if identical). Mirrors Chrome — assigning location.href
411
432
  // to a same-document URL on Lightpanda triggers a real navigation
@@ -8,10 +8,10 @@ module Capybara
8
8
  READY_PATTERN = /server running.*address\s*=\s*(\d+\.\d+\.\d+\.\d+:\d+)/m
9
9
  ADDRESS_IN_USE_PATTERN = /err=AddressInUse/
10
10
 
11
- # Floor for the cookie/navigation/redirect/modal/keyboard/css/forms/dispatch
12
- # fixes the gem now relies on: PR #2255 (Network.clearBrowserCookies
13
- # empty params + Network.getAllCookies), PR #2257
14
- # (window.location.pathname/.search assignment triggers navigation),
11
+ # Floor for the cookie/navigation/redirect/modal/keyboard/css/forms/dispatch/
12
+ # xpath/history/iframe-context/dialog fixes the gem now relies on:
13
+ # PR #2255 (Network.clearBrowserCookies empty params + Network.getAllCookies),
14
+ # PR #2257 (window.location.pathname/.search assignment triggers navigation),
15
15
  # PR #2265 (URL fragment inherited across fragment-less redirect),
16
16
  # PR #2261 (LP.handleJavaScriptDialog pre-arm), PR #2283 (Referer on
17
17
  # cross-page nav), PR #2292 (KeyboardEvent.keyCode/charCode), PR #2294
@@ -25,10 +25,37 @@ module Capybara
25
25
  # PR #2342 (<summary> click toggles parent <details>.open),
26
26
  # PR #2352 (HTMLInputElement.pattern + patternMismatch via V8 RegExp),
27
27
  # PR #2368 (events: report listener exceptions instead of halting
28
- # dispatch — lets us drop the polyfills.js patchDispatch IIFE).
29
- # Build 6065 = main HEAD 61364437 (2026-05-06, PR #2368 merge);
30
- # ships in nightly published 2026-05-06 ~03:30 UTC for all four platforms.
31
- MINIMUM_NIGHTLY_BUILD = Gem::Version.new("6065")
28
+ # dispatch — load-bearing for the gem's JS bundle dispatch assumptions),
29
+ # PR #2289 (Page.getNavigationHistory + Page.navigateToHistoryEntry
30
+ # lets us drop the history.back()/history.forward() JS workaround in
31
+ # Browser#back / #forward), PR #2305 (XPath 1.0: Document.evaluate,
32
+ # XPathResult, XPathEvaluator, XPathExpression — lets us drop the
33
+ # ~700 LOC XPath polyfill in javascripts/index.js),
34
+ # PR #2431 (cdp: remove duplicate Page.frameNavigated emission + reuse
35
+ # child frame's V8 context — fixes issue #2400 iframe contextId churn,
36
+ # lets us drop Browser#find_in_frame's refresh_frame_stack! rescue),
37
+ # PR #2445 (cdp: reset browser context arena on Target.disposeBrowserContext
38
+ # — restores per-spec state hygiene during Driver#reset!, cures the
39
+ # batch-mode pollution that PR #2431 alone exposed),
40
+ # PR #2435 (dom: implement HTMLDialogElement.{show, showModal, close}
41
+ # natively — load-bearing for the gem's HTMLDialogElement assumptions
42
+ # after polyfills.js was deleted),
43
+ # PR #2450 (forms: add enctype + 5 submitter form-* IDL accessors +
44
+ # text/plain submission — lets us delete polyfills.js entirely; reads
45
+ # of form.enctype / submitter.form{Action,Enctype,Method,NoValidate,
46
+ # Target} now return spec-typed values natively),
47
+ # PR #2478 (css: evaluate @media and matchMedia against viewport —
48
+ # inline <style> @media blocks now apply declarations against the
49
+ # hardcoded 1920×1080 viewport, and window.matchMedia(q).matches
50
+ # returns spec-correct booleans. Lets _lightpanda.isVisible detect
51
+ # inline-@media-gated hides via el.checkVisibility() without any
52
+ # gem-side workaround. External <link rel="stylesheet"> fetch stays
53
+ # out of scope by design — see .claude/rules/lightpanda-io.md
54
+ # limitation #6).
55
+ # Build 6269 = first nightly carrying PR #2478 (merge commit
56
+ # ab63cfbf, 2026-05-16); the 2026-05-16 nightly cut at 03:36 UTC
57
+ # was hours before the merge at 13:42 UTC.
58
+ MINIMUM_NIGHTLY_BUILD = Gem::Version.new("6269")
32
59
 
33
60
  attr_reader :pid, :ws_url, :version, :nightly_build
34
61
 
@@ -42,10 +69,11 @@ module Capybara
42
69
  @stdout_w = nil
43
70
  @stderr_r = nil
44
71
  @stderr_w = nil
72
+ @finalizer_registered = false
45
73
  end
46
74
 
47
75
  def start
48
- binary_path = @options.browser_path || Binary.find_or_download
76
+ binary_path = @options.browser_path || Binary.update
49
77
 
50
78
  raise BinaryNotFoundError, "Lightpanda binary not found" unless binary_path
51
79
 
@@ -165,7 +193,7 @@ module Capybara
165
193
  end
166
194
 
167
195
  def build_args
168
- [
196
+ base = [
169
197
  "serve",
170
198
  "--host",
171
199
  @options.host.to_s,
@@ -174,6 +202,8 @@ module Capybara
174
202
  "--log_level",
175
203
  "info",
176
204
  ]
205
+ extra = ENV.fetch("LIGHTPANDA_EXTRA_ARGS", "").split
206
+ base + extra
177
207
  end
178
208
 
179
209
  def wait_for_ready
@@ -252,17 +282,29 @@ module Capybara
252
282
  end
253
283
 
254
284
  # Returns an array of PIDs holding the TCP port, [] if none, or nil if
255
- # `lsof` itself isn't available on this system.
285
+ # `lsof` itself isn't available / usable on this system.
286
+ #
287
+ # `lsof -ti` exits 1 with empty stdout/stderr when nothing matches the
288
+ # filter — that's the common "port not held" case, so we treat
289
+ # (exit != 0, empty stdout, empty stderr) as []. A non-zero exit with
290
+ # stderr content is a real lsof failure (broken install, permission
291
+ # error, etc.); surface that as `nil` so the caller raises a clear
292
+ # BinaryError instead of silently retrying the start.
256
293
  def pids_listening_on(port)
257
- stdout, _, status = Open3.capture3("lsof", "-ti", "tcp:#{port}")
258
- return [] unless status.success?
294
+ stdout, stderr, status = Open3.capture3("lsof", "-ti", "tcp:#{port}")
295
+ return parse_lsof_pids(stdout) if status.success?
296
+ return [] if stdout.strip.empty? && stderr.strip.empty?
259
297
 
298
+ nil
299
+ rescue Errno::ENOENT
300
+ nil
301
+ end
302
+
303
+ def parse_lsof_pids(stdout)
260
304
  stdout.split("\n").filter_map do |line|
261
305
  pid = line.strip.to_i
262
306
  pid.positive? ? pid : nil
263
307
  end
264
- rescue Errno::ENOENT
265
- nil
266
308
  end
267
309
 
268
310
  # Class method so the finalizer proc doesn't capture `self` (which
@@ -280,8 +322,19 @@ module Capybara
280
322
  end
281
323
  end
282
324
 
325
+ # `start` may be called more than once on the same Process instance
326
+ # (Browser#restart_process_if_dead runs `stop` then `start` after a
327
+ # crash). Each `attempt_start` calls `register_finalizer`, and
328
+ # ObjectSpace allows multiple finalizers per object — so without
329
+ # this guard the second start would queue a redundant TERM-on-GC
330
+ # whose first invocation no-ops on ESRCH but is still pure noise.
331
+ # We register exactly once; the captured `pid` is overwritten by
332
+ # `undefine_finalizer + define_finalizer` so the finalizer always
333
+ # references the most recently started process.
283
334
  def register_finalizer(pid)
335
+ ObjectSpace.undefine_finalizer(self) if @finalizer_registered
284
336
  ObjectSpace.define_finalizer(self, self.class.send(:weak_kill, pid))
337
+ @finalizer_registered = true
285
338
  end
286
339
 
287
340
  def cleanup_pipes
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "capybara-lightpanda"
4
+
5
+ namespace :lightpanda do
6
+ namespace :binary do
7
+ desc "Print the version of the cached Lightpanda binary"
8
+ task :version do
9
+ version = Capybara::Lightpanda::Binary.current_version
10
+ if version
11
+ puts version
12
+ else
13
+ warn "No cached Lightpanda binary at #{Capybara::Lightpanda::Binary.install_path}"
14
+ exit 1
15
+ end
16
+ end
17
+
18
+ desc "Download the Lightpanda binary (optionally pinned: rake lightpanda:binary:update[0.3.0])"
19
+ task :update, [:version] do |_, args|
20
+ Capybara::Lightpanda::Binary.required_version = args[:version] if args[:version]
21
+ path = Capybara::Lightpanda::Binary.update
22
+ puts "Lightpanda binary ready at #{path}"
23
+ end
24
+
25
+ desc "Remove the cached Lightpanda binary"
26
+ task :remove do
27
+ removed = Capybara::Lightpanda::Binary.remove
28
+ if removed
29
+ puts "Removed #{removed}"
30
+ else
31
+ puts "Nothing to remove at #{Capybara::Lightpanda::Binary.install_path}"
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Capybara
4
+ module Lightpanda
5
+ module Utils
6
+ # Block-based polling helper. Borrowed from selenium-webdriver's
7
+ # Wait class (rb/lib/selenium/webdriver/common/wait.rb). Sibling
8
+ # of Utils::Attempt — Attempt retries on a specific error class,
9
+ # Wait loops until the block returns truthy.
10
+ module Wait
11
+ DEFAULT_INTERVAL = 0.1
12
+
13
+ # Polls the block until it returns a truthy value or `timeout`
14
+ # seconds elapse. Exceptions whose class is listed in `ignore`
15
+ # are swallowed between polls; the most recent one is appended
16
+ # to the timeout message so the failure stays diagnosable.
17
+ #
18
+ # @raise [Capybara::Lightpanda::TimeoutError] if the block never
19
+ # returns truthy within `timeout` seconds.
20
+ # @return the truthy value returned by the block.
21
+ def self.until(timeout:, interval: DEFAULT_INTERVAL, ignore: [], message: nil)
22
+ deadline = monotonic_time + timeout
23
+ last_error = nil
24
+ loop do
25
+ begin
26
+ result = yield
27
+ return result if result
28
+ rescue *Array(ignore) => e
29
+ last_error = e
30
+ end
31
+
32
+ break if monotonic_time > deadline
33
+
34
+ sleep interval
35
+ end
36
+
37
+ msg = message || "timed out after #{timeout}s"
38
+ msg = "#{msg} (#{last_error.message})" if last_error
39
+ raise Capybara::Lightpanda::TimeoutError, msg
40
+ end
41
+
42
+ def self.monotonic_time
43
+ ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Capybara
4
4
  module Lightpanda
5
- VERSION = "0.2.2"
5
+ VERSION = "0.4.0"
6
6
  end
7
7
  end
@@ -10,14 +10,16 @@ require_relative "capybara/lightpanda/binary"
10
10
  require_relative "capybara/lightpanda/process"
11
11
  require_relative "capybara/lightpanda/utils/event"
12
12
  require_relative "capybara/lightpanda/utils/attempt"
13
+ require_relative "capybara/lightpanda/utils/wait"
13
14
  require_relative "capybara/lightpanda/client"
15
+ require_relative "capybara/lightpanda/headers"
14
16
  require_relative "capybara/lightpanda/network"
15
17
  require_relative "capybara/lightpanda/cookies"
16
18
  require_relative "capybara/lightpanda/keyboard"
17
- require_relative "capybara/lightpanda/frame"
18
19
  require_relative "capybara/lightpanda/browser"
19
- require_relative "capybara/lightpanda/xpath_polyfill"
20
+ require_relative "capybara/lightpanda/auto_scripts"
20
21
  require_relative "capybara/lightpanda/node"
22
+ require_relative "capybara/lightpanda/element_extension"
21
23
  require_relative "capybara/lightpanda/driver"
22
24
 
23
25
  module Capybara