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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +34 -0
- data/README.md +22 -179
- data/lib/capybara/lightpanda/browser.rb +45 -69
- data/lib/capybara/lightpanda/client/web_socket.rb +4 -2
- data/lib/capybara/lightpanda/cookies.rb +14 -53
- data/lib/capybara/lightpanda/driver.rb +39 -2
- data/lib/capybara/lightpanda/errors.rb +33 -15
- data/lib/capybara/lightpanda/javascripts/index.js +33 -143
- data/lib/capybara/lightpanda/keyboard.rb +45 -4
- data/lib/capybara/lightpanda/node.rb +22 -217
- data/lib/capybara/lightpanda/options.rb +9 -1
- data/lib/capybara/lightpanda/process.rb +52 -13
- data/lib/capybara/lightpanda/utils/attempt.rb +30 -0
- data/lib/capybara/lightpanda/version.rb +1 -1
- data/lib/capybara-lightpanda.rb +1 -0
- metadata +2 -1
|
@@ -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
|
-
#
|
|
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
|
-
|
|
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:
|
|
552
|
-
#
|
|
553
|
-
#
|
|
554
|
-
#
|
|
555
|
-
#
|
|
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
|
|
417
|
+
var btn = form.querySelector(
|
|
561
418
|
'button[type=submit], button:not([type]), input[type=submit], input[type=image]'
|
|
562
419
|
);
|
|
563
|
-
if (
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
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, :
|
|
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
|
|
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
|
-
#
|
|
10
|
-
#
|
|
11
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
data/lib/capybara-lightpanda.rb
CHANGED
|
@@ -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.
|
|
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
|