capybara-lightpanda 0.9.0 → 0.10.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.
@@ -7,6 +7,7 @@ require_relative "browser/finder"
7
7
  require_relative "browser/navigation"
8
8
  require_relative "browser/modals"
9
9
  require_relative "browser/console"
10
+ require_relative "browser/selenium_compat"
10
11
 
11
12
  module Capybara
12
13
  module Lightpanda
@@ -18,6 +19,7 @@ module Capybara
18
19
  include Navigation
19
20
  include Modals
20
21
  include Console
22
+ include SeleniumCompat
21
23
 
22
24
  attr_reader :options, :process, :client, :target_id, :session_id, :browser_context_id, :frame_stack
23
25
 
@@ -78,10 +80,17 @@ module Capybara
78
80
  @process&.version
79
81
  end
80
82
 
83
+ # Set on the nightly/dev channel only; nil for a tagged release, which
84
+ # carries no build counter. `release` is its mirror image — exactly one of
85
+ # the two is non-nil once the process has started.
81
86
  def nightly_build
82
87
  @process&.nightly_build
83
88
  end
84
89
 
90
+ def release
91
+ @process&.release
92
+ end
93
+
85
94
  def initialize(options = {})
86
95
  @options = Options.new(options)
87
96
  @process = nil
@@ -94,8 +103,12 @@ module Capybara
94
103
  @modal_messages = []
95
104
  @modal_messages_mutex = Mutex.new
96
105
  @modal_handler_installed = false
106
+ @modal_armed = false
107
+ @unhandled_modal = nil
97
108
  @console_logs = []
98
109
  @console_logs_mutex = Mutex.new
110
+ @page_errors = []
111
+ @page_errors_mutex = Mutex.new
99
112
  @frame_stack = []
100
113
  @turbo_event = Utils::Event.new
101
114
  @turbo_event.set
@@ -149,7 +162,23 @@ module Capybara
149
162
  # clear_session_state's network.reset flipped @enabled back, so this
150
163
  # re-subscribes on the fresh context.
151
164
  network.enable
165
+ # Opt into file downloads when a destination exists (driver :save_path
166
+ # or Capybara.save_path). No-op otherwise. Must run after the context
167
+ # is loaded — Browser.setDownloadBehavior is a no-op without one.
168
+ downloads.enable(configured_download_path)
152
169
  register_auto_scripts
170
+ set_viewport
171
+ # Subscribe to dialogs from the first page, not lazily on the first
172
+ # accept_modal — otherwise a dialog nobody pre-armed is invisible and
173
+ # the unhandled-modal check below has nothing to report.
174
+ prepare_modals
175
+ end
176
+
177
+ # Download destination: explicit :save_path option wins, else
178
+ # Capybara.save_path (Cuprite parity). nil => downloads stay off.
179
+ def configured_download_path
180
+ @options.save_path ||
181
+ (defined?(Capybara) && Capybara.respond_to?(:save_path) ? Capybara.save_path : nil)
153
182
  end
154
183
 
155
184
  # Wipe per-session state — cookies, storage, all targets — and start
@@ -193,14 +222,23 @@ module Capybara
193
222
  def clear_session_state
194
223
  @page_events_enabled = false
195
224
  @modal_handler_installed = false
196
- @modal_messages_mutex.synchronize { @modal_messages.clear }
225
+ @modal_messages_mutex.synchronize do
226
+ @modal_messages.clear
227
+ @modal_armed = false
228
+ @unhandled_modal = nil
229
+ end
197
230
  @console_logs_mutex.synchronize { @console_logs.clear }
231
+ @page_errors_mutex.synchronize { @page_errors.clear }
198
232
  clear_frames
199
233
  # Network#reset, not #clear: disposing the BrowserContext also
200
234
  # destroyed the Network domain and its subscriptions, so we must
201
235
  # flip @enabled back to false — otherwise the next #enable
202
236
  # short-circuits and traffic tracking is silently dead.
203
237
  @network&.reset
