capybara-lightpanda 0.1.0 → 0.2.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.
@@ -246,8 +246,6 @@ module Capybara
246
246
  raise ObsoleteNode.new(self, "Node is no longer attached to the document")
247
247
  end
248
248
 
249
- # Trigger implicit form submission via the IMPLICIT_SUBMIT_JS pipeline
250
- # (same fetch+swap as CLICK_JS, but without a submitter).
251
249
  def implicit_submit
252
250
  call(IMPLICIT_SUBMIT_JS)
253
251
  driver.browser.wait_for_idle
@@ -277,7 +275,7 @@ module Capybara
277
275
  # HTML implicit-submission: a trailing \n in a text-like input is like the
278
276
  # user pressing Enter — submits the form when there's a default submit
279
277
  # button OR exactly one text control. Strip the \n, set the value, then
280
- # route through IMPLICIT_SUBMIT_JS so CLICK_JS's fetch+swap runs.
278
+ # trigger submission via IMPLICIT_SUBMIT_JS.
281
279
  def fill_text_input(type, str)
282
280
  if str.end_with?("\n") && TEXT_LIKE_INPUT_TYPES.include?(type)
283
281
  call(SET_VALUE_JS, truncate_to_maxlength(str.chomp))
@@ -341,13 +339,6 @@ module Capybara
341
339
  driver.browser.with_default_context_wait do
342
340
  driver.browser.call_function_on(@remote_object_id, function_declaration, *args)
343
341
  end
344
- rescue BrowserError => e
345
- case e.message
346
- when /MouseEventFailed/i
347
- raise MouseEventFailed.new(self, e.response&.dig("message"))
348
- else
349
- raise
350
- end
351
342
  rescue JavaScriptError => e
352
343
  case e.class_name
353
344
  when "InvalidSelector"
@@ -355,150 +346,16 @@ module Capybara
355
346
  else
356
347
  raise
357
348
  end
349
+ rescue BrowserError => e
350
+ case e.message
351
+ when /MouseEventFailed/i
352
+ raise MouseEventFailed.new(self, e.response&.dig("message"))
353
+ else
354
+ raise
355
+ end
358
356
  end
359
357
 
