capybara-lightpanda 0.1.0 → 0.2.1

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.
@@ -9,6 +9,8 @@ module Capybara
9
9
  @browser = browser
10
10
  @traffic = []
11
11
  @enabled = false
12
+ @request_handler = nil
13
+ @response_handler = nil
12
14
  end
13
15
 
14
16
  def enable
@@ -22,7 +24,13 @@ module Capybara
22
24
  def disable
23
25
  return unless @enabled
24
26
 
27
+ # Tell the browser to stop emitting BEFORE unsubscribing locally:
28
+ # otherwise an in-flight Network.responseReceived can race past the
29
+ # already-removed handler and leave a `response: nil` entry in
30
+ # @traffic for the matching request — which then trips
31
+ # wait_for_idle's pending count on a future call.
25
32
  browser.command("Network.disable")
33
+ unsubscribe
26
34
  @enabled = false
27
35
  end
28
36
 
@@ -34,6 +42,18 @@ module Capybara
34
42
  @traffic.clear
35
43
  end
36
44
 
45
+ # Wipe local state without sending Network.disable. Called by
46
+ # Browser#reset after Target.disposeBrowserContext, which destroys
47
+ # the subscriptions and the Network domain along with the context —
48
+ # leaving @enabled true would silently no-op the next #enable.
49
+ # Also unsubscribes locally so we don't rely on the caller having
50
+ # cleared the Subscriber first.
51
+ def reset
52
+ unsubscribe
53
+ @traffic.clear
54
+ @enabled = false
55
+ end
56
+
37
57
  def headers=(headers)
38
58
  @extra_headers = headers
39
59
  browser.page_command("Network.setExtraHTTPHeaders", headers: headers)
@@ -65,7 +85,7 @@ module Capybara
65
85
  private
66
86
 
67
87
  def subscribe
68
- browser.on("Network.requestWillBeSent") do |params|
88
+ @request_handler = lambda do |params|
69
89
  @traffic << {
70
90
  request_id: params["requestId"],
71
91
  url: params.dig("request", "url"),
@@ -75,7 +95,7 @@ module Capybara
75
95
  }
76
96
  end
77
97
 
78
- browser.on("Network.responseReceived") do |params|
98
+ @response_handler = lambda do |params|
79
99
  request = @traffic.find { |t| t[:request_id] == params["requestId"] }
80
100
 
81
101
  next unless request
@@ -86,6 +106,16 @@ module Capybara
86
106
  mime_type: params.dig("response", "mimeType"),
87
107
  }
88
108
  end
109
+
110
+ browser.on("Network.requestWillBeSent", &@request_handler)
111
+ browser.on("Network.responseReceived", &@response_handler)
112
+ end
113
+
114
+ def unsubscribe
115
+ browser.off("Network.requestWillBeSent", @request_handler) if @request_handler
116
+ browser.off("Network.responseReceived", @response_handler) if @response_handler
117
+ @request_handler = nil
118
+ @response_handler = nil
89
119
  end
90
120
  end
91
121
  end
@@ -116,6 +116,14 @@ module Capybara
116
116
  call("function() { this.dispatchEvent(new MouseEvent('mouseover', {bubbles: true, cancelable: true})) }")
117
117
  end
118
118
 
119
+ # Dispatch an arbitrary DOM event by name. Mirrors Cuprite's Node#trigger
120
+ # — picks the right Event constructor for known mouse/focus/form names
121
+ # and falls back to a generic Event for everything else (so callers can
122
+ # fire custom events like `node.trigger('lp:custom')`).
123
+ def trigger(event)
124
+ call(TRIGGER_JS, event.to_s)
125
+ end
126
+
119
127
  def set(value, **_options)
120
128
  case tag_name
121
129
  when "input"
@@ -246,8 +254,6 @@ module Capybara
246
254
  raise ObsoleteNode.new(self, "Node is no longer attached to the document")
247
255
  end
248
256
 
249
- # Trigger implicit form submission via the IMPLICIT_SUBMIT_JS pipeline
250
- # (same fetch+swap as CLICK_JS, but without a submitter).
251
257
  def implicit_submit
252
258
  call(IMPLICIT_SUBMIT_JS)
253
259
  driver.browser.wait_for_idle
@@ -277,7 +283,7 @@ module Capybara
277
283
  # HTML implicit-submission: a trailing \n in a text-like input is like the
278
284
  # user pressing Enter — submits the form when there's a default submit
279
285
  # 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.
286
+ # trigger submission via IMPLICIT_SUBMIT_JS.
281
287
  def fill_text_input(type, str)
282
288
  if str.end_with?("\n") && TEXT_LIKE_INPUT_TYPES.include?(type)
283
289
  call(SET_VALUE_JS, truncate_to_maxlength(str.chomp))
@@ -341,13 +347,6 @@ module Capybara
341
347
  driver.browser.with_default_context_wait do
342
348
  driver.browser.call_function_on(@remote_object_id, function_declaration, *args)
343
349
  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
350
  rescue JavaScriptError => e
352
351
  case e.class_name
353
352
  when "InvalidSelector"
@@ -355,148 +354,84 @@ module Capybara
355
354
  else
356
355
  raise
357
356
  end
357
+ rescue BrowserError => e
358
+ case e.message
359
+ when /MouseEventFailed/i
360
+ raise MouseEventFailed.new(self, e.response&.dig("message"))
361
+ else
362
+ raise
363
+ end
358
364
  end
359
365
 
360
- # Form-submit click bypass for Lightpanda.
366
+ # Native `this.click()` reaches all ancestors on the happy path, but if any
367
+ # listener throws (Stimulus / Turbo edge cases) Lightpanda halts dispatch
368
+ # instead of reporting the exception per DOM §2.9 step 4 (see UPSTREAM_BUGS.md
369
+ # Bug #3). Dispatching via JS routes through `polyfills.js`'s patchDispatch
370
+ # IIFE, which catches the throw and re-walks parents manually so document-
371
+ # level delegated handlers still see the event.
361
372
  #
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.
373
+ # We dispatch a `MouseEvent` (not a generic `Event`) because Turbo's link
374
+ # and form interceptors guard with `event instanceof MouseEvent` before
375
+ # they consider intercepting a synthetic `Event('click')` is silently
376
+ # ignored by Turbo Frame / Drive, and CLICK_JS would then fall through to
377
+ # the manual default action below, which does a full-page navigation
378
+ # instead of a frame swap.
367
379
  #
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.
380
+ # For submit buttons (`<button type=submit>`, `<input type=submit>`,
381
+ # `<input type=image>`): route through `form.requestSubmit(this)` so the
382
+ # browser dispatches a real `SubmitEvent` with submitter set, honors the
383
+ # submitter's `formaction` / `formmethod` / `formenctype`, and includes
384
+ # the submitter's name/value in the form data. A manual
385
+ # `dispatchEvent(new Event('submit'))` + `form.submit()` would lose all of
386
+ # that and break Turbo Drive / Hotwire form handling. We can't rely on
387
+ # the synthetic click's default action because synthetic events don't
388
+ # trigger the implicit form-submission default action per DOM spec.
377
389
  CLICK_JS = <<~JS
378
390
  function() {
379
- var tag = this.tagName.toLowerCase();
391
+ var EventCtor = (typeof MouseEvent !== 'undefined') ? MouseEvent : Event;
392
+ var clickEvt = new EventCtor('click', { bubbles: true, cancelable: true });
393
+ var notCancelled = true;
394
+ try {
395
+ notCancelled = this.dispatchEvent(clickEvt);
396
+ } catch (e) { /* patchDispatch in polyfills.js rescues bubble phase */ }
397
+ if (!notCancelled || clickEvt.defaultPrevented) return;
398
+ var tag = this.tagName;
380
399
  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);
400
+ var isSubmitButton =
401
+ (tag === 'BUTTON' && (type === 'submit' || type === '')) ||
402
+ (tag === 'INPUT' && (type === 'submit' || type === 'image'));
403
+ if (isSubmitButton && this.form) {
404
+ this.form.requestSubmit(this);
405
+ } else if (tag === 'A' && this.href && this.target !== '_blank') {
406
+ window.location.href = this.href;
451
407
  }
408
+ }
409
+ JS
452
410
 
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;
411
+ # Mirrors Cuprite's trigger map. Picks the right Event constructor based
412
+ # on the event name so listeners that key on `event instanceof MouseEvent`
413
+ # / `instanceof SubmitEvent` see what they expect; everything else goes
414
+ # through a generic Event so custom names ("turbo:load", "lp:custom")
415
+ # still dispatch. Each constructor is feature-detected (`typeof X !==
416
+ # 'undefined'`) before use so a missing IDL on Lightpanda falls back
417
+ # to plain Event rather than throwing.
418
+ TRIGGER_JS = <<~JS
419
+ function(name) {
420
+ var MOUSE = ['click','dblclick','mousedown','mouseenter','mouseleave',
421
+ 'mousemove','mouseover','mouseout','mouseup','contextmenu'];
422
+ var FOCUS = ['blur','focus','focusin','focusout'];
423
+ var init = { bubbles: true, cancelable: true };
424
+ var event;
425
+ if (MOUSE.indexOf(name) !== -1 && typeof MouseEvent !== 'undefined') {
426
+ event = new MouseEvent(name, init);
427
+ } else if (FOCUS.indexOf(name) !== -1 && typeof FocusEvent !== 'undefined') {
428
+ event = new FocusEvent(name, init);
429
+ } else if (name === 'submit' && typeof SubmitEvent !== 'undefined') {
430
+ event = new SubmitEvent(name, init);
487
431
  } else {
488
- opts.headers = { 'Content-Type': 'application/x-www-form-urlencoded' };
489
- opts.body = formEncode(formData);
432
+ event = new Event(name, init);
490
433
  }
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
- });
434
+ this.dispatchEvent(event);
500
435
  }
501
436
  JS
502
437
 
@@ -525,7 +460,14 @@ module Capybara
525
460
  autofocus: 'autofocus', required: 'required' };
526
461
  var prop = BOOL_PROP[name.toLowerCase()];
527
462
  if (prop && this[prop] !== undefined) return this[prop];
528
- return this.getAttribute(name);
463
+ if (this.hasAttribute(name)) return this.getAttribute(name);
464
+ // Property-only fallback: things like `validationMessage` have no
465
+ // backing HTML attribute. Return primitives only — DOM-node properties
466
+ // (form, options, etc.) shouldn't leak through.
467
+ var live = this[name];
468
+ if (live === null || live === undefined) return null;
469
+ var t = typeof live;
470
+ return (t === 'string' || t === 'number' || t === 'boolean') ? live : null;
529
471
  }
530
472
  JS
531
473
 
@@ -548,77 +490,25 @@ module Capybara
548
490
  }
549
491
  JS
550
492
 
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).
493
+ # HTML implicit-submission: a trailing \n in a text-like input acts like
494
+ # pressing Enter — submits the form if it has a default submit button OR
495
+ # exactly one submittable text control. Click the default button (so
496
+ # click handlers fire) or fall back to `form.requestSubmit()` so the
497
+ # `submit` event still dispatches.
556
498
  IMPLICIT_SUBMIT_JS = <<~JS
557
499
  function() {
558
500
  var form = this.form;
559
501
  if (!form) return;
560
- var hasDefault = !!form.querySelector(
502
+ var btn = form.querySelector(
561
503
  'button[type=submit], button:not([type]), input[type=submit], input[type=image]'
562
504
  );
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
- });
505
+ if (btn) { btn.click(); return; }
506
+ var textInputs = form.querySelectorAll(
507
+ 'input[type=text], input[type=email], input[type=password], ' +
508
+ 'input[type=url], input[type=tel], input[type=search], ' +
509
+ 'input[type=number], input:not([type])'
510
+ );
511
+ if (textInputs.length === 1) form.requestSubmit();
622
512
  }
623
513
  JS
624
514
 
@@ -659,8 +549,6 @@ module Capybara
659
549
 
660
550
  SET_CHECKBOX_JS = <<~JS
661
551
  function(value) {
662
- // Use `click()` so user-installed click/change handlers fire and
663
- // observe a real toggle. No-op if already in the requested state.
664
552
  if (this.checked !== value) this.click();
665
553
  }
666
554
  JS
@@ -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,32 @@
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),
26
+ # PR #2352 (HTMLInputElement.pattern + patternMismatch via V8 RegExp).
27
+ # Build 6051 = main HEAD d360fcc0 (2026-05-04); ships in nightly
28
+ # published 2026-05-05 ~03:30 UTC for all four platforms.
29
+ MINIMUM_NIGHTLY_BUILD = Gem::Version.new("6051")
12
30
 
13
31
  attr_reader :pid, :ws_url, :version, :nightly_build
14
32
 
@@ -76,14 +94,17 @@ module Capybara
76
94
  def check_minimum_version(binary_path)
77
95
  stdout, = Open3.capture3(binary_path, "version")
78
96
  @version = stdout.strip
79
- @nightly_build = @version[/nightly\.(\d+)/, 1]&.to_i
97
+ # Accept either `nightly.NNNN` (publicly distributed builds) or
98
+ # `dev.NNNN` (locally compiled trees) — the build number is the same
99
+ # `git rev-list --count HEAD` counter, just labelled differently.
100
+ build = @version[/(?:nightly|dev)\.(\d+)/, 1]
101
+ @nightly_build = Gem::Version.new(build) if build
80
102
 
81
103
  return if @nightly_build && @nightly_build >= MINIMUM_NIGHTLY_BUILD
82
104
 
83
105
  raise BinaryError,
84
106
  "Lightpanda #{@version} is too old. " \
85
- "This gem requires nightly build >= #{MINIMUM_NIGHTLY_BUILD} " \
86
- "(Page.addScriptToEvaluateOnNewDocument support). " \
107
+ "This gem requires build >= #{MINIMUM_NIGHTLY_BUILD}. " \
87
108
  "Update: curl -sL https://github.com/lightpanda-io/browser/releases/download/nightly/" \
88
109
  "#{Binary.platform_binary} -o #{binary_path} && chmod +x #{binary_path}"
89
110
  rescue Errno::ENOENT
@@ -204,17 +225,22 @@ module Capybara
204
225
  @pid = nil
205
226
  end
206
227
 
228
+ # Auto-recover when a previous Lightpanda is still bound to our port.
229
+ # Best-effort: relies on `lsof` to map port → pid (macOS / most Linux
230
+ # distros). Where `lsof` isn't on PATH, we surface a clear error rather
231
+ # than silently failing the retry — the user can free the port manually.
207
232
  def kill_process_on_port(port)
208
233
  port = port.to_i
209
234
  return if port <= 0
210
235
 
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
236
+ pids = pids_listening_on(port)
237
+ if pids.nil?
238
+ raise BinaryError,
239
+ "Port #{port} is in use and `lsof` is unavailable to identify the holder. " \
240
+ "Free the port manually or install lsof to enable automatic recovery."
241
+ end
217
242
 
243
+ pids.each do |pid|
218
244
  ::Process.kill("TERM", pid)
219
245
  rescue Errno::ESRCH, Errno::EPERM
220
246
  nil
@@ -223,6 +249,20 @@ module Capybara
223
249
  sleep 0.5
224
250
  end
225
251
 
252
+ # Returns an array of PIDs holding the TCP port, [] if none, or nil if
253
+ # `lsof` itself isn't available on this system.
254
+ def pids_listening_on(port)
255
+ stdout, _, status = Open3.capture3("lsof", "-ti", "tcp:#{port}")
256
+ return [] unless status.success?
257
+
258
+ stdout.split("\n").filter_map do |line|
259
+ pid = line.strip.to_i
260
+ pid.positive? ? pid : nil
261
+ end
262
+ rescue Errno::ENOENT
263
+ nil
264
+ end
265
+
226
266
  # Class method so the finalizer proc doesn't capture `self` (which
227
267
  # would prevent GC from ever running the finalizer).
228
268
  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.1"
6
6
  end
7
7
  end
@@ -5,6 +5,11 @@ module Capybara
5
5
  module XPathPolyfill
6
6
  JS_PATH = File.expand_path("javascripts/index.js", __dir__).freeze
7
7
  JS = File.read(JS_PATH).freeze
8
+
9
+ # Polyfills pour les APIs DOM manquantes du binaire Lightpanda.
10
+ # Voir UPSTREAM_BUGS.md à la racine du gem.
11
+ POLYFILLS_PATH = File.expand_path("javascripts/polyfills.js", __dir__).freeze
12
+ POLYFILLS_JS = File.read(POLYFILLS_PATH).freeze
8
13
  end
9
14
  end
10
15
  end