238
+ # Same contract for Downloads: the disposed context drops the
239
+ # download config + subscriptions, so reset re-arms create_page's
240
+ # downloads.enable on the fresh context.
241
+ @downloads&.reset
204
242
  end
205
243
 
206
244
  # Liveness of the CDP transport. Driver#browser checks this to decide
@@ -218,8 +256,12 @@ module Capybara
218
256
  # instance leaves @enabled true and create_page's network.enable
219
257
  # no-ops, silently killing status_code/traffic capture. Guarded on
220
258
  # @client: with no client the handlers are already moot and
221
- # unsubscribe would have nothing to detach from.
222
- @network&.reset if @client
259
+ # unsubscribe would have nothing to detach from. Downloads carries the
260
+ # same subscription contract, so it resets under the same guard.
261
+ if @client
262
+ @network&.reset
263
+ @downloads&.reset
264
+ end
223
265
  begin
224
266
  @client&.close
225
267
  rescue StandardError
@@ -325,7 +367,7 @@ module Capybara
325
367
  # objectId, replaces input.files with a real FileList, and fires
326
368
  # `input`/`change`. The submitted form then carries the bytes as
327
369
  # multipart/form-data (PR #2654, build ≥6672) — both halves are needed,
328
- # which is why MINIMUM_NIGHTLY_BUILD sits at the 6672 floor. Paths are
370
+ # and both are guaranteed by the MINIMUM_NIGHTLY_BUILD floor. Paths are
329
371
  # read off the machine running Lightpanda (local for the spawned process).
330
372
  def set_file_input_files(remote_object_id, paths)
331
373
  page_command("DOM.setFileInputFiles", objectId: remote_object_id, files: paths)
@@ -391,6 +433,7 @@ module Capybara
391
433
 
392
434
  @default_context_event.wait(@options.timeout)
393
435
  @turbo_event.wait(@options.timeout)
436
+ check_unhandled_modal!
394
437
  end
395
438
 
396
439
  def keyboard
@@ -401,6 +444,10 @@ module Capybara
401
444
  @network ||= Network.new(self)
402
445
  end
403
446
 
447
+ def downloads
448
+ @downloads ||= Downloads.new(self)
449
+ end
450
+
404
451
  def cookies
405
452
  @cookies ||= Cookies.new(self)
406
453
  end
@@ -455,6 +502,53 @@ module Capybara
455
502
  page_command("Page.addScriptToEvaluateOnNewDocument", source: AutoScripts::JS)
456
503
  end
457
504
 
505
+ # Apply the `window_size` option as a JS-visible viewport: it drives
506
+ # window.innerWidth/innerHeight and the viewport `matchMedia` / `@media`
507
+ # evaluate against, so responsive branches resolve at the requested size.
508
+ # It is NOT layout — Lightpanda has no rendering engine, so nothing
509
+ # reflows and getBoundingClientRect stays synthetic.
510
+ #
511
+ # Re-applied on every create_page rather than once at connect. The
512
+ # override lives on the CDP-connection-scoped Browser upstream, so it
513
+ # already survives Driver#reset!'s disposeBrowserContext; re-sending is
514
+ # one idempotent call that also covers the reconnect path, where the
515
+ # connection (and therefore the override) is genuinely new.
516
+ #
517
+ # window_size is validated in Options#initialize, which runs before the
518
+ # process is spawned — a bad value must not leave an orphaned browser
519
+ # behind, and raising from here would (Browser#initialize starts the
520
+ # process before create_page ever runs).
521
+ #
522
+ # Public (declared below the private section via `public :set_viewport`)
523
+ # because Driver#resize_window_to drives it mid-test: with no arguments
524
+ # it re-applies the configured size, which is what create_page wants.
525
+ # SHARP EDGE, verified 2026-07-25 on nightly 8285: this updates
526
+ # window.innerWidth/innerHeight and what `matchMedia` reports
527
+ # immediately, but it does NOT re-resolve `@media` rules for the document
528
+ # already loaded. Lightpanda fixes the cascade when a document is parsed
529
+ # and nothing invalidates it on a metrics change, so a page loaded at
530
+ # 1920 keeps rendering its desktop branch even while `matchMedia`
531
+ # ("(max-width: 500px)") reports true. Navigating (Driver#visit) parses
532
+ # under the new metrics and resolves correctly.
533
+ #
534
+ # No workaround shipped on purpose. DOM/CSSOM mutations look like they
535
+ # force a re-cascade but don't survive scrutiny (the probe that suggested
536
+ # otherwise had been contaminated by the previous iteration's viewport),
537
+ # so anything here would be a guess dressed up as a fix. Driver's window
538
+ # methods document the resize-then-visit shape instead.
539
+ def set_viewport(width = nil, height = nil)
540
+ width, height = @options.window_size if width.nil? || height.nil?
541
+ page_command("Emulation.setDeviceMetricsOverride", width: width, height: height)
542
+ [width, height]
543
+ end
544
+
545
+ # The viewport as the page currently sees it. Read back from JS rather
546
+ # than echoing what we last set, so it stays truthful if a page (or a
547
+ # direct Emulation call through #execute_cdp) moved it behind our back.
548
+ def viewport_size
549
+ evaluate("[window.innerWidth, window.innerHeight]")
550
+ end
551
+
458
552
  # Track default-execution-context availability via Runtime events.
