capybara-lightpanda 0.14.1 → 0.15.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)
@@ -165,10 +173,10 @@ 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
181
  call(TRIGGER_JS, event.to_s)
174
182
  end
@@ -194,10 +202,10 @@ module Capybara
194
202
  # on this element, so HTML5 dropzones see the payload via
195
203
  # `event.dataTransfer`.
196
204
  #
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
205
+ # Files reach the page through a hidden `<input type=file>`: it is
206
+ # attached to this element's document, `DOM.setFileInputFiles` points it
207
+ # at the paths (the browser reads the bytes off disk itself), and the
208
+ # drop JS moves `input.files` into the
201
209
  # DataTransfer and removes the input. Previously the bytes were base64'd
202
210
  # into the `Runtime.callFunctionOn` message, which capped a drop at
203
211
  # ~70 MB under `--cdp-max-message-size` and pinned every byte in Ruby;
@@ -216,15 +224,15 @@ module Capybara
216
224
  end
217
225
 
218
226
  # Maps Capybara's documented drop_modifiers aliases onto the DragEvent
219
- # init keys (`ctrlKey`, `metaKey`, ...). Same table as Cuprite's #315.
227
+ # init keys (`ctrlKey`, `metaKey`, ...).
220
228
  DRAG_MODIFIER_ALIASES = { control: :ctrl, command: :meta, cmd: :meta }.freeze
221
229
 
222
230
  # 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
231
+ # Capybara's own Selenium HTML5_DRAG_DROP_SCRIPT: dragstart on the
232
+ # draggable ancestor, then dragenter -> 2x dragover -> drop (accepted)
233
+ # or dragleave (refused) -> dragend, setTimeout-paced, sharing one
234
+ # DataTransfer so `setData` in the page's dragstart handler is readable
235
+ # at drop. Runs through `evaluate_async` (the script signals
228
236
  # completion via the appended callback), so the drag has fully played out
229
237
  # before this method returns.
230
238
  #
@@ -235,8 +243,8 @@ module Capybara
235
243
  # Selenium presses a real button, then apply the same
236
244
  # prevented-or-no-draggable-ancestor test.
237
245
  #
238
- # `steps:`/`scroll:` (Cuprite's legacy-path knobs) are accepted and
239
- # ignored so suites migrating from cuprite don't ArgumentError.
246
+ # `steps:`/`scroll:` (legacy mouse-path knobs of other CDP drivers) are
247
+ # accepted and ignored so suites migrating from them don't ArgumentError.
240
248
  def drag_to(other, html5: nil, delay: 0.05, drop_modifiers: [], **)
241
249
  keys = Array(drop_modifiers).map { |m| DRAG_MODIFIER_ALIASES.fetch(m.to_sym, m.to_sym).to_s }
242
250
  html5 = !call(LEGACY_DRAG_CHECK_JS) if html5.nil?
@@ -318,20 +326,18 @@ module Capybara
318
326
  end
319
327
 
320
328
  # Ancestor chain from `parentNode` up to (but not including) `document`,
321
- # returned as Lightpanda::Node wrappers. Mirrors Cuprite's `Node#parents`.
329
+ # returned as Lightpanda::Node wrappers.
322
330
  def parents
323
331
  oids = driver.browser.parents_of(@remote_object_id)
324
332
  oids.map { |oid| self.class.new(driver, oid, frame_scoped: @frame_scoped) }
325
333
  end
326
334
 
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) }
335
+ def find_xpath(selector, **hints)
336
+ find_hinted("xpath", selector, hints)
330
337
  end
331
338
 
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) }
339
+ def find_css(selector, **hints)
340
+ find_hinted("css", selector, hints)
335
341
  end
336
342
 
337
343
  # Equality compares the underlying DOM node via backendNodeId, the only
@@ -368,6 +374,11 @@ module Capybara
368
374
 
369
375
  private
370
376
 
377
+ def find_hinted(method, selector, hints)
378
+ driver.browser.find_within_hinted(@remote_object_id, method, selector, self.class.hint_spec(**hints))
379
+ .map { |oid, cache| self.class.new(driver, oid, cache, frame_scoped: @frame_scoped) }
380
+ end
381
+
371
382
  def implicit_submit
372
383
  call(IMPLICIT_SUBMIT_JS)
373
384
  driver.browser.wait_for_idle
@@ -446,8 +457,8 @@ module Capybara
446
457
 
447
458
  # ISO 8601 week-of-year, "%G" giving the ISO week-numbering year so that
448
459
  # 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.
460
+ # rendered with the correct year. Matches what the user would type into
461
+ # a `<input type=week>` field.
451
462
  def format_week_value(value)
452
463
  return value.to_s if value.is_a?(String) || !value.respond_to?(:to_date)
453
464
 
@@ -492,7 +503,7 @@ module Capybara
492
503
  self.class.new(driver, oid)
493
504
  end
494
505
 
