capybara-lightpanda 0.14.2 → 0.16.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 +4 -4
- data/lib/capybara/lightpanda/auto_scripts.rb +3 -3
- data/lib/capybara/lightpanda/browser/console.rb +73 -51
- data/lib/capybara/lightpanda/browser/finder.rb +83 -32
- data/lib/capybara/lightpanda/browser/modals.rb +8 -7
- data/lib/capybara/lightpanda/browser/navigation.rb +10 -1
- data/lib/capybara/lightpanda/browser/runtime.rb +3 -3
- data/lib/capybara/lightpanda/browser.rb +13 -4
- data/lib/capybara/lightpanda/client/web_socket.rb +21 -2
- data/lib/capybara/lightpanda/client.rb +4 -1
- data/lib/capybara/lightpanda/cookies.rb +1 -1
- data/lib/capybara/lightpanda/downloads.rb +4 -4
- data/lib/capybara/lightpanda/driver.rb +27 -28
- data/lib/capybara/lightpanda/errors.rb +3 -3
- data/lib/capybara/lightpanda/javascripts/attach.js +2 -1
- data/lib/capybara/lightpanda/javascripts/predicates.js +81 -9
- data/lib/capybara/lightpanda/keyboard.rb +15 -5
- data/lib/capybara/lightpanda/network.rb +1 -1
- data/lib/capybara/lightpanda/node.rb +114 -71
- data/lib/capybara/lightpanda/options.rb +9 -7
- data/lib/capybara/lightpanda/process.rb +71 -6
- data/lib/capybara/lightpanda/version.rb +1 -1
- metadata +1 -2
- data/lib/capybara/lightpanda/javascripts/errors.js +0 -74
|
@@ -11,14 +11,22 @@ module Capybara
|
|
|
11
11
|
# `frame_scoped`: the node lives in an iframe's document — it was found
|
|
12
12
|
# while a frame was switched to, or from another frame-scoped node. See
|
|
13
13
|
# #wrap_with_attached_guard for what that changes.
|
|
14
|
-
def initialize(driver, remote_object_id, frame_scoped: false)
|
|
15
|
-
super(driver, remote_object_id)
|
|
14
|
+
def initialize(driver, remote_object_id, initial_cache = {}, frame_scoped: false)
|
|
15
|
+
super(driver, remote_object_id, initial_cache)
|
|
16
16
|
@remote_object_id = remote_object_id
|
|
17
17
|
@frame_scoped = frame_scoped
|
|
18
|
+
@tag_name = initial_cache[:tag_name]
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Capybara's finder hints → the spec `_lightpanda.findHints` answers.
|
|
22
|
+
# Only tag_name is cached (when the bounded helper runs); Capybara reads
|
|
23
|
+
# initial_cache[:tag_name] before asking the node.
|
|
24
|
+
def self.hint_spec(texts: nil, **)
|
|
25
|
+
texts&.any? ? { texts: texts.map(&:to_s) } : {}
|
|
18
26
|
end
|
|
19
27
|
|
|
20
28
|
# Capybara::Driver::Node#native returns the constructor's second argument,
|
|
21
|
-
# which here is the raw CDP objectId — a String. So the Selenium
|
|
29
|
+
# which here is the raw CDP objectId — a String. So the Selenium
|
|
22
30
|
# idiom `element.native.send_keys(...)` (solidus's
|
|
23
31
|
# return_authorizations_spec.rb does exactly that) died with
|
|
24
32
|
# "undefined method 'send_keys' for an instance of String". This driver
|
|
@@ -112,7 +120,7 @@ module Capybara
|
|
|
112
120
|
self.class.new(driver, result["objectId"])
|
|
113
121
|
end
|
|
114
122
|
|
|
115
|
-
# Smart property/attribute getter
|
|
123
|
+
# Smart property/attribute getter.
|
|
116
124
|
# Returns resolved URLs for src/href, raw attributes otherwise.
|
|
117
125
|
def [](name)
|
|
118
126
|
call(PROPERTY_OR_ATTRIBUTE_JS, name.to_s)
|
|
@@ -127,7 +135,7 @@ module Capybara
|
|
|
127
135
|
end
|
|
128
136
|
|
|
129
137
|
def click(_keys = [], **_options)
|
|
130
|
-
call(CLICK_JS)
|
|
138
|
+
call(CLICK_JS, native_anchor_downloads?)
|
|
131
139
|
driver.browser.wait_for_idle
|
|
132
140
|
end
|
|
133
141
|
|
|
@@ -165,12 +173,14 @@ module Capybara
|
|
|
165
173
|
def scroll_to(*); end
|
|
166
174
|
def scroll_by(*); end
|
|
167
175
|
|
|
168
|
-
# Dispatch an arbitrary DOM event by name.
|
|
169
|
-
#
|
|
170
|
-
#
|
|
171
|
-
#
|
|
176
|
+
# Dispatch an arbitrary DOM event by name. Picks the right Event
|
|
177
|
+
# constructor for known mouse/focus/form names and falls back to a
|
|
178
|
+
# generic Event for everything else (so callers can fire custom events
|
|
179
|
+
# like `node.trigger('lp:custom')`).
|
|
172
180
|
def trigger(event)
|
|
173
|
-
|
|
181
|
+
name = event.to_s
|
|
182
|
+
call(TRIGGER_JS, name)
|
|
183
|
+
driver.browser.wait_for_idle if name == "click"
|
|
174
184
|
end
|
|
175
185
|
|
|
176
186
|
def set(value, **_options)
|
|
@@ -194,10 +204,10 @@ module Capybara
|
|
|
194
204
|
# on this element, so HTML5 dropzones see the payload via
|
|
195
205
|
# `event.dataTransfer`.
|
|
196
206
|
#
|
|
197
|
-
# Files reach the page
|
|
198
|
-
#
|
|
199
|
-
#
|
|
200
|
-
#
|
|
207
|
+
# Files reach the page through a hidden `<input type=file>`: it is
|
|
208
|
+
# attached to this element's document, `DOM.setFileInputFiles` points it
|
|
209
|
+
# at the paths (the browser reads the bytes off disk itself), and the
|
|
210
|
+
# drop JS moves `input.files` into the
|
|
201
211
|
# DataTransfer and removes the input. Previously the bytes were base64'd
|
|
202
212
|
# into the `Runtime.callFunctionOn` message, which capped a drop at
|
|
203
213
|
# ~70 MB under `--cdp-max-message-size` and pinned every byte in Ruby;
|
|
@@ -216,15 +226,15 @@ module Capybara
|
|
|
216
226
|
end
|
|
217
227
|
|
|
218
228
|
# Maps Capybara's documented drop_modifiers aliases onto the DragEvent
|
|
219
|
-
# init keys (`ctrlKey`, `metaKey`, ...).
|
|
229
|
+
# init keys (`ctrlKey`, `metaKey`, ...).
|
|
220
230
|
DRAG_MODIFIER_ALIASES = { control: :ctrl, command: :meta, cmd: :meta }.freeze
|
|
221
231
|
|
|
222
232
|
# Capybara's `Element#drag_to` — HTML5 half only. HTML5_DRAG_JS replays
|
|
223
|
-
# Capybara's own Selenium HTML5_DRAG_DROP_SCRIPT
|
|
224
|
-
#
|
|
225
|
-
#
|
|
226
|
-
#
|
|
227
|
-
#
|
|
233
|
+
# Capybara's own Selenium HTML5_DRAG_DROP_SCRIPT: dragstart on the
|
|
234
|
+
# draggable ancestor, then dragenter -> 2x dragover -> drop (accepted)
|
|
235
|
+
# or dragleave (refused) -> dragend, setTimeout-paced, sharing one
|
|
236
|
+
# DataTransfer so `setData` in the page's dragstart handler is readable
|
|
237
|
+
# at drop. Runs through `evaluate_async` (the script signals
|
|
228
238
|
# completion via the appended callback), so the drag has fully played out
|
|
229
239
|
# before this method returns.
|
|
230
240
|
#
|
|
@@ -235,8 +245,8 @@ module Capybara
|
|
|
235
245
|
# Selenium presses a real button, then apply the same
|
|
236
246
|
# prevented-or-no-draggable-ancestor test.
|
|
237
247
|
#
|
|
238
|
-
# `steps:`/`scroll:` (
|
|
239
|
-
# ignored so suites migrating from
|
|
248
|
+
# `steps:`/`scroll:` (legacy mouse-path knobs of other CDP drivers) are
|
|
249
|
+
# accepted and ignored so suites migrating from them don't ArgumentError.
|
|
240
250
|
def drag_to(other, html5: nil, delay: 0.05, drop_modifiers: [], **)
|
|
241
251
|
keys = Array(drop_modifiers).map { |m| DRAG_MODIFIER_ALIASES.fetch(m.to_sym, m.to_sym).to_s }
|
|
242
252
|
html5 = !call(LEGACY_DRAG_CHECK_JS) if html5.nil?
|
|
@@ -318,20 +328,18 @@ module Capybara
|
|
|
318
328
|
end
|
|
319
329
|
|
|
320
330
|
# Ancestor chain from `parentNode` up to (but not including) `document`,
|
|
321
|
-
# returned as Lightpanda::Node wrappers.
|
|
331
|
+
# returned as Lightpanda::Node wrappers.
|
|
322
332
|
def parents
|
|
323
333
|
oids = driver.browser.parents_of(@remote_object_id)
|
|
324
334
|
oids.map { |oid| self.class.new(driver, oid, frame_scoped: @frame_scoped) }
|
|
325
335
|
end
|
|
326
336
|
|
|
327
|
-
def find_xpath(selector)
|
|
328
|
-
|
|
329
|
-
object_ids.map { |oid| self.class.new(driver, oid, frame_scoped: @frame_scoped) }
|
|
337
|
+
def find_xpath(selector, **hints)
|
|
338
|
+
find_hinted("xpath", selector, hints)
|
|
330
339
|
end
|
|
331
340
|
|
|
332
|
-
def find_css(selector)
|
|
333
|
-
|
|
334
|
-
object_ids.map { |oid| self.class.new(driver, oid, frame_scoped: @frame_scoped) }
|
|
341
|
+
def find_css(selector, **hints)
|
|
342
|
+
find_hinted("css", selector, hints)
|
|
335
343
|
end
|
|
336
344
|
|
|
337
345
|
# Equality compares the underlying DOM node via backendNodeId, the only
|
|
@@ -368,6 +376,23 @@ module Capybara
|
|
|
368
376
|
|
|
369
377
|
private
|
|
370
378
|
|
|
379
|
+
# Below #4124 a download attribute suppresses native activation entirely,
|
|
380
|
+
# so the manual navigation is still needed for attachment responses.
|
|
381
|
+
# The API has no feature marker: keep the floor path through 1.0.x and
|
|
382
|
+
# enable native download activation on build 11097 / the 1.1 release line.
|
|
383
|
+
def native_anchor_downloads?
|
|
384
|
+
browser = driver.browser
|
|
385
|
+
build = browser.nightly_build
|
|
386
|
+
return build >= Gem::Version.new("11097") if build
|
|
387
|
+
|
|
388
|
+
browser.release && browser.release >= Gem::Version.new("1.1.0")
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
def find_hinted(method, selector, hints)
|
|
392
|
+
driver.browser.find_within_hinted(@remote_object_id, method, selector, self.class.hint_spec(**hints))
|
|
393
|
+
.map { |oid, cache| self.class.new(driver, oid, cache, frame_scoped: @frame_scoped) }
|
|
394
|
+
end
|
|
395
|
+
|
|
371
396
|
def implicit_submit
|
|
372
397
|
call(IMPLICIT_SUBMIT_JS)
|
|
373
398
|
driver.browser.wait_for_idle
|
|
@@ -446,8 +471,8 @@ module Capybara
|
|
|
446
471
|
|
|
447
472
|
# ISO 8601 week-of-year, "%G" giving the ISO week-numbering year so that
|
|
448
473
|
# the last days of December that belong to week 1 of the next year are
|
|
449
|
-
# rendered with the correct year. Matches
|
|
450
|
-
#
|
|
474
|
+
# rendered with the correct year. Matches what the user would type into
|
|
475
|
+
# a `<input type=week>` field.
|
|
451
476
|
def format_week_value(value)
|
|
452
477
|
return value.to_s if value.is_a?(String) || !value.respond_to?(:to_date)
|
|
453
478
|
|
|
@@ -492,7 +517,7 @@ module Capybara
|
|
|
492
517
|
self.class.new(driver, oid)
|
|
493
518
|
end
|
|
494
519
|
|
|
495
|
-
# Whitespace-normalized text
|
|
520
|
+
# Whitespace-normalized text. Capybara's text matchers compare
|
|
496
521
|
# against this, and Lightpanda's textContent preserves source-template whitespace
|
|
497
522
|
# differently than Chrome — without normalization, multi-line fixtures fail
|
|
498
523
|
# `text: "Line\nLine"` matchers.
|
|
@@ -582,7 +607,7 @@ module Capybara
|
|
|
582
607
|
# 6167 as duplicate `turbo:submit-start` events; the first request
|
|
583
608
|
# gets aborted by the second and Turbo never renders the response.
|
|
584
609
|
CLICK_JS = <<~JS
|
|
585
|
-
function() {
|
|
610
|
+
function(nativeAnchorDownloads) {
|
|
586
611
|
var EventCtor = (typeof MouseEvent !== 'undefined') ? MouseEvent : Event;
|
|
587
612
|
// Emulate coordinate hit-testing without a layout engine. In a real
|
|
588
613
|
// browser a pointer click on a container lands on its frontmost
|
|
@@ -639,26 +664,19 @@ module Capybara
|
|
|
639
664
|
hit.dispatchEvent(new EventCtor('mouseup', { bubbles: true, cancelable: true }));
|
|
640
665
|
var clickEvt = new EventCtor('click', { bubbles: true, cancelable: true });
|
|
641
666
|
// A link to a scheme the browser can't fetch (mailto:, tel:, sms:…)
|
|
642
|
-
// goes to an external app in Chrome and the page stays.
|
|
643
|
-
// upstream #3940 (
|
|
644
|
-
//
|
|
645
|
-
// b.com (1.0.0), or loses the execution context on a failed `tel:`
|
|
646
|
-
// navigation (10261–10535). Cancel that default from a window
|
|
647
|
-
// listener added for this one dispatch: it runs after the page's
|
|
648
|
-
// own listeners, which still see an uncancelled click.
|
|
667
|
+
// goes to an external app in Chrome and the page stays. The link's
|
|
668
|
+
// own activation is a no-op for it since upstream #3940 (10536, in
|
|
669
|
+
// the floor); don't navigate to it ourselves either.
|
|
649
670
|
var link = hit.closest ? hit.closest('a[href]') : null;
|
|
650
671
|
var scheme = link && /^\\s*([a-z][a-z0-9+.-]*):/i.exec(link.getAttribute('href'));
|
|
651
672
|
var external = !!scheme && !/^(https?|about|blob|data|file|javascript)$/i.test(scheme[1]);
|
|
652
|
-
var
|
|
653
|
-
var keepPage = function (e) { if (e === clickEvt) e.preventDefault(); };
|
|
654
|
-
if (external && view) view.addEventListener('click', keepPage);
|
|
655
|
-
var notCancelled;
|
|
656
|
-
try {
|
|
657
|
-
notCancelled = hit.dispatchEvent(clickEvt);
|
|
658
|
-
} finally {
|
|
659
|
-
if (external && view) view.removeEventListener('click', keepPage);
|
|
660
|
-
}
|
|
673
|
+
var notCancelled = hit.dispatchEvent(clickEvt);
|
|
661
674
|
if (external || !notCancelled || clickEvt.defaultPrevented) return;
|
|
675
|
+
// Native activation has already started the download. Navigating
|
|
676
|
+
// again cancels it and replaces the document. Keep the complete
|
|
677
|
+
// synthetic event sequence and its cancellation, but stop here on
|
|
678
|
+
// browsers that support the download attribute.
|
|
679
|
+
if (nativeAnchorDownloads && link && link.hasAttribute('download')) return;
|
|
662
680
|
var tag = hit.tagName;
|
|
663
681
|
// A `javascript:` link never navigates: the click's own activation
|
|
664
682
|
// behavior has already run its code. Assigning it to location.href
|
|
@@ -695,9 +713,9 @@ module Capybara
|
|
|
695
713
|
}
|
|
696
714
|
JS
|
|
697
715
|
|
|
698
|
-
#
|
|
699
|
-
#
|
|
700
|
-
#
|
|
716
|
+
# Picks the right Event constructor based on the event name so listeners
|
|
717
|
+
# that key on `event instanceof MouseEvent` / `instanceof SubmitEvent`
|
|
718
|
+
# see what they expect; everything else goes
|
|
701
719
|
# through a generic Event so custom names ("turbo:load", "lp:custom")
|
|
702
720
|
# still dispatch. Each constructor is feature-detected (`typeof X !==
|
|
703
721
|
# 'undefined'`) before use so a missing IDL on Lightpanda falls back
|
|
@@ -715,6 +733,9 @@ module Capybara
|
|
|
715
733
|
event = new FocusEvent(name, init);
|
|
716
734
|
} else if (name === 'submit' && typeof SubmitEvent !== 'undefined') {
|
|
717
735
|
event = new SubmitEvent(name, init);
|
|
736
|
+
} else if (name === 'input' && typeof InputEvent !== 'undefined') {
|
|
737
|
+
// inputType is what typing sends; a plain Event leaves it undefined.
|
|
738
|
+
event = new InputEvent(name, { bubbles: true, cancelable: false, inputType: 'insertText' });
|
|
718
739
|
} else {
|
|
719
740
|
event = new Event(name, init);
|
|
720
741
|
}
|
|
@@ -776,19 +797,45 @@ module Capybara
|
|
|
776
797
|
}
|
|
777
798
|
JS
|
|
778
799
|
|
|
779
|
-
# Ported
|
|
800
|
+
# Ported from Capybara's Selenium driver
|
|
780
801
|
# (capybara/selenium/extensions/html5_drag.rb, HTML5_DRAG_DROP_SCRIPT) —
|
|
781
|
-
#
|
|
782
|
-
#
|
|
783
|
-
#
|
|
784
|
-
#
|
|
802
|
+
# kept close to ease future syncs. Unlike the original, a drag ends the
|
|
803
|
+
# way it does in a browser: `drop` if the target accepted it (cancelled
|
|
804
|
+
# the last dragover), otherwise `dragleave`, never both. Selenium's
|
|
805
|
+
# script fired `dragleave` before `drop`, and Pragmatic drag and drop
|
|
806
|
+
# reads a relatedTarget-less `dragleave` as the drag leaving the window,
|
|
807
|
+
# so the drop landed on nothing. Two port bugs are fixed too:
|
|
808
|
+
# pointOnRect read `rectPt.top` (a DOMPoint has none, so a target
|
|
809
|
+
# entered from above or below got the source's center as its entry
|
|
810
|
+
# point), and `key` leaked as a global. Cuprite made the same changes
|
|
811
|
+
# (rubycdp/cuprite#343). The
|
|
785
812
|
# `source.draggable` reads are native — the HTMLElement.draggable IDL
|
|
786
813
|
# shipped upstream in #3257 (build 8793, guaranteed by the floor; the
|
|
787
814
|
# `_lightpanda.isDraggable` polyfill was retired with that bump).
|
|
788
815
|
# Coordinates come from getBoundingClientRect, which Lightpanda
|
|
789
816
|
# synthesizes without layout — dropzones reading clientX/Y get
|
|
790
817
|
# plausible-but-synthetic (integer since #3259) numbers.
|
|
818
|
+
#
|
|
819
|
+
# One deliberate deviation: the target is centered by
|
|
820
|
+
# `scrollCenterIntoView`, not `scrollIntoView({block: 'center'})`.
|
|
821
|
+
# Lightpanda's scrollIntoView ignores its options and aligns the
|
|
822
|
+
# element's top with the viewport's, and since upstream #3658 (nightly
|
|
823
|
+
# 10661) rects follow the scroll, so a source just above the target
|
|
824
|
+
# left the viewport and the first dragover carried a negative clientY.
|
|
825
|
+
# The helper does what Chrome does (element center at viewport center,
|
|
826
|
+
# clamped to the scrollable range), measuring against the root's rect
|
|
827
|
+
# so it holds whether or not the build's rects follow the scroll.
|
|
828
|
+
# Pinned by `drag_test.rb`.
|
|
791
829
|
HTML5_DRAG_JS = <<~JS
|
|
830
|
+
function scrollCenterIntoView(el) {
|
|
831
|
+
var doc = el.ownerDocument, win = doc.defaultView;
|
|
832
|
+
var rect = el.getBoundingClientRect();
|
|
833
|
+
var root = doc.documentElement.getBoundingClientRect();
|
|
834
|
+
var x = rect.left - root.left + rect.width / 2 - win.innerWidth / 2;
|
|
835
|
+
var y = rect.top - root.top + rect.height / 2 - win.innerHeight / 2;
|
|
836
|
+
win.scrollTo(Math.max(0, x), Math.max(0, y));
|
|
837
|
+
}
|
|
838
|
+
|
|
792
839
|
function rectCenter(rect){
|
|
793
840
|
return new DOMPoint(
|
|
794
841
|
(rect.left + rect.right)/2,
|
|
@@ -813,7 +860,7 @@ module Capybara
|
|
|
813
860
|
}
|
|
814
861
|
|
|
815
862
|
if (pt.y <= rectPt.y) { // top side
|
|
816
|
-
var minYx = (
|
|
863
|
+
var minYx = (rect.top - pt.y) / slope + pt.x;
|
|
817
864
|
if (rect.left <= minYx && minYx <= rect.right)
|
|
818
865
|
return new DOMPoint(minYx, rect.top);
|
|
819
866
|
}
|
|
@@ -828,12 +875,12 @@ module Capybara
|
|
|
828
875
|
}
|
|
829
876
|
|
|
830
877
|
function dragEnterTarget() {
|
|
831
|
-
target
|
|
878
|
+
scrollCenterIntoView(target);
|
|
832
879
|
var targetRect = target.getBoundingClientRect();
|
|
833
880
|
var sourceCenter = rectCenter(source.getBoundingClientRect());
|
|
834
881
|
|
|
835
882
|
for (var i = 0; i < drop_modifier_keys.length; i++) {
|
|
836
|
-
key = drop_modifier_keys[i];
|
|
883
|
+
var key = drop_modifier_keys[i];
|
|
837
884
|
if (key == "control"){
|
|
838
885
|
key = "ctrl"
|
|
839
886
|
}
|
|
@@ -856,18 +903,14 @@ module Capybara
|
|
|
856
903
|
var dragOverOpts = Object.assign({clientX: targetCenter.x, clientY: targetCenter.y}, opts);
|
|
857
904
|
var dragOverEvent = new DragEvent('dragover', dragOverOpts);
|
|
858
905
|
target.dispatchEvent(dragOverEvent);
|
|
859
|
-
window.setTimeout(
|
|
906
|
+
window.setTimeout(dragEnd, step_delay, dragOverEvent.defaultPrevented, dragOverOpts);
|
|
860
907
|
}
|
|
861
908
|
|
|
862
|
-
function
|
|
863
|
-
var
|
|
864
|
-
var
|
|
865
|
-
target.dispatchEvent(
|
|
866
|
-
|
|
867
|
-
var dropEvent = new DragEvent('drop', dragLeaveOptions);
|
|
868
|
-
target.dispatchEvent(dropEvent);
|
|
869
|
-
}
|
|
870
|
-
var dragEndEvent = new DragEvent('dragend', dragLeaveOptions);
|
|
909
|
+
function dragEnd(drop, dragOverOpts) {
|
|
910
|
+
var dragEndOptions = Object.assign({}, opts, dragOverOpts);
|
|
911
|
+
var dropOrLeaveEvent = new DragEvent(drop ? 'drop' : 'dragleave', dragEndOptions);
|
|
912
|
+
target.dispatchEvent(dropOrLeaveEvent);
|
|
913
|
+
var dragEndEvent = new DragEvent('dragend', dragEndOptions);
|
|
871
914
|
source.dispatchEvent(dragEndEvent);
|
|
872
915
|
callback.call(true);
|
|
873
916
|
}
|
|
@@ -1085,7 +1128,7 @@ module Capybara
|
|
|
1085
1128
|
# same node (`node #path returns xpath which points to itself`), so this
|
|
1086
1129
|
# emits `/HTML/BODY/DIV[2]/P[1]` rather than the CSS-ish
|
|
1087
1130
|
# `html > body > div:nth-of-type(2) > p` it used to. Chrome exposes no
|
|
1088
|
-
# native equivalent either
|
|
1131
|
+
# native equivalent either.
|
|
1089
1132
|
#
|
|
1090
1133
|
# Every step is indexed, including single children: `P[1]` and `P` select
|
|
1091
1134
|
# the same node, but the explicit index keeps the output stable if a
|
|
@@ -20,10 +20,11 @@ module Capybara
|
|
|
20
20
|
# `Capybara::Lightpanda.configure { |c| c.port = 9222 }` when external
|
|
21
21
|
# tooling needs a known address.
|
|
22
22
|
DEFAULT_PORT = 0
|
|
23
|
-
# Mirrors Lightpanda's own `Viewport.default` (1920x1080)
|
|
24
|
-
#
|
|
25
|
-
# a 1024x768 default would silently shrink the viewport
|
|
26
|
-
# suite and flip `@media` branches under them.
|
|
23
|
+
# Mirrors Lightpanda's own `Viewport.default` (1920x1080), not the
|
|
24
|
+
# 1024x768 some drivers default to. window_size is applied for real now
|
|
25
|
+
# (see below), so a 1024x768 default would silently shrink the viewport
|
|
26
|
+
# of every existing suite and flip `@media` branches under them.
|
|
27
|
+
# Matching the browser's
|
|
27
28
|
# native default keeps `window_size` truthful AND leaves default
|
|
28
29
|
# behavior byte-identical to before it was wired up.
|
|
29
30
|
DEFAULT_WINDOW_SIZE = [1920, 1080].freeze
|
|
@@ -36,16 +37,17 @@ module Capybara
|
|
|
36
37
|
# no rendering engine, so getBoundingClientRect stays synthetic and
|
|
37
38
|
# nothing reflows. Sizing down will not make an off-viewport element
|
|
38
39
|
# report as obscured.
|
|
39
|
-
# headless is accepted
|
|
40
|
+
# headless is accepted so suites migrating from other drivers keep
|
|
41
|
+
# working, but is inert —
|
|
40
42
|
# headless is the only mode Lightpanda runs in.
|
|
41
|
-
# save_path: directory for downloaded files
|
|
43
|
+
# save_path: directory for downloaded files. nil falls
|
|
42
44
|
# back to Capybara.save_path at create_page time; downloads stay off when
|
|
43
45
|
# both are nil (Browser#create_page only opts in when a path exists).
|
|
44
46
|
# raise_on_unhandled_modal: a JS dialog that opens with no
|
|
45
47
|
# accept_modal/dismiss_modal pre-arm in flight is resolved by Lightpanda's
|
|
46
48
|
# silent default (confirm → cancel, prompt → null, alert → dismissed), so
|
|
47
49
|
# a runaway confirm cancels the action and the spec can still pass. false
|
|
48
|
-
# (default
|
|
50
|
+
# (default) warns on stderr; true raises
|
|
49
51
|
# UnhandledModalError from the action that opened it.
|
|
50
52
|
# load_images: adds `image` to the `--load-resources` value so `<img>`
|
|
51
53
|
# elements are fetched over the network (upstream #3230, build >= 8834,
|
|
@@ -264,7 +264,48 @@ module Capybara
|
|
|
264
264
|
# end in Network.loadingFailed, which Network consumes); #3529 (9877)
|
|
265
265
|
# SameSite=None without Secure refused (Cookies#set reports it);
|
|
266
266
|
# #3702 (9968) click no longer focuses (CLICK_JS focuses itself).
|
|
267
|
-
|
|
267
|
+
#
|
|
268
|
+
# 2026-10-16 bump 9994 -> 10641 (= release 1.0.1, the new lockstep cap).
|
|
269
|
+
# Retired or ungated by it:
|
|
270
|
+
# #3958 (10613, ours) Window and Document carry the GlobalEventHandlers
|
|
271
|
+
# `on*` attributes, so javascripts/event_handlers.js is gone — jQuery
|
|
272
|
+
# 1.x sees `'onchange' in window` natively.
|
|
273
|
+
# #3940 (10536) a link to a non-fetchable scheme (mailto:, tel:) is a
|
|
274
|
+
# no-op that keeps the document, so CLICK_JS no longer cancels the
|
|
275
|
+
# click's default from a window listener.
|
|
276
|
+
# #3755 / #3849 (10049 / 10310, ours / upstream) exceptions from timers,
|
|
277
|
+
# rAF, inline handlers and microtasks reach `window.onerror`, so
|
|
278
|
+
# Browser#page_errors sees them; #3757 (10052) hides a closed
|
|
279
|
+
# [popover]; #3759 (10057) routes send_keys into an iframe; #3874
|
|
280
|
+
# (10366) Ctrl+A selects a field — upstream_fixes_test.rb runs them
|
|
281
|
+
# un-gated.
|
|
282
|
+
# #3763 (10239, ours) history traverses pushState entries in place;
|
|
283
|
+
# #3831 (10255) `window` serializes as CyclicObject, not a node;
|
|
284
|
+
# #3843 (10261) a failed navigation commits an error document — their
|
|
285
|
+
# tests drop the nightly_build branches.
|
|
286
|
+
# #3952 (10575) typed text fires `change` when committed by blur/Enter.
|
|
287
|
+
# A `nightly_build` gate is nil on a release binary, so every such test
|
|
288
|
+
# skipped on 1.0.1 although it carried the fix: ungate, don't re-gate.
|
|
289
|
+
#
|
|
290
|
+
# 2026-10-23 bump 10641 -> 10965 (= release 1.0.2, the new lockstep cap;
|
|
291
|
+
# CSS nesting #3962 at 10976 and blank-page timers #4148 at 11012 stay
|
|
292
|
+
# above it). Rides along:
|
|
293
|
+
# #4096 (10904) Runtime.exceptionThrown for unhandled rejections, and
|
|
294
|
+
# `unhandledrejection` no longer fires for a rejection handled in
|
|
295
|
+
# the same tick — errors.js stops reporting those false positives;
|
|
296
|
+
# page_errors_test.rb drops its nightly_build gate. With #4115
|
|
297
|
+
# (10910, an iframe's exception carries its context id) this
|
|
298
|
+
# unblocks the B19 swap off errors.js.
|
|
299
|
+
# #3997 (10685) the scheduler rework: a MessageChannel task loop no
|
|
300
|
+
# longer delays a pending fetch tenfold (#4042), which held
|
|
301
|
+
# wait_for_idle.
|
|
302
|
+
# #3658 (10657) client rects follow the scroll and
|
|
303
|
+
# DOM.scrollIntoViewIfNeeded is real — HTML5_DRAG_JS centers the
|
|
304
|
+
# drop target itself because scrollIntoView ignores `block`.
|
|
305
|
+
MINIMUM_NIGHTLY_BUILD = Gem::Version.new("10965")
|
|
306
|
+
|
|
307
|
+
# Lines of browser output #output keeps (ferrum keeps the same 100).
|
|
308
|
+
OUTPUT_LINES = 100
|
|
268
309
|
|
|
269
310
|
# Second, equivalent floor for the *release* channel.
|
|
270
311
|
#
|
|
@@ -273,16 +314,16 @@ module Capybara
|
|
|
273
314
|
# bare "0.3.6" carrying no commit counter at all, and MINIMUM_NIGHTLY_BUILD
|
|
274
315
|
# has nothing to compare against. Releases are cut from the same trunk, so
|
|
275
316
|
# a release is acceptable exactly when its own commit count clears the
|
|
276
|
-
# nightly floor. 1.0.
|
|
317
|
+
# nightly floor. 1.0.2 (2026-10-23) is build 10965 — exactly the nightly
|
|
277
318
|
# floor, which is no accident: the floor was raised *to* the release so
|
|
278
|
-
# both channels gate the same commit. (0.
|
|
279
|
-
#
|
|
319
|
+
# both channels gate the same commit. (1.0.1 = 10641 predates fixes the
|
|
320
|
+
# floor exists for.)
|
|
280
321
|
#
|
|
281
322
|
# INVARIANT: every MINIMUM_NIGHTLY_BUILD bump must also move this to the
|
|
282
323
|
# first release containing that build (`git rev-list --count <tag>` in the
|
|
283
324
|
# browser repo tells you). Leaving it behind would let the release channel
|
|
284
325
|
# silently accept a binary the nightly channel rejects.
|
|
285
|
-
MINIMUM_RELEASE = Gem::Version.new("1.0.
|
|
326
|
+
MINIMUM_RELEASE = Gem::Version.new("1.0.2")
|
|
286
327
|
|
|
287
328
|
class << self
|
|
288
329
|
# `lightpanda version` prints one of two shapes, and the gem supports
|
|
@@ -348,6 +389,17 @@ module Capybara
|
|
|
348
389
|
@stderr_r = nil
|
|
349
390
|
@stderr_w = nil
|
|
350
391
|
@finalizer_registered = false
|
|
392
|
+
@output = []
|
|
393
|
+
@output_partial = {}
|
|
394
|
+
@output_mutex = Mutex.new
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
# The last OUTPUT_LINES lines Lightpanda wrote to stdout/stderr, oldest
|
|
398
|
+
# first, across restarts of this Process. Kept for diagnostics: a crash
|
|
399
|
+
# or panic only ever shows up there, and from build 10783 so do page
|
|
400
|
+
# errors (`page_error` scope, WARN). Mirrors ferrum's Process::Output.
|
|
401
|
+
def output
|
|
402
|
+
@output_mutex.synchronize { @output.dup }
|
|
351
403
|
end
|
|
352
404
|
|
|
353
405
|
def start
|
|
@@ -416,7 +468,7 @@ module Capybara
|
|
|
416
468
|
next unless ready
|
|
417
469
|
|
|
418
470
|
ready[0].each do |io|
|
|
419
|
-
io.read_nonblock(4096)
|
|
471
|
+
record_output(io, io.read_nonblock(4096))
|
|
420
472
|
rescue IO::WaitReadable
|
|
421
473
|
# No data
|
|
422
474
|
rescue EOFError
|
|
@@ -430,6 +482,18 @@ module Capybara
|
|
|
430
482
|
end
|
|
431
483
|
end
|
|
432
484
|
|
|
485
|
+
# Split per pipe so a line cut across two reads is kept whole, and a
|
|
486
|
+
# stdout fragment never glues onto a stderr one.
|
|
487
|
+
def record_output(io, chunk)
|
|
488
|
+
text = chunk.dup.force_encoding(Encoding::UTF_8).scrub
|
|
489
|
+
@output_mutex.synchronize do
|
|
490
|
+
*lines, rest = "#{@output_partial[io]}#{text}".split("\n", -1)
|
|
491
|
+
@output_partial[io] = rest
|
|
492
|
+
@output.concat(lines)
|
|
493
|
+
@output.shift(@output.size - OUTPUT_LINES) if @output.size > OUTPUT_LINES
|
|
494
|
+
end
|
|
495
|
+
end
|
|
496
|
+
|
|
433
497
|
def spawn_process(binary_path)
|
|
434
498
|
args = build_args
|
|
435
499
|
|
|
@@ -499,6 +563,7 @@ module Capybara
|
|
|
499
563
|
ready[0].each do |io|
|
|
500
564
|
chunk = io.read_nonblock(1024)
|
|
501
565
|
output << chunk
|
|
566
|
+
record_output(io, chunk)
|
|
502
567
|
|
|
503
568
|
if (match = output.match(READY_PATTERN))
|
|
504
569
|
@ws_url = "ws://#{match[1]}/"
|
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.16.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Navid Emad
|
|
@@ -91,7 +91,6 @@ files:
|
|
|
91
91
|
- lib/capybara/lightpanda/headers.rb
|
|
92
92
|
- lib/capybara/lightpanda/javascripts/attach.js
|
|
93
93
|
- lib/capybara/lightpanda/javascripts/banner.js
|
|
94
|
-
- lib/capybara/lightpanda/javascripts/errors.js
|
|
95
94
|
- lib/capybara/lightpanda/javascripts/predicates.js
|
|
96
95
|
- lib/capybara/lightpanda/javascripts/turbo.js
|
|
97
96
|
- lib/capybara/lightpanda/keyboard.rb
|
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
// --- Uncaught page errors ---
|
|
2
|
-
// Lightpanda implements no `Runtime.exceptionThrown` (upstream gap, wishlist
|
|
3
|
-
// B19), so an uncaught exception in page JS reaches no CDP client at all. It is
|
|
4
|
-
// invisible to console_logs, which only ever sees explicit console.* calls, so
|
|
5
|
-
// a handler that dies on a TypeError leaves no trace and the failure surfaces
|
|
6
|
-
// far away as ElementNotFound on whatever it was supposed to produce. That is
|
|
7
|
-
// the exact shape of the solidus taxon-tree failure in real-apps causes.yml.
|
|
8
|
-
//
|
|
9
|
-
// These listeners are passive — no preventDefault, so nothing the page would
|
|
10
|
-
// otherwise see is swallowed, and nothing that would have propagated stops
|
|
11
|
-
// propagating. They re-emit through the same console.debug sentinel channel
|
|
12
|
-
// turbo.js uses, and console.rb routes them into Browser#page_errors rather
|
|
13
|
-
// than #console_logs. Keeping the two apart is deliberate: console_logs stays
|
|
14
|
-
// "the console.* calls the page made", which is what Chrome models (an
|
|
15
|
-
// exception is exceptionThrown, not consoleAPICalled) and what Playwright and
|
|
16
|
-
// Puppeteer expose (pageerror, separate from console). Folding exceptions into
|
|
17
|
-
// console_logs would also break every suite already asserting that buffer is
|
|
18
|
-
// free of errors.
|
|
19
|
-
//
|
|
20
|
-
// Delete this file once upstream emits Runtime.exceptionThrown and the floor
|
|
21
|
-
// covers it: subscribe Browser::Console to that event and feed the same buffer.
|
|
22
|
-
function _reportPageError(fields) {
|
|
23
|
-
try {
|
|
24
|
-
console.debug('__lightpanda_page_error_' + JSON.stringify(fields));
|
|
25
|
-
} catch (e) {
|
|
26
|
-
// A field that won't serialize (a getter that throws, a cyclic `reason`)
|
|
27
|
-
// must not turn one page error into two, so report the kind and move on.
|
|
28
|
-
try {
|
|
29
|
-
console.debug('__lightpanda_page_error_' + JSON.stringify({
|
|
30
|
-
kind: fields && fields.kind ? fields.kind : 'error',
|
|
31
|
-
message: '(page error could not be serialized)'
|
|
32
|
-
}));
|
|
33
|
-
} catch (e2) {}
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
window.addEventListener('error', function (e) {
|
|
38
|
-
// A failed <img>/<script>/<link> fetch fires `error` too, but on the element
|
|
39
|
-
// and without bubbling, so it shouldn't reach this listener. Guard anyway
|
|
40
|
-
// (Lightpanda's dispatch need not match Chrome's exactly) and identify those
|
|
41
|
-
// by their missing string `message` — a resource failure is a network fact
|
|
42
|
-
// and Network#traffic already carries it.
|
|
43
|
-
if (!e || typeof e.message !== 'string') return;
|
|
44
|
-
|
|
45
|
-
_reportPageError({
|
|
46
|
-
kind: 'error',
|
|
47
|
-
message: e.message,
|
|
48
|
-
url: typeof e.filename === 'string' ? e.filename : '',
|
|
49
|
-
line: typeof e.lineno === 'number' ? e.lineno : null,
|
|
50
|
-
column: typeof e.colno === 'number' ? e.colno : null,
|
|
51
|
-
// Cap the stack: it rides a console argument over the CDP WebSocket, and a
|
|
52
|
-
// deep recursion stack can be megabytes.
|
|
53
|
-
stack: e.error && typeof e.error.stack === 'string' ? e.error.stack.slice(0, 4000) : null
|
|
54
|
-
});
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
window.addEventListener('unhandledrejection', function (e) {
|
|
58
|
-
var reason = e ? e.reason : null;
|
|
59
|
-
var message;
|
|
60
|
-
try {
|
|
61
|
-
message = reason && reason.message ? String(reason.message) : String(reason);
|
|
62
|
-
} catch (err) {
|
|
63
|
-
message = '(rejection reason could not be stringified)';
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
_reportPageError({
|
|
67
|
-
kind: 'unhandledrejection',
|
|
68
|
-
message: message,
|
|
69
|
-
url: '',
|
|
70
|
-
line: null,
|
|
71
|
-
column: null,
|
|
72
|
-
stack: reason && typeof reason.stack === 'string' ? reason.stack.slice(0, 4000) : null
|
|
73
|
-
});
|
|
74
|
-
});
|