459
553
  # Lightpanda destroys the V8 default context at navigation start (long
460
554
  # before frameNavigated fires), then re-creates it once the new page
@@ -510,6 +604,12 @@ module Capybara
510
604
  def monotonic_time
511
605
  ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
512
606
  end
607
+
608
+ # Viewport control is API, not lifecycle: Driver#resize_window_to and
609
+ # #window_size call it mid-test. It lives up in the private section
610
+ # beside register_auto_scripts because create_page is its other caller
611
+ # and the two read together.
612
+ public :set_viewport, :viewport_size
513
613
  end
514
614
  end
515
615
  end
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Capybara
6
+ module Lightpanda
7
+ # File-download tracker built on `Browser.setDownloadBehavior` (upstream PR
8
+ # #2722, build >= 7545, guaranteed by MINIMUM_NIGHTLY_BUILD). When a
9
+ # navigation response carries `Content-Disposition: attachment`, Lightpanda
10
+ # streams the body to disk under `downloadPath` and — with
11
+ # `eventsEnabled: true` — emits `Browser.downloadWillBegin` /
12
+ # `Browser.downloadProgress`. We mirror those into a completed-files list +
13
+ # a blocking `wait`, the way ferrum's Downloads does.
14
+ #
15
+ # IMPORTANT: the trigger is `Content-Disposition: attachment`, NOT MIME
16
+ # type. A `text/csv` (or any) response without that header is rendered as a
17
+ # normal navigation, not downloaded — which is why Capybara's MIME-triggered
18
+ # `:download` shared spec stays in capybara_skip.
19
+ #
20
+ # Structure deliberately mirrors Network (same browser ref, same mutex +
21
+ # subscribe/unsubscribe lifecycle, same reset-after-disposeBrowserContext
22
+ # contract) so the two trackers behave identically under create_page/reset.
23
+ class Downloads
24
+ attr_reader :browser, :path
25
+
26
+ # How long #wait gives a download to BEGIN (the downloadWillBegin frame
27
+ # can arrive a beat after the click that triggers it, once the click's
28
+ # own wait_for_idle has already returned). Once a download has begun we
29
+ # wait the full `timeout` for it to finish.
30
+ DOWNLOAD_BEGIN_GRACE = 1.0
31
+
32
+ def initialize(browser)
33
+ @browser = browser
34
+ @path = nil
35
+ @enabled = false
36
+ @mutex = Mutex.new
37
+ @pending = {} # guid => on-disk basename (from suggestedFilename)
38
+ @files = [] # absolute paths of completed downloads, in order
39
+ @started = 0 # monotonic count of downloads begun this session
40
+ @will_handler = nil
41
+ @progress_handler = nil
42
+ end
43
+
44
+ # Opt into downloads, writing completed files under `save_path`. No-op
45
+ # when `save_path` is blank — downloads then stay at Lightpanda's `deny`
46
+ # default. Connection-scoped (browser.command) like Network.enable;
47
+ # Browser#create_page calls this after the context is loaded (the CDP
48
+ # method is a no-op without one). Subscribe BEFORE the wire toggle so no
49
+ # event can slip past, and roll the handlers back if the command fails.
50
+ def enable(save_path)
51
+ return if @enabled || save_path.to_s.empty?
52
+
53
+ @path = File.expand_path(save_path.to_s)
54
+ FileUtils.mkdir_p(@path)
55
+ subscribe
56
+ begin
57
+ browser.command("Browser.setDownloadBehavior",
58
+ behavior: "allow", downloadPath: @path, eventsEnabled: true)
59
+ rescue StandardError
60
+ unsubscribe
61
+ @path = nil
62
+ raise
63
+ end
64
+ @enabled = true
65
+ end
66
+
67
+ # Absolute paths of downloads completed this session (newest last).
68
+ def files
69
+ @mutex.synchronize { @files.dup }
70
+ end
71
+
72
+ # Monotonic count of downloads that have begun this session. Used by
73
+ # #wait to detect "a download started since I was called" even after the
74
+ # event queue has drained and @pending is empty again.
75
+ def started_count
76
+ @mutex.synchronize { @started }
77
+ end
78
+
79
+ def in_progress?
80
+ @mutex.synchronize { @pending.any? }
81
+ end
82
+
83
+ # Block until in-flight downloads finish, then return the completed-file
84
+ # list. Non-raising, matching Network#wait_for_idle.
85
+ #
86
+ # The triggering click returns (its wait_for_idle settles on the
87
+ # navigation's network response) slightly BEFORE the downloadWillBegin
88
+ # frame is processed, so a naive `wait until !in_progress?` would see an
89
+ # empty queue and return before the download even registered. Instead:
90
+ # give a download up to DOWNLOAD_BEGIN_GRACE to begin (tracked by the
91
+ # monotonic @started counter, immune to the queue already having drained),
92
+ # then wait up to `timeout` for every in-flight download to finish.
93
+ def wait(timeout: 5)
94
+ baseline = started_count
95
+ clock = -> { ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) }
96
+ hard_deadline = clock.call + timeout
97
+ begin_deadline = clock.call + [timeout, DOWNLOAD_BEGIN_GRACE].min
98
+
99
+ loop do
100
+ began = started_count > baseline
101
+ done = (began ? !in_progress? : clock.call >= begin_deadline) || clock.call >= hard_deadline
102
+ return files if done
103
+
104
+ sleep 0.02
105
+ end
106
+ end
107
+
108
+ def clear
109
+ @mutex.synchronize do
110
+ @files.clear
111
+ @pending.clear
112
+ @started = 0
113
+ end
114
+ end
115
+
116
+ # Wipe local state after Target.disposeBrowserContext, which drops both
117
+ # the subscriptions and the per-connection download config — leaving
118
+ # @enabled true would no-op the next #enable. Mirrors Network#reset.
119
+ def reset
120
+ unsubscribe
121
+ clear
122
+ @enabled = false
123
+ @path = nil
124
+ end
125
+
126
+ private
127
+
128
+ def subscribe
129
+ @will_handler = build_will_handler
130
+ @progress_handler = build_progress_handler
131
+ browser.on("Browser.downloadWillBegin", &@will_handler)
132
+ browser.on("Browser.downloadProgress", &@progress_handler)
133
+ end
134
+
135
+ # downloadWillBegin carries {guid, url, suggestedFilename}. behavior
136
+ # "allow" writes the file under the (path-stripped) suggested name, so
137
+ # remember guid => basename to resolve the absolute path on completion.
138
+ def build_will_handler
139
+ lambda do |params|
140
+ guid = params["guid"]
141
+ name = File.basename(params["suggestedFilename"].to_s)
142
+ next if guid.nil? || name.empty?
143
+
144
+ @mutex.synchronize do
145
+ @pending[guid] = name
146
+ @started += 1
147
+ end
148
+ end
149
+ end
150
+
151
+ # downloadProgress carries {guid, state}. On "completed" the file is on
152
+ # disk at downloadPath/basename; "canceled" just drops the pending entry.
153
+ def build_progress_handler
154
+ lambda do |params|
155
+ guid = params["guid"]
156
+ case params["state"]
157
+ when "completed"
158
+ @mutex.synchronize do
159
+ name = @pending.delete(guid)
160
+ @files << File.join(@path, name) if name && @path
161
+ end
162
+ when "canceled"
163
+ @mutex.synchronize { @pending.delete(guid) }
164
+ end
165
+ end
166
+ end
167
+
168
+ def unsubscribe
169
+ browser.off("Browser.downloadWillBegin", @will_handler) if @will_handler
170
+ browser.off("Browser.downloadProgress", @progress_handler) if @progress_handler
171
+ @will_handler = nil
172
+ @progress_handler = nil
173
+ end
174
+ end
175
+ end
176
+ end
@@ -117,6 +117,22 @@ module Capybara
117
117
  network.wait_for_idle(timeout: timeout, connections: connections)
118
118
  end
119
119
 
120
+ # -- Downloads --
121
+ # Files downloaded since the session started (absolute paths). Capture is
122
+ # on whenever a destination exists (the :save_path driver option, else
123
+ # Capybara.save_path) and the server sends `Content-Disposition:
124
+ # attachment`. Ferrum/Cuprite expose downloads on the driver too.
125
+
126
+ def downloads
127
+ browser.downloads.files
128
+ end
129
+
130
+ # Block until in-flight downloads finish (or timeout); returns the file
131
+ # list. Click the download trigger first, then call this.
132
+ def wait_for_download(timeout: 5)
133
+ browser.downloads.wait(timeout: timeout)
134
+ end
135
+
120
136
  # -- Cookie Management --
121
137
 
122
138
  def set_cookie(name, value, **options)
@@ -158,6 +174,83 @@ module Capybara
158
174
  end
159
175
  end
160
176
 
177
+ # -- Window Support --
178
+ # Single-window driver. Lightpanda's BrowserContext is 1:1:1 with
179
+ # Session/Page/target and rejects a second Target.createTarget with
180
+ # TargetAlreadyLoaded (upstream #1962, maintainer-owned), so there is
181
+ # exactly one window and its handle is the CDP target id.
182
+ #
183
+ # Resizing drives Emulation.setDeviceMetricsOverride, so it is real for
184
+ # window.innerWidth/innerHeight and for what `matchMedia` reports. Two
185
+ # limits worth knowing before writing a responsive spec:
186
+ #
187
+ # 1. It is not layout. Element geometry stays synthetic, so a resize
188
+ # changes which CSS branch applies, never where anything sits.
189
+ # 2. `@media` rules do NOT re-resolve for the document already on
190
+ # screen — Lightpanda fixes the cascade at parse time and a metrics
191
+ # change doesn't invalidate it. So resize, THEN visit:
192
+ #
193
+ # page.current_window.resize_to(375, 667)
194
+ # visit "/pricing" # parses under the new metrics
195
+ # assert_selector "#mobile-cta"
196
+ #
197
+ # Resizing without re-visiting leaves `matchMedia` and the rendered
198
+ # branch disagreeing. See Browser#set_viewport for the verification.
199
+
200
+ def current_window_handle
201
+ browser.target_id
202
+ end
203
+
204
+ def window_handles
205
+ [browser.target_id]
206
+ end
207
+
208
+ def switch_to_window(handle)
209
+ return if handle == browser.target_id
210
+
211
+ raise NoSuchPageError, "Window #{handle.inspect} does not exist. " \
212
+ "Lightpanda supports a single window per session."
213
+ end
214
+
215
+ # Capybara's Window#current? rescues this to decide whether a handle is
216
+ # still live, so it must be a class, not a raise.
217
+ def no_such_window_error
218
+ NoSuchPageError
219
+ end
220
+
221
+ def window_size(handle)
222
+ assert_current_window!(handle)
223
+ browser.viewport_size
224
+ end
225
+
226
+ def resize_window_to(handle, width, height)
227
+ assert_current_window!(handle)
228
+ browser.set_viewport(width, height)
229
+ end
230
+
231
+ # No window manager and no layout, so "as large as the screen" has no
232
+ # meaning beyond the configured size. Both reset to it rather than
233
+ # raising, because suites call maximize defensively in setup and a
234
+ # raise there would take out the example before it starts.
235
+ def maximize_window(handle)
236
+ assert_current_window!(handle)
237
+ browser.set_viewport
238
+ end
239
+
240
+ alias fullscreen_window maximize_window
241
+
242
+ def open_new_window(_kind = :tab)
243
+ raise Capybara::NotSupportedByDriverError,
244
+ "Lightpanda serves a single target per CDP connection (upstream lightpanda-io/browser#1962), " \
245
+ "so a second window cannot be opened. Drive the second page in its own Capybara session instead."
246
+ end
247
+
248
+ def close_window(_handle)
249
+ raise Capybara::NotSupportedByDriverError,
250
+ "Lightpanda has a single window per session; closing it would end the session. " \
251
+ "Use Driver#reset! to start a fresh one."
252
+ end
253
+
161
254
  # -- Modal/Dialog Support --
162
255
 
163
256
  # find_modal owns the wait default (browser.options.timeout) — pass
@@ -196,8 +289,11 @@ module Capybara
196
289
  alias render save_screenshot
197
290
 
198
291
  # -- Headers (Cuprite-compatible driver surface) --
199
- # Delegates to Network, which lazily enables the Network domain and
200
- # remembers the headers across reset. Cuprite exposes these on the
292
+ # Delegates to Network, which lazily enables the Network domain. The
293
+ # overrides do NOT survive reset!: disposing the BrowserContext takes
294
+ # setExtraHTTPHeaders with it, so Network#reset drops its cached copy
295
+ # rather than report headers the browser stopped sending (pinned by
296
+ # network_test.rb "clears extra_headers"). Cuprite exposes these on the
201
297
  # driver, and real suites call them there (page.driver.headers = ...).
202
298
 
203
299
  def headers
@@ -273,6 +369,17 @@ module Capybara
273
369
 
274
370
  private
275
371
 
372
+ # Every window method takes a handle because Capybara's Window objects
373
+ # carry one; with a single window the only valid value is the current
374
+ # target id. Reject anything else rather than silently operating on the
375
+ # wrong window.
376
+ def assert_current_window!(handle)
377
+ return if handle.nil? || handle == browser.target_id
378
+
379
+ raise NoSuchPageError, "Window #{handle.inspect} does not exist. " \
380
+ "Lightpanda supports a single window per session."
381
+ end
382
+
276
383
  # Unwrap arguments before sending to the browser. Capybara::Node::Element wraps
277
384
  # our Lightpanda::Node — pull `.base` out so `serialize_argument` can build
278
385
  # `{objectId: …}` for the CDP payload. Cuprite's `native_args` pattern.
@@ -13,6 +13,11 @@ module Capybara
13
13
  class UnsupportedPlatformError < Error; end
14
14
 
15
15
  class TimeoutError < Error; end
16
+ # A JS dialog opened with no accept_modal/dismiss_modal pre-arm in flight
17
+ # and the driver was configured with `raise_on_unhandled_modal: true`.
18
+ # Lightpanda has already applied its silent default by the time this is
19
+ # raised (confirm → cancel, prompt → null, alert → dismissed).
20
+ class UnhandledModalError < Error; end
16
21
 
17
22
  # Base class for any error originating from a CDP response or live browser
18
23
  # state. Lets callers `rescue BrowserError` to catch the whole CDP family
@@ -0,0 +1,74 @@
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
+ });