495
- # Whitespace-normalized text (Cuprite pattern). Capybara's text matchers compare
506
+ # Whitespace-normalized text. Capybara's text matchers compare
496
507
  # against this, and Lightpanda's textContent preserves source-template whitespace
497
508
  # differently than Chrome — without normalization, multi-line fixtures fail
498
509
  # `text: "Line\nLine"` matchers.
@@ -638,8 +649,15 @@ module Capybara
638
649
  }
639
650
  hit.dispatchEvent(new EventCtor('mouseup', { bubbles: true, cancelable: true }));
640
651
  var clickEvt = new EventCtor('click', { bubbles: true, cancelable: true });
652
+ // A link to a scheme the browser can't fetch (mailto:, tel:, sms:…)
653
+ // goes to an external app in Chrome and the page stays. The link's
654
+ // own activation is a no-op for it since upstream #3940 (10536, in
655
+ // the floor); don't navigate to it ourselves either.
656
+ var link = hit.closest ? hit.closest('a[href]') : null;
657
+ var scheme = link && /^\\s*([a-z][a-z0-9+.-]*):/i.exec(link.getAttribute('href'));
658
+ var external = !!scheme && !/^(https?|about|blob|data|file|javascript)$/i.test(scheme[1]);
641
659
  var notCancelled = hit.dispatchEvent(clickEvt);
642
- if (!notCancelled || clickEvt.defaultPrevented) return;
660
+ if (external || !notCancelled || clickEvt.defaultPrevented) return;
643
661
  var tag = hit.tagName;
644
662
  // A `javascript:` link never navigates: the click's own activation
645
663
  // behavior has already run its code. Assigning it to location.href
@@ -676,9 +694,9 @@ module Capybara
676
694
  }
677
695
  JS
678
696
 
679
- # Mirrors Cuprite's trigger map. Picks the right Event constructor based
680
- # on the event name so listeners that key on `event instanceof MouseEvent`
681
- # / `instanceof SubmitEvent` see what they expect; everything else goes
697
+ # Picks the right Event constructor based on the event name so listeners
698
+ # that key on `event instanceof MouseEvent` / `instanceof SubmitEvent`
699
+ # see what they expect; everything else goes
682
700
  # through a generic Event so custom names ("turbo:load", "lp:custom")
683
701
  # still dispatch. Each constructor is feature-detected (`typeof X !==
684
702
  # 'undefined'`) before use so a missing IDL on Lightpanda falls back
@@ -696,6 +714,9 @@ module Capybara
696
714
  event = new FocusEvent(name, init);
697
715
  } else if (name === 'submit' && typeof SubmitEvent !== 'undefined') {
698
716
  event = new SubmitEvent(name, init);
717
+ } else if (name === 'input' && typeof InputEvent !== 'undefined') {
718
+ // inputType is what typing sends; a plain Event leaves it undefined.
719
+ event = new InputEvent(name, { bubbles: true, cancelable: false, inputType: 'insertText' });
699
720
  } else {
700
721
  event = new Event(name, init);
701
722
  }
@@ -757,19 +778,45 @@ module Capybara
757
778
  }
758
779
  JS
759
780
 
760
- # Ported near-verbatim from Capybara's Selenium driver
781
+ # Ported from Capybara's Selenium driver
761
782
  # (capybara/selenium/extensions/html5_drag.rb, HTML5_DRAG_DROP_SCRIPT) —
762
- # the same source Cuprite's #315 drag.js ports — kept close to ease
763
- # future syncs. Upstream quirks preserved deliberately: `rectPt.top` in
764
- # pointOnRect (DOMPoint has no .top, that branch just falls through), the
765
- # undeclared `key` loop variable, and `callback.call(true)`. The
783
+ # kept close to ease future syncs. Unlike the original, a drag ends the
784
+ # way it does in a browser: `drop` if the target accepted it (cancelled
785
+ # the last dragover), otherwise `dragleave`, never both. Selenium's
786
+ # script fired `dragleave` before `drop`, and Pragmatic drag and drop
787
+ # reads a relatedTarget-less `dragleave` as the drag leaving the window,
788
+ # so the drop landed on nothing. Two port bugs are fixed too:
789
+ # pointOnRect read `rectPt.top` (a DOMPoint has none, so a target
790
+ # entered from above or below got the source's center as its entry
791
+ # point), and `key` leaked as a global. Cuprite made the same changes
792
+ # (rubycdp/cuprite#343). The
766
793
  # `source.draggable` reads are native — the HTMLElement.draggable IDL
767
794
  # shipped upstream in #3257 (build 8793, guaranteed by the floor; the
768
795
  # `_lightpanda.isDraggable` polyfill was retired with that bump).
769
796
  # Coordinates come from getBoundingClientRect, which Lightpanda
770
797
  # synthesizes without layout — dropzones reading clientX/Y get
771
798
  # plausible-but-synthetic (integer since #3259) numbers.