360
- # Form-submit click bypass for Lightpanda.
361
- #
362
- # Lightpanda's `form.submit()` does NOT navigate — it parses, validates, but
363
- # never issues an HTTP request. And `document.write()` is a no-op (verified
364
- # 2026-04-26: body length unchanged after open/write/close). So both the
365
- # native submit path and the previous `fetch+document.write` workaround leave
366
- # the page on the original URL with the form still rendered.
367
- #
368
- # For submit-button clicks we instead `fetch` the form action ourselves,
369
- # parse the response with `DOMParser`, swap `document.body.innerHTML`, and
370
- # `history.replaceState` the response URL. `_lightpanda` and the XPath
371
- # polyfill survive the swap because we don't reload the document.
372
- #
373
- # For non-submit elements (links, regular buttons, anchors) we fall through
374
- # to native `this.click()`. Turbo Drive's click handler — when Turbo is
375
- # loaded — intercepts that natively, runs its own fetch+replaceWith, and
376
- # works fine on Lightpanda after the `#id` rewriter polyfill in index.js.
377
- CLICK_JS = <<~JS
378
- function() {
379
- var tag = this.tagName.toLowerCase();
380
- var type = (this.type || '').toLowerCase();
381
- // <button> with no `type` attribute defaults to submit per HTML.
382
- var isSubmitBtn = (tag === 'button' && (type === '' || type === 'submit')) ||
383
- (tag === 'input' && (type === 'submit' || type === 'image'));
384
- var form = isSubmitBtn ? this.form : null;
385
- // Lightpanda doesn't propagate label clicks to their associated
386
- // form control the way browsers do, so when Capybara clicks a
387
- // <label> for a hidden checkbox/radio (automatic_label_click)
388
- // we explicitly forward the click.
389
- if (tag === 'label') {
390
- this.click();
391
- var ctrl = null;
392
- var forId = this.getAttribute('for');
393
- if (forId) ctrl = this.ownerDocument.getElementById(forId);
394
- if (!ctrl) ctrl = this.querySelector('input, select, textarea');
395
- if (ctrl) {
396
- var ctype = (ctrl.type || '').toLowerCase();
397
- if (ctype === 'checkbox' || ctype === 'radio') ctrl.click();
398
- }
399
- return;
400
- }
401
- if (!form) {
402
- this.click();
403
- // Lightpanda doesn't toggle <details> when its <summary> is clicked.
404
- // Walk up to the nearest <details> (only if click hit a summary
405
- // and we haven't been preventDefault'd by user JS) and flip `open`.
406
- if (tag === 'summary') {
407
- var d = this.parentNode;
408
- while (d && d.nodeType === 1 && d.tagName.toLowerCase() !== 'details') {
409
- d = d.parentNode;
410
- }
411
- if (d && d.tagName && d.tagName.toLowerCase() === 'details') {
412
- d.open = !d.open;
413
- }
414
- }
415
- return;
416
- }
417
-
418
- // Fire the submit event first so user JS handlers can intercept and
419
- // preventDefault — but skip this when Turbo is loaded, because Turbo's
420
- // submit pipeline throws on Lightpanda (and the gem already handles the
421
- // navigation below). Turbo's link-click pipeline still works fine.
422
- if (typeof Turbo === 'undefined') {
423
- var ev;
424
- if (typeof SubmitEvent === 'function') {
425
- ev = new SubmitEvent('submit', { bubbles: true, cancelable: true, submitter: this });
426
- } else {
427
- ev = new Event('submit', { bubbles: true, cancelable: true });
428
- ev.submitter = this;
429
- }
430
- var allowed = form.dispatchEvent(ev);
431
- if (!allowed) return;
432
- }
433
-
434
- // No handler intercepted — fetch + swap ourselves because Lightpanda's
435
- // native form.submit() does not navigate.
436
- // Pass the submitter so the button is serialized at its document
437
- // position alongside the form's other named controls.
438
- var formData;
439
- try { formData = new FormData(form, this); }
440
- catch (e) { formData = new FormData(form); }
441
- var submitterName = this.getAttribute('name');
442
- if (submitterName && !formData.has(submitterName)) {
443
- // Lightpanda's FormData(form, submitter) may omit a <button> with no
444
- // explicit value attribute; HTML says the value falls back to
445
- // textContent, so feed that in ourselves when the entry is missing.
446
- var submitterValue = this.getAttribute('value');
447
- if (submitterValue === null) {
448
- submitterValue = (tag === 'button') ? (this.textContent || '') : '';
449
- }
450
- formData.append(submitterName, submitterValue);
451
- }
452
-
453
- var action = this.getAttribute('formaction') || form.getAttribute('action') || window.location.href;
454
- try { action = new URL(action, window.location.href).href; } catch (e) {}
455
- var method = (this.getAttribute('formmethod') || form.getAttribute('method') || 'GET').toUpperCase();
456
-
457
- var enctype = (this.getAttribute('formenctype') ||
458
- form.getAttribute('enctype') ||
459
- 'application/x-www-form-urlencoded').toLowerCase();
460
- // Lightpanda's URLSearchParams.toString() drops the `=` when the value
461
- // is an empty string (`{key: ""}` serializes as `key`, not `key=`),
462
- // which makes the server parse the field as nil instead of "". Lightpanda
463
- // also doesn't perform the HTML-spec LF→CRLF normalization for textarea
464
- // values during form submission. Build the query string by hand so both
465
- // round-trip correctly.
466
- var formEncode = function(fd) {
467
- var pairs = [];
468
- for (var entry of fd.entries()) {
469
- var value = entry[1];
470
- if (typeof value === 'string') {
471
- // Normalize line endings to CRLF per HTML form-data set spec.
472
- value = value.replace(/\\r\\n|\\r|\\n/g, '\\r\\n');
473
- }
474
- pairs.push(encodeURIComponent(entry[0]).replace(/%20/g, '+') +
475
- '=' +
476
- encodeURIComponent(value).replace(/%20/g, '+'));
477
- }
478
- return pairs.join('&');
479
- };
480
- var opts = { method: method, credentials: 'same-origin', redirect: 'follow' };
481
- if (method === 'GET') {
482
- var sep = action.indexOf('?') >= 0 ? '&' : '?';
483
- action = action + sep + formEncode(formData);
484
- } else if (enctype === 'multipart/form-data') {
485
- // Pass FormData directly — fetch sets Content-Type with the correct boundary.
486
- opts.body = formData;
487
- } else {
488
- opts.headers = { 'Content-Type': 'application/x-www-form-urlencoded' };
489
- opts.body = formEncode(formData);
490
- }
491
-
492
- return fetch(action, opts).then(function(r) {
493
- return r.text().then(function(html) { return { url: r.url, html: html }; });
494
- }).then(function(o) {
495
- var doc = new DOMParser().parseFromString(o.html, 'text/html');
496
- document.title = (doc.title || '');
497
- document.body.innerHTML = doc.body.innerHTML;
498
- try { history.replaceState(null, '', o.url); } catch (e) {}
499
- });
500
- }
501
- JS
358
+ CLICK_JS = "function() { this.click() }"
502
359
 
