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.
@@ -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/Cuprite
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 (Cuprite pattern).
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. Mirrors Cuprite's Node#trigger
169
- # — picks the right Event constructor for known mouse/focus/form names
170
- # and falls back to a generic Event for everything else (so callers can
171
- # fire custom events like `node.trigger('lp:custom')`).
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
- call(TRIGGER_JS, event.to_s)
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 the way Cuprite's #316 does it: a hidden
198
- # `<input type=file>` is attached to this element's document,
199
- # `DOM.setFileInputFiles` points it at the paths (the browser reads the
200
- # bytes off disk itself), and the drop JS moves `input.files` into the
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`, ...). Same table as Cuprite's #315.
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 (the same source
224
- # Cuprite's drag.js ports): dragstart on the draggable ancestor, then
225
- # dragenter -> 2x dragover -> dragleave/drop -> dragend, setTimeout-paced,
226
- # sharing one DataTransfer so `setData` in the page's dragstart handler is
227
- # readable at drop. Runs through `evaluate_async` (the script signals
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:` (Cuprite's legacy-path knobs) are accepted and
239
- # ignored so suites migrating from cuprite don't ArgumentError.
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. Mirrors Cuprite's `Node#parents`.
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
- object_ids = driver.browser.find_within(@remote_object_id, "xpath", selector)
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
- object_ids = driver.browser.find_within(@remote_object_id, "css", selector)
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 Cuprite's `Node#set` for week
450
- # inputs and what the user would type into a `<input type=week>` field.
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 (Cuprite pattern). Capybara's text matchers compare
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. Below
643
- // upstream #3940 (build 10536) the link's own activation instead
644
- // turns `mailto:a@b.com` into `http://mailto:a@b.com/` and loads
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 view = hit.ownerDocument.defaultView;
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
- # Mirrors Cuprite's trigger map. Picks the right Event constructor based
699
- # on the event name so listeners that key on `event instanceof MouseEvent`
700
- # / `instanceof SubmitEvent` see what they expect; everything else goes
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 near-verbatim from Capybara's Selenium driver
800
+ # Ported from Capybara's Selenium driver
780
801
  # (capybara/selenium/extensions/html5_drag.rb, HTML5_DRAG_DROP_SCRIPT) —
781
- # the same source Cuprite's #315 drag.js ports — kept close to ease
782
- # future syncs. Upstream quirks preserved deliberately: `rectPt.top` in
783
- # pointOnRect (DOMPoint has no .top, that branch just falls through), the
784
- # undeclared `key` loop variable, and `callback.call(true)`. The
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 = (rectPt.top - pt.y) / slope + pt.x;
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.scrollIntoView({behavior: 'instant', block: 'center', inline: 'center'});
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(dragLeave, step_delay, dragOverEvent.defaultPrevented, dragOverOpts);
906
+ window.setTimeout(dragEnd, step_delay, dragOverEvent.defaultPrevented, dragOverOpts);
860
907
  }
861
908
 
862
- function dragLeave(drop, dragOverOpts) {
863
- var dragLeaveOptions = Object.assign({}, opts, dragOverOpts);
864
- var dragLeaveEvent = new DragEvent('dragleave', dragLeaveOptions);
865
- target.dispatchEvent(dragLeaveEvent);
866
- if (drop) {
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; Cuprite hand-rolls the same walk.
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) rather than
24
- # Cuprite's 1024x768. window_size is applied for real now (see below), so
25
- # a 1024x768 default would silently shrink the viewport of every existing
26
- # suite and flip `@media` branches under them. Matching the browser's
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 for Cuprite drop-in compatibility but inert —
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 (Cuprite parity). nil falls
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, Cuprite parity) warns on stderr; true raises
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
- MINIMUM_NIGHTLY_BUILD = Gem::Version.new("9994")
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.0 (2026-10-02) is build 9994 — exactly the nightly
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.4.1 = 9463 and 0.4.0 = 9058
279
- # predate fixes the floor exists for.)
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.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]}/"
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Capybara
4
4
  module Lightpanda
5
- VERSION = "0.14.2"
5
+ VERSION = "0.16.0"
6
6
  end
7
7
  end
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.14.2
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
- });