799
+ #
800
+ # One deliberate deviation: the target is centered by
801
+ # `scrollCenterIntoView`, not `scrollIntoView({block: 'center'})`.
802
+ # Lightpanda's scrollIntoView ignores its options and aligns the
803
+ # element's top with the viewport's, and since upstream #3658 (nightly
804
+ # 10661) rects follow the scroll, so a source just above the target
805
+ # left the viewport and the first dragover carried a negative clientY.
806
+ # The helper does what Chrome does (element center at viewport center,
807
+ # clamped to the scrollable range), measuring against the root's rect
808
+ # so it holds whether or not the build's rects follow the scroll.
809
+ # Pinned by `drag_test.rb`.
772
810
  HTML5_DRAG_JS = <<~JS
811
+ function scrollCenterIntoView(el) {
812
+ var doc = el.ownerDocument, win = doc.defaultView;
813
+ var rect = el.getBoundingClientRect();
814
+ var root = doc.documentElement.getBoundingClientRect();
815
+ var x = rect.left - root.left + rect.width / 2 - win.innerWidth / 2;
816
+ var y = rect.top - root.top + rect.height / 2 - win.innerHeight / 2;
817
+ win.scrollTo(Math.max(0, x), Math.max(0, y));
818
+ }
819
+
773
820
  function rectCenter(rect){
774
821
  return new DOMPoint(
775
822
  (rect.left + rect.right)/2,
@@ -794,7 +841,7 @@ module Capybara
794
841
  }
795
842
 
796
843
  if (pt.y <= rectPt.y) { // top side
797
- var minYx = (rectPt.top - pt.y) / slope + pt.x;
844
+ var minYx = (rect.top - pt.y) / slope + pt.x;
798
845
  if (rect.left <= minYx && minYx <= rect.right)
799
846
  return new DOMPoint(minYx, rect.top);
800
847
  }
@@ -809,12 +856,12 @@ module Capybara
809
856
  }
810
857
 
811
858
  function dragEnterTarget() {
812
- target.scrollIntoView({behavior: 'instant', block: 'center', inline: 'center'});
859
+ scrollCenterIntoView(target);
813
860
  var targetRect = target.getBoundingClientRect();
814
861
  var sourceCenter = rectCenter(source.getBoundingClientRect());
815
862
 
816
863
  for (var i = 0; i < drop_modifier_keys.length; i++) {
817
- key = drop_modifier_keys[i];
864
+ var key = drop_modifier_keys[i];
818
865
  if (key == "control"){
819
866
  key = "ctrl"
820
867
  }
@@ -837,18 +884,14 @@ module Capybara
837
884
  var dragOverOpts = Object.assign({clientX: targetCenter.x, clientY: targetCenter.y}, opts);
838
885
  var dragOverEvent = new DragEvent('dragover', dragOverOpts);
839
886
  target.dispatchEvent(dragOverEvent);
840
- window.setTimeout(dragLeave, step_delay, dragOverEvent.defaultPrevented, dragOverOpts);
887
+ window.setTimeout(dragEnd, step_delay, dragOverEvent.defaultPrevented, dragOverOpts);
841
888
  }
842
889
 
843
- function dragLeave(drop, dragOverOpts) {
844
- var dragLeaveOptions = Object.assign({}, opts, dragOverOpts);
845
- var dragLeaveEvent = new DragEvent('dragleave', dragLeaveOptions);
846
- target.dispatchEvent(dragLeaveEvent);
847
- if (drop) {
848
- var dropEvent = new DragEvent('drop', dragLeaveOptions);
849
- target.dispatchEvent(dropEvent);
850
- }
851
- var dragEndEvent = new DragEvent('dragend', dragLeaveOptions);
890
+ function dragEnd(drop, dragOverOpts) {
891
+ var dragEndOptions = Object.assign({}, opts, dragOverOpts);
892
+ var dropOrLeaveEvent = new DragEvent(drop ? 'drop' : 'dragleave', dragEndOptions);
893
+ target.dispatchEvent(dropOrLeaveEvent);
894
+ var dragEndEvent = new DragEvent('dragend', dragEndOptions);
852
895
  source.dispatchEvent(dragEndEvent);
853
896
  callback.call(true);
854
897
  }
@@ -1066,7 +1109,7 @@ module Capybara
1066
1109
  # same node (`node #path returns xpath which points to itself`), so this
1067
1110
  # emits `/HTML/BODY/DIV[2]/P[1]` rather than the CSS-ish
1068
1111
  # `html > body > div:nth-of-type(2) > p` it used to. Chrome exposes no
1069
- # native equivalent either; Cuprite hand-rolls the same walk.
1112
+ # native equivalent either.
1070
1113
  #
1071
1114
  # Every step is indexed, including single children: `P[1]` and `P` select
1072
1115
  # 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.1"
5
+ VERSION = "0.15.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.1
4
+ version: 0.15.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
- });