503
360
  VISIBLE_JS = "function() { return _lightpanda.isVisible(this); }"
504
361
 
@@ -548,77 +405,25 @@ module Capybara
548
405
  }
549
406
  JS
550
407
 
551
- # HTML implicit-submission: when the user presses Enter in a text-like
552
- # field, the form is submitted if either (a) there's a default submit
553
- # button, or (b) the form has exactly one submittable text control.
554
- # `this` is the input. Mirror CLICK_JS's submit pipeline so the gem's
555
- # fetch+swap path runs (Lightpanda's form.submit() doesn't navigate).
408
+ # HTML implicit-submission: a trailing \n in a text-like input acts like
409
+ # pressing Enter — submits the form if it has a default submit button OR
410
+ # exactly one submittable text control. Click the default button (so
411
+ # click handlers fire) or fall back to `form.requestSubmit()` so the
412
+ # `submit` event still dispatches.
556
413
  IMPLICIT_SUBMIT_JS = <<~JS
557
414
  function() {
558
415
  var form = this.form;
559
416
  if (!form) return;
560
- var hasDefault = !!form.querySelector(
417
+ var btn = form.querySelector(
561
418
  'button[type=submit], button:not([type]), input[type=submit], input[type=image]'
562
419
  );
563
- if (!hasDefault) {
564
- var textInputs = form.querySelectorAll(
565
- 'input[type=text], input[type=email], input[type=password], ' +
566
- 'input[type=url], input[type=tel], input[type=search], ' +
567
- 'input[type=number], input:not([type])'
568
- );
569
- if (textInputs.length !== 1) return;
570
- }
571
-
572
- if (typeof Turbo === 'undefined') {
573
- var ev;
574
- if (typeof SubmitEvent === 'function') {
575
- ev = new SubmitEvent('submit', { bubbles: true, cancelable: true });
576
- } else {
577
- ev = new Event('submit', { bubbles: true, cancelable: true });
578
- }
579
- var allowed = form.dispatchEvent(ev);
580
- if (!allowed) return;
581
- }
582
-
583
- var formData = new FormData(form);
584
- var action = form.getAttribute('action') || window.location.href;
585
- try { action = new URL(action, window.location.href).href; } catch (e) {}
586
- var method = (form.getAttribute('method') || 'GET').toUpperCase();
587
- var enctype = (form.getAttribute('enctype') || 'application/x-www-form-urlencoded').toLowerCase();
588
-
589
- var formEncode = function(fd) {
590
- var pairs = [];
591
- for (var entry of fd.entries()) {
592
- var value = entry[1];
593
- if (typeof value === 'string') {
594
- value = value.replace(/\\r\\n|\\r|\\n/g, '\\r\\n');
595
- }
596
- pairs.push(encodeURIComponent(entry[0]).replace(/%20/g, '+') +
597
- '=' +
598
- encodeURIComponent(value).replace(/%20/g, '+'));
599
- }
600
- return pairs.join('&');
601
- };
602
-
603
- var opts = { method: method, credentials: 'same-origin', redirect: 'follow' };
604
- if (method === 'GET') {
605
- var sep = action.indexOf('?') >= 0 ? '&' : '?';
606
- action = action + sep + formEncode(formData);
607
- } else if (enctype === 'multipart/form-data') {
608
- opts.body = formData;
609
- } else {
610
- opts.headers = { 'Content-Type': 'application/x-www-form-urlencoded' };
611
- opts.body = formEncode(formData);
612
- }
613
-
614
- return fetch(action, opts).then(function(r) {
615
- return r.text().then(function(html) { return { url: r.url, html: html }; });
616
- }).then(function(o) {
617
- var doc = new DOMParser().parseFromString(o.html, 'text/html');
618
- document.title = (doc.title || '');
619
- document.body.innerHTML = doc.body.innerHTML;
620
- try { history.replaceState(null, '', o.url); } catch (e) {}
621
- });
420
+ if (btn) { btn.click(); return; }
421
+ var textInputs = form.querySelectorAll(
422
+ 'input[type=text], input[type=email], input[type=password], ' +
423
+ 'input[type=url], input[type=tel], input[type=search], ' +
424
+ 'input[type=number], input:not([type])'
425
+ );
426
+ if (textInputs.length === 1) form.requestSubmit();
622
427
  }
623
428
  JS
624
429
 
@@ -7,17 +7,24 @@ module Capybara
7
7
  class Options
8
8
  DEFAULT_TIMEOUT = ENV.fetch("LIGHTPANDA_DEFAULT_TIMEOUT", 15).to_i
9
9
  DEFAULT_PROCESS_TIMEOUT = ENV.fetch("LIGHTPANDA_PROCESS_TIMEOUT", 10).to_i
10
+ # Bounded budget for the WebSocket TCP+Upgrade handshake. Distinct from
11
+ # `timeout` (per-CDP-command budget) because a handshake either succeeds
12
+ # in a few hundred ms or won't — bleeding the full command budget into
13
+ # it just delays the eventual failure.
14
+ DEFAULT_HANDSHAKE_TIMEOUT = ENV.fetch("LIGHTPANDA_HANDSHAKE_TIMEOUT", 5).to_i
10
15
  DEFAULT_HOST = "127.0.0.1"
11
16
  DEFAULT_PORT = 9222
12
17
  DEFAULT_WINDOW_SIZE = [1024, 768].freeze
13
18
 
14
- attr_accessor :host, :port, :timeout, :process_timeout, :window_size, :browser_path, :headless, :logger
19
+ attr_accessor :host, :port, :timeout, :handshake_timeout, :process_timeout,
20
+ :window_size, :browser_path, :headless, :logger
15
21
  attr_writer :ws_url
16
22
 
17
23
  def initialize(options = {})
18
24
  @host = options.fetch(:host, DEFAULT_HOST)
19
25
  @port = options.fetch(:port, DEFAULT_PORT)
20
26
  @timeout = options.fetch(:timeout, DEFAULT_TIMEOUT)
27
+ @handshake_timeout = options.fetch(:handshake_timeout, DEFAULT_HANDSHAKE_TIMEOUT)
21
28
  @process_timeout = options.fetch(:process_timeout, DEFAULT_PROCESS_TIMEOUT)
22
29
  @window_size = options.fetch(:window_size, DEFAULT_WINDOW_SIZE)
23
30
  @browser_path = options[:browser_path]
@@ -39,6 +46,7 @@ module Capybara
39
46
  host: host,
40
47
  port: port,
41
48
  timeout: timeout,
49
+ handshake_timeout: handshake_timeout,
42
50
  process_timeout: process_timeout,
43
51
  window_size: window_size,
44
52
  browser_path: browser_path,
@@ -1,14 +1,31 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "open3"
4
+
3
5
  module Capybara
4
6
  module Lightpanda
5
7
  class Process
6
- READY_PATTERN = /server running.*address=(\d+\.\d+\.\d+\.\d+:\d+)/
8
+ READY_PATTERN = /server running.*address\s*=\s*(\d+\.\d+\.\d+\.\d+:\d+)/m
7
9
  ADDRESS_IN_USE_PATTERN = /err=AddressInUse/
8
10
 
9
- # First nightly with Page.addScriptToEvaluateOnNewDocument (PR #1993, merged 2026-03-30).
10
- # The gem relies on this for XPath polyfill auto-injection.
11
- MINIMUM_NIGHTLY_BUILD = 5267
11
+ # Floor for the cookie/navigation/redirect/modal/keyboard/css/forms
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),
15
+ # PR #2265 (URL fragment inherited across fragment-less redirect),
16
+ # PR #2261 (LP.handleJavaScriptDialog pre-arm), PR #2283 (Referer on
17
+ # cross-page nav), PR #2292 (KeyboardEvent.keyCode/charCode), PR #2294
18
+ # (UA stylesheet display:none for HEAD/SCRIPT/STYLE/NOSCRIPT/TEMPLATE/
19
+ # TITLE/[type=hidden]), PR #2308 (textarea LF→CRLF), PR #2312 (<input
20
+ # type=image> click submits form), PR #2315 (:disabled honors fieldset/
21
+ # optgroup ancestors), PR #2322 (LP dialog defaultText fallback when
22
+ # promptText is null), PR #2324 (<label> click runs activation behavior
23
+ # on labeled control), PR #2286 (HTML constraint validation API:
24
+ # el.validity, validationMessage, checkValidity, reportValidity),
25
+ # PR #2342 (<summary> click toggles parent <details>.open). Build
26
+ # 6005 = main HEAD 0420802f (2026-05-04); ships in nightly published
27
+ # 2026-05-04 03:44 UTC for all four platforms.
28
+ MINIMUM_NIGHTLY_BUILD = Gem::Version.new("6005")
12
29
 
