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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +45 -0
- data/README.md +22 -179
- data/lib/capybara/lightpanda/binary.rb +1 -1
- data/lib/capybara/lightpanda/browser.rb +179 -85
- data/lib/capybara/lightpanda/client/subscriber.rb +29 -2
- data/lib/capybara/lightpanda/client/web_socket.rb +4 -2
- data/lib/capybara/lightpanda/client.rb +7 -0
- data/lib/capybara/lightpanda/cookies.rb +14 -53
- data/lib/capybara/lightpanda/driver.rb +43 -6
- data/lib/capybara/lightpanda/errors.rb +58 -15
- data/lib/capybara/lightpanda/javascripts/index.js +33 -143
- data/lib/capybara/lightpanda/javascripts/polyfills.js +81 -0
- data/lib/capybara/lightpanda/keyboard.rb +45 -4
- data/lib/capybara/lightpanda/network.rb +32 -2
- data/lib/capybara/lightpanda/node.rb +97 -209
- data/lib/capybara/lightpanda/options.rb +9 -1
- data/lib/capybara/lightpanda/process.rb +53 -13
- data/lib/capybara/lightpanda/utils/attempt.rb +30 -0
- data/lib/capybara/lightpanda/version.rb +1 -1
- data/lib/capybara/lightpanda/xpath_polyfill.rb +5 -0
- data/lib/capybara-lightpanda.rb +1 -0
- metadata +3 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
363
|
-
#
|
|
364
|
-
#
|
|
365
|
-
#
|
|
366
|
-
# the
|
|
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
|
|
369
|
-
#
|
|
370
|
-
#
|
|
371
|
-
#
|
|
372
|
-
#
|
|
373
|
-
#
|
|
374
|
-
#
|
|
375
|
-
#
|
|
376
|
-
#
|
|
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
|
|
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
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
var
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
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
|
-
|
|
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:
|
|
552
|
-
#
|
|
553
|
-
#
|
|
554
|
-
#
|
|
555
|
-
#
|
|
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
|
|
502
|
+
var btn = form.querySelector(
|
|
561
503
|
'button[type=submit], button:not([type]), input[type=submit], input[type=image]'
|
|
562
504
|
);
|
|
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
|
-
});
|
|
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, :
|
|
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
|
|
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),
|
|
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
|
-
|
|
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
|
|
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 =
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
|
@@ -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
|