13
30
  attr_reader :pid, :ws_url, :version, :nightly_build
14
31
 
@@ -76,14 +93,17 @@ module Capybara
76
93
  def check_minimum_version(binary_path)
77
94
  stdout, = Open3.capture3(binary_path, "version")
78
95
  @version = stdout.strip
79
- @nightly_build = @version[/nightly\.(\d+)/, 1]&.to_i
96
+ # Accept either `nightly.NNNN` (publicly distributed builds) or
97
+ # `dev.NNNN` (locally compiled trees) — the build number is the same
98
+ # `git rev-list --count HEAD` counter, just labelled differently.
99
+ build = @version[/(?:nightly|dev)\.(\d+)/, 1]
100
+ @nightly_build = Gem::Version.new(build) if build
80
101
 
81
102
  return if @nightly_build && @nightly_build >= MINIMUM_NIGHTLY_BUILD
82
103
 
83
104
  raise BinaryError,
84
105
  "Lightpanda #{@version} is too old. " \
85
- "This gem requires nightly build >= #{MINIMUM_NIGHTLY_BUILD} " \
86
- "(Page.addScriptToEvaluateOnNewDocument support). " \
106
+ "This gem requires build >= #{MINIMUM_NIGHTLY_BUILD}. " \
87
107
  "Update: curl -sL https://github.com/lightpanda-io/browser/releases/download/nightly/" \
88
108
  "#{Binary.platform_binary} -o #{binary_path} && chmod +x #{binary_path}"
89
109
  rescue Errno::ENOENT
@@ -204,17 +224,22 @@ module Capybara
204
224
  @pid = nil
205
225
  end
206
226
 
227
+ # Auto-recover when a previous Lightpanda is still bound to our port.
228
+ # Best-effort: relies on `lsof` to map port → pid (macOS / most Linux
229
+ # distros). Where `lsof` isn't on PATH, we surface a clear error rather
230
+ # than silently failing the retry — the user can free the port manually.
207
231
  def kill_process_on_port(port)
208
232
  port = port.to_i
209
233
  return if port <= 0
210
234
 
211
- pids = `lsof -ti tcp:#{port} 2>/dev/null`.strip
212
- return if pids.empty?
213
-
214
- pids.split("\n").each do |pid_str|
215
- pid = pid_str.strip.to_i
216
- next if pid <= 0
235
+ pids = pids_listening_on(port)
236
+ if pids.nil?
237
+ raise BinaryError,
238
+ "Port #{port} is in use and `lsof` is unavailable to identify the holder. " \
239
+ "Free the port manually or install lsof to enable automatic recovery."
240
+ end
217
241
 
242
+ pids.each do |pid|
218
243
  ::Process.kill("TERM", pid)
219
244
  rescue Errno::ESRCH, Errno::EPERM
220
245
  nil
@@ -223,6 +248,20 @@ module Capybara
223
248
  sleep 0.5
224
249
  end
225
250
 
251
+ # Returns an array of PIDs holding the TCP port, [] if none, or nil if
252
+ # `lsof` itself isn't available on this system.
253
+ def pids_listening_on(port)
254
+ stdout, _, status = Open3.capture3("lsof", "-ti", "tcp:#{port}")
255
+ return [] unless status.success?
256
+
257
+ stdout.split("\n").filter_map do |line|
258
+ pid = line.strip.to_i
259
+ pid.positive? ? pid : nil
260
+ end
261
+ rescue Errno::ENOENT
262
+ nil
263
+ end
264
+
226
265
  # Class method so the finalizer proc doesn't capture `self` (which
227
266
  # would prevent GC from ever running the finalizer).
228
267
  class << self
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Capybara
4
+ module Lightpanda
5
+ module Utils
6
+ # Generic retry helper for transient CDP-class errors. Mirrors ferrum's
7
+ # Utils::Attempt — extracted so callsites don't have to rebuild the
8
+ # rescue/sleep loop. Default `max` and `wait` match ferrum's
9
+ # INTERMITTENT_ATTEMPTS / INTERMITTENT_SLEEP so behavior is predictable
10
+ # across the two ecosystems.
11
+ module Attempt
12
+ INTERMITTENT_ATTEMPTS = 6
13
+ INTERMITTENT_SLEEP = 0.1
14
+
15
+ def self.with_retry(errors:, max: INTERMITTENT_ATTEMPTS, wait: INTERMITTENT_SLEEP)
16
+ attempts = 0
17
+ begin
18
+ yield
19
+ rescue *Array(errors)
20
+ attempts += 1
21
+ raise if attempts >= max
22
+
23
+ sleep wait
24
+ retry
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Capybara
4
4
  module Lightpanda
5
- VERSION = "0.1.0"
5
+ VERSION = "0.2.0"
6
6
  end
7
7
  end
@@ -9,6 +9,7 @@ require_relative "capybara/lightpanda/options"
9
9
  require_relative "capybara/lightpanda/binary"
10
10
  require_relative "capybara/lightpanda/process"
11
11
  require_relative "capybara/lightpanda/utils/event"
12
+ require_relative "capybara/lightpanda/utils/attempt"
12
13
  require_relative "capybara/lightpanda/client"
13
14
  require_relative "capybara/lightpanda/network"
14
15
  require_relative "capybara/lightpanda/cookies"
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.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Navid Emad
@@ -87,6 +87,7 @@ files:
87
87
  - lib/capybara/lightpanda/node.rb
88
88
  - lib/capybara/lightpanda/options.rb
89
89
  - lib/capybara/lightpanda/process.rb
90
+ - lib/capybara/lightpanda/utils/attempt.rb
90
91
  - lib/capybara/lightpanda/utils/event.rb
91
92
  - lib/capybara/lightpanda/version.rb
92
93
  - lib/capybara/lightpanda/xpath_polyfill.rb