capybara-lightpanda 0.8.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.
Files changed (32) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +62 -0
  3. data/README.md +14 -1
  4. data/lib/capybara/lightpanda/auto_scripts.rb +30 -2
  5. data/lib/capybara/lightpanda/binary.rb +72 -54
  6. data/lib/capybara/lightpanda/browser/console.rb +190 -0
  7. data/lib/capybara/lightpanda/browser/finder.rb +196 -0
  8. data/lib/capybara/lightpanda/browser/modals.rb +150 -0
  9. data/lib/capybara/lightpanda/browser/navigation.rb +186 -0
  10. data/lib/capybara/lightpanda/browser/runtime.rb +258 -0
  11. data/lib/capybara/lightpanda/browser/selenium_compat.rb +124 -0
  12. data/lib/capybara/lightpanda/browser.rb +158 -813
  13. data/lib/capybara/lightpanda/client/subscriber.rb +2 -0
  14. data/lib/capybara/lightpanda/client/web_socket.rb +19 -21
  15. data/lib/capybara/lightpanda/client.rb +5 -4
  16. data/lib/capybara/lightpanda/downloads.rb +176 -0
  17. data/lib/capybara/lightpanda/driver.rb +124 -32
  18. data/lib/capybara/lightpanda/errors.rb +24 -10
  19. data/lib/capybara/lightpanda/javascripts/attach.js +16 -0
  20. data/lib/capybara/lightpanda/javascripts/banner.js +15 -0
  21. data/lib/capybara/lightpanda/javascripts/errors.js +74 -0
  22. data/lib/capybara/lightpanda/javascripts/predicates.js +116 -0
  23. data/lib/capybara/lightpanda/javascripts/turbo.js +67 -0
  24. data/lib/capybara/lightpanda/keyboard.rb +23 -19
  25. data/lib/capybara/lightpanda/network.rb +103 -18
  26. data/lib/capybara/lightpanda/node.rb +200 -95
  27. data/lib/capybara/lightpanda/options.rb +44 -3
  28. data/lib/capybara/lightpanda/process.rb +154 -29
  29. data/lib/capybara/lightpanda/version.rb +1 -1
  30. data/lib/capybara-lightpanda.rb +1 -0
  31. metadata +14 -3
  32. data/lib/capybara/lightpanda/javascripts/index.js +0 -226
@@ -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
+ });
@@ -0,0 +1,116 @@
1
+ // --- DOM visibility / state predicates ---
2
+ // Centralized so node.rb's predicate methods (visible?, obscured?, disabled?)
3
+ // and the visible_text walker share one implementation of the ancestor
4
+ // cascade. Available in iframes too because the bundle is registered via
5
+ // Page.addScriptToEvaluateOnNewDocument.
6
+ //
7
+ // These are bare top-level function declarations — attach.js exposes them on
8
+ // window._lightpanda. They reference each other by name (visibleText calls
9
+ // isVisible), not through `this`, so they keep working when node.rb invokes
10
+ // `_lightpanda.visibleText(this)` with `this` bound to a DOM element by CDP.
11
+
12
+ // Returns true if `el` is visible per Capybara's semantics. Lightpanda's
13
+ // `checkVisibility()` walks ancestors and rejects `display:none` from
14
+ // inline styles, stylesheets, and the UA sheet (PR #2294 — covers
15
+ // HEAD/SCRIPT/STYLE/NOSCRIPT/TEMPLATE/TITLE, `[hidden]`, `[type=hidden]`,
16
+ // closed-<details> children). `visibility:hidden|collapse` is handled
17
+ // separately because checkVisibility's defaults (per spec) ignore it.
18
+ //
19
+ // External `<link rel=stylesheet>` ARE fetched and applied — the gem
20
+ // always passes --enable-external-stylesheets (Lightpanda PR #2487,
21
+ // guaranteed by the build floor) — so stylesheet-driven `display:none`
22
+ // participates in this check. The remaining gap is viewport emulation:
23
+ // `@media` rules and `matchMedia()` evaluate against the hardcoded
24
+ // 1920×1080 viewport (no resize), so visibility gated on a non-desktop
25
+ // viewport — e.g. a mobile-only CTA under `@media (max-width: …)` —
26
+ // resolves like desktop Chrome at 1920×1080. Keep those specs on a
27
+ // full-layout driver (README's dual-driver setup).
28
+ function isVisible(el) {
29
+ if (!el || el.nodeType !== 1) return false;
30
+ var win = el.ownerDocument.defaultView || window;
31
+ var style = win.getComputedStyle(el);
32
+ if (style.visibility === 'hidden' || style.visibility === 'collapse') return false;
33
+ return el.checkVisibility();
34
+ }
35
+
36
+ // Returns true if the element is obscured at its center point — i.e.
37
+ // hit-testing elementFromPoint at the center returns something that is
38
+ // not the element or its descendant. `visibility:hidden|collapse` is
39
+ // short-circuited explicitly because those elements still produce a
40
+ // layout box (so the rect-zero check below can't catch them); every
41
+ // other "not rendered" case (display:none, [hidden], descendants of
42
+ // either) falls out naturally because getBoundingClientRect returns
43
+ // DOMRect{0,0,0,0} for elements with no layout box.
44
+ function isObscured(el) {
45
+ var doc = el.ownerDocument;
46
+ var win = doc.defaultView || window;
47
+ var style = win.getComputedStyle(el);
48
+ if (style.visibility === 'hidden' || style.visibility === 'collapse') return true;
49
+ var r = el.getBoundingClientRect();
50
+ if (r.width === 0 || r.height === 0) return true;
51
+ var cx = r.left + (r.width / 2);
52
+ var cy = r.top + (r.height / 2);
53
+ var w = win.innerWidth || doc.documentElement.clientWidth;
54
+ var h = win.innerHeight || doc.documentElement.clientHeight;
55
+ if (cx < 0 || cy < 0 || cx > w || cy > h) return true;
56
+ var hit = doc.elementFromPoint(cx, cy);
57
+ if (!hit) return true;
58
+ if (hit === el) return false;
59
+ return !el.contains(hit);
60
+ }
61
+
62
+ // Capybara's `Node#disabled?` is more permissive than CSS `:disabled`:
63
+ // an `<option>` inside a disabled `<select>` or a disabled `<fieldset>`
64
+ // is reported as disabled, even though those don't match CSS `:disabled`
65
+ // per the HTML spec. Mirrors Cuprite's behavior so the shared specs pass.
66
+ function isDisabled(el) {
67
+ if (el.matches && el.matches(':disabled')) return true;
68
+ if ((el.tagName || '').toUpperCase() === 'OPTION') {
69
+ var p = el.parentElement;
70
+ while (p) {
71
+ if (p.disabled) return true;
72
+ p = p.parentElement;
73
+ }
74
+ }
75
+ return false;
76
+ }
77
+
78
+ // True if the element is the host of a contenteditable region: it (or any
79
+ // ancestor) has a non-"false" `contenteditable` attribute. Lightpanda's
80
+ // native `HTMLElement.isContentEditable` (PR #2310) is hardwired to return
81
+ // `false` for every element — it has no caret/keyboard editing pipeline —
82
+ // so the IDL property is useless here and we walk ancestors ourselves.
83
+ function isContentEditable(el) {
84
+ var n = el;
85
+ while (n && n.nodeType === 1) {
86
+ if (n.hasAttribute && n.hasAttribute('contenteditable')) {
87
+ var v = (n.getAttribute('contenteditable') || '').toLowerCase();
88
+ return v !== 'false';
89
+ }
90
+ n = n.parentElement;
91
+ }
92
+ return false;
93
+ }
94
+
95
+ // Capybara's "visible text" = WebDriver getText semantics, which native
96
+ // innerText alone doesn't provide at two edges: a non-rendered element must
97
+ // yield "" (innerText returns textContent per the HTML getter's step 1), and a
98
+ // ShadowRoot/DocumentFragment has no innerText at all. So we gate on visibility
99
+ // and otherwise delegate the rendered-text collection — block line breaks +
100
+ // display:none descendant skipping + whitespace — to native innerText
101
+ // (Lightpanda #2785/#2795, guaranteed by the build floor). node.rb#visible_text
102
+ // normalizes the whitespace on top.
103
+ function visibleText(el) {
104
+ if (el.nodeType === 1) return isVisible(el) ? el.innerText : '';
105
+ // ShadowRoot / DocumentFragment: no innerText of its own. Concatenate the
106
+ // native innerText of each visible element child with the text nodes between
107
+ // them — collapsing the latter's whitespace to a single space so template
108
+ // newlines between inline children render as a separating space, not a break.
109
+ var out = '';
110
+ for (var i = 0; i < el.childNodes.length; i++) {
111
+ var c = el.childNodes[i];
112
+ if (c.nodeType === 3) out += c.nodeValue.replace(/[\t\n\r\f\v ]+/g, ' ');
113
+ else if (c.nodeType === 1 && isVisible(c)) out += c.innerText;
114
+ }
115
+ return out;
116
+ }
@@ -0,0 +1,67 @@
1
+ // --- Turbo activity tracking ---
2
+ // Tracks pending Turbo operations so the driver can wait for Turbo to settle.
3
+ // Inspired by the CapybaraLockstep approach for stabilizing Turbo integration tests.
4
+ // Events are not perfectly symmetrical in Turbo, so we track multiple pairs
5
+ // and use a counter to handle overlapping operations.
6
+ //
7
+ // Transitions across 0 emit `__lightpanda_turbo_busy` / `__lightpanda_turbo_idle`
8
+ // sentinels via console.debug. Browser#wait_for_turbo subscribes to those
9
+ // sentinels (Runtime.consoleAPICalled) and toggles a Concurrent::Event so the
10
+ // Ruby side can wait event-driven instead of polling.
11
+ //
12
+ // Pages without Turbo never trigger _turboStart, so no sentinels fire and the
13
+ // Ruby Event stays set (idle by default) — wait_for_turbo returns immediately.
14
+ var _pendingTurboOps = 0;
15
+ function _signalTurbo(state) {
16
+ try { console.debug('__lightpanda_turbo_' + state); } catch (e) {}
17
+ }
18
+ function _turboStart() {
19
+ _pendingTurboOps++;
20
+ if (_pendingTurboOps === 1) _signalTurbo('busy');
21
+ }
22
+ function _turboEnd() {
23
+ if (_pendingTurboOps > 0) {
24
+ _pendingTurboOps--;
25
+ if (_pendingTurboOps === 0) _signalTurbo('idle');
26
+ }
27
+ }
28
+
29
+ // Fetch requests (covers Drive, Frames, and Form submission fetches)
30
+ document.addEventListener('turbo:before-fetch-request', _turboStart);
31
+ document.addEventListener('turbo:before-fetch-response', _turboEnd);
32
+ document.addEventListener('turbo:fetch-request-error', _turboEnd);
33
+
34
+ // Form submissions (can outlast their underlying fetch)
35
+ document.addEventListener('turbo:submit-start', _turboStart);
36
+ document.addEventListener('turbo:submit-end', _turboEnd);
37
+
38
+ // Frame rendering (can outlast the fetch that triggered it)
39
+ document.addEventListener('turbo:before-frame-render', _turboStart);
40
+ document.addEventListener('turbo:frame-render', _turboEnd);
41
+
42
+ // Stream rendering (no symmetric end event — wrap the render function)
43
+ document.addEventListener('turbo:before-stream-render', function(event) {
44
+ _turboStart();
45
+ if (event.detail && event.detail.render) {
46
+ var originalRender = event.detail.render;
47
+ event.detail.render = function(streamElement) {
48
+ var result = originalRender(streamElement);
49
+ if (result && typeof result.then === 'function') {
50
+ return result.finally(_turboEnd);
51
+ }
52
+ _turboEnd();
53
+ return result;
54
+ };
55
+ } else {
56
+ _turboEnd();
57
+ }
58
+ });
59
+
60
+ // Drive page visits: turbo:load fires after the page is fully rendered.
61
+ // Also serves as a safety reset — clears any counter leaks from aborted fetches.
62
+ // Always re-signal idle so the Ruby Event re-arms even if some `_turboEnd`
63
+ // call dropped on the floor mid-navigation.
64
+ document.addEventListener('turbo:load', function() {
65
+ _pendingTurboOps = 0;
66
+ _signalTurbo('idle');
67
+ });
@@ -14,6 +14,7 @@ module Capybara
14
14
  enter: { key: "Enter", code: "Enter", keyCode: 13, text: "\r" },
15
15
  shift: { key: "Shift", code: "ShiftLeft", keyCode: 16 },
16
16
  control: { key: "Control", code: "ControlLeft", keyCode: 17 },
17
+ ctrl: { key: "Control", code: "ControlLeft", keyCode: 17 },
17
18
  alt: { key: "Alt", code: "AltLeft", keyCode: 18 },
18
19
  pause: { key: "Pause", code: "Pause", keyCode: 19 },
19
20
  escape: { key: "Escape", code: "Escape", keyCode: 27 },
@@ -120,12 +121,14 @@ module Capybara
120
121
  def press_modifier(mod, active_mods)
121
122
  return if active_mods.include?(mod)
122
123
 
123
- send_key_event("keyDown", KEYS[mod])
124
+ # key_definition (not KEYS[mod]) so a MODIFIERS entry missing its
125
+ # KEYS counterpart fails as ArgumentError, not NoMethodError on nil.
126
+ send_key_event("keyDown", key_definition(mod))
124
127
  active_mods << mod
125
128
  end
126
129
 
127
130
  def release_modifiers(active_mods)
128
- active_mods.reverse_each { |m| send_key_event("keyUp", KEYS[m]) }
131
+ active_mods.reverse_each { |m| send_key_event("keyUp", key_definition(m)) }
129
132
  active_mods.clear
130
133
  end
131
134
 
@@ -133,7 +136,7 @@ module Capybara
133
136
  return dispatch_key(key) if active_mods.empty?
134
137
 
135
138
  modifier_value = active_mods.sum { |m| MODIFIERS[m] }
136
- dispatch_modified(key, modifier_value, active_mods)
139
+ raw_dispatch(key_definition(key), modifiers: modifier_value)
137
140
  end
138
141
 
139
142
  def dispatch_char_with_mods(char, active_mods)
@@ -144,31 +147,28 @@ module Capybara
144
147
  end
145
148
 
146
149
  def dispatch_key(key)
147
- definition = KEYS.fetch(key) { raise ArgumentError, "Unknown key: #{key.inspect}" }
148
- raw_dispatch(definition)
150
+ raw_dispatch(key_definition(key))
149
151
  end
150
152
 
151
153
  def dispatch_char(char)
152
154
  @browser.page_command("Input.insertText", text: char)
153
155
  end
154
156
 
157
+ # An array groups modifiers with the keys they modify —
158
+ # `send_keys([:ctrl, "a"])` — scoped to the array instead of the rest
159
+ # of the call. Same press/dispatch/release machinery as held modifiers.
155
160
  def type_with_modifiers(keys)
156
- modifiers, chars = keys.partition { |k| k.is_a?(Symbol) && MODIFIERS.key?(k) }
157
- modifier_value = modifiers.sum { |m| MODIFIERS[m] }
161
+ modifiers, rest = keys.partition { |k| k.is_a?(Symbol) && MODIFIERS.key?(k) }
158
162
 
159
- modifiers.each { |m| send_key_event("keyDown", KEYS[m]) }
160
- chars.each { |key| dispatch_modified(key, modifier_value, modifiers) }
161
- modifiers.reverse_each { |m| send_key_event("keyUp", KEYS[m]) }
162
- end
163
-
164
- def dispatch_modified(key, modifier_value, modifiers)
165
- case key
166
- when Symbol
167
- definition = KEYS.fetch(key) { raise ArgumentError, "Unknown key: #{key.inspect}" }
168
- raw_dispatch(definition, modifiers: modifier_value)
169
- when String
170
- key.each_char { |char| dispatch_modified_char(char, modifier_value, modifiers) }
163
+ active_mods = []
164
+ modifiers.each { |m| press_modifier(m, active_mods) }
165
+ rest.each do |key|
166
+ case key
167
+ when Symbol then dispatch_key_with_mods(key, active_mods)
168
+ when String then key.each_char { |char| dispatch_char_with_mods(char, active_mods) }
169
+ end
171
170
  end
171
+ release_modifiers(active_mods)
172
172
  end
173
173
 
174
174
  def dispatch_modified_char(char, modifier_value, modifiers)
@@ -177,6 +177,10 @@ module Capybara
177
177
  send_key_event("keyUp", { key: text }, modifiers: modifier_value)
178
178
  end
179
179
 
180
+ def key_definition(key)
181
+ KEYS.fetch(key) { raise ArgumentError, "Unknown key: #{key.inspect}" }
182
+ end
183
+
180
184
  def raw_dispatch(definition, modifiers: 0)
181
185
  type = definition[:text] ? "keyDown" : "rawKeyDown"
182
186
  send_key_event(type, definition, modifiers: modifiers)
@@ -3,25 +3,54 @@
3
3
  module Capybara
4
4
  module Lightpanda
5
5
  class Network
6
+ # Tracking is always on (create_page enables it), so the buffer needs
7
+ # the same unbounded-growth cap as Browser's console ring buffer —
8
+ # one very long session would otherwise grow @traffic indefinitely.
9
+ TRAFFIC_LIMIT = 1_000
10
+
6
11
  attr_reader :browser
7
12
 
13
+ # Status/headers of the last top-level document navigation; nil before
14
+ # the first navigation completes. Backs Browser#status_code /
15
+ # #response_headers. Captured by #subscribe's handlers.
16
+ attr_reader :last_navigation_response
17
+
8
18
  def initialize(browser)
9
19
  @browser = browser
10
20
  @traffic = []
11
21
  @traffic_mutex = Mutex.new
12
22
  @enabled = false
13
- @request_handler = nil
14
- @response_handler = nil
23
+ @request_handler = @response_handler = nil
24
+ @last_navigation_response = nil
25
+ @document_request_id = nil
15
26
  end
16
27
 
28
+ # The domain toggle is connection-scoped (browser.command), while
29
+ # setExtraHTTPHeaders is session-scoped (browser.page_command) — see
30
+ # #headers=. Browser#create_page calls this, so tracking (traffic AND
31
+ # the navigation-response capture) is on for every page.
17
32
  def enable
18
33
  return if @enabled
19
34
 
20
- browser.command("Network.enable")
35
+ # Subscribe BEFORE flipping the wire toggle (mirror image of
36
+ # #disable's ordering): events can't be emitted while the domain is
37
+ # off, so this order can never miss one. If the command fails
38
+ # (Lightpanda can block commands mid-navigation), roll the handlers
39
+ # back — orphaned duplicates would double-count every request and
40
+ # wedge pending_connections above zero for the session.
21
41
  subscribe
42
+ begin
43
+ browser.command("Network.enable")
44
+ rescue StandardError
45
+ unsubscribe
46
+ raise
47
+ end
22
48
  @enabled = true
23
49
  end
24
50
 
51
+ # Caveat: disabling the domain also stops the navigation-response
52
+ # capture, so Browser#status_code / #response_headers freeze at their
53
+ # last values until the next #enable.
25
54
  def disable
26
55
  return unless @enabled
27
56
 
@@ -51,8 +80,11 @@ module Capybara
51
80
  # cleared the Subscriber first.
52
81
  def reset
53
82
  unsubscribe
54
- @traffic_mutex.synchronize { @traffic.clear }
83
+ clear
55
84
  @enabled = false
85
+ @extra_headers = nil # the fresh context never received setExtraHTTPHeaders
86
+ @last_navigation_response = nil
87
+ @document_request_id = nil
56
88
  end
57
89
 
58
90
  # Headers applied via headers= / add_headers. Backs Driver#headers.
@@ -111,11 +143,41 @@ module Capybara
111
143
 
112
144
  private
113
145
 
146
+ # CDP events arrive on the message thread while wait_for_idle /
147
+ # pending_connections read from the main thread; serialize all
148
+ # @traffic mutations and reads through @traffic_mutex.
149
+ #
150
+ # The handlers also capture the last top-level navigation response
151
+ # (mirrors capybara-playwright-driver's navigation_request? hook). CDP
152
+ # normally marks the main-document response via
153
+ # `Network.responseReceived.type`. Lightpanda only started emitting that
154
+ # field on responses in build 8318 (#3037) — far above the gem's floor —
155
+ # so match the long way instead: remember the document requestId, store
156
+ # the response whose requestId equals it. Works on every supported build;
157
+ # don't "simplify" it to read response.type until the floor clears 8318.
114
158
  def subscribe
115
- # CDP events arrive on the message thread while wait_for_idle /
116
- # pending_connections read from the main thread; serialize all
117
- # @traffic mutations and reads through @traffic_mutex.
118
- @request_handler = lambda do |params|
159
+ @request_handler = build_request_handler
160
+ @response_handler = build_response_handler
161
+ browser.on("Network.requestWillBeSent", &@request_handler)
162
+ browser.on("Network.responseReceived", &@response_handler)
163
+ end
164
+
165
+ # Redirects follow Chrome's shape (Lightpanda since #3175, build ≥8602):
166
+ # every followed hop re-emits requestWillBeSent with the SAME requestId
167
+ # and the 3xx riding along as `redirectResponse`; the 3xx never gets a
168
+ # responseReceived of its own. So the hop's event is what closes the
169
+ # previous entry — without it that entry stays `response: nil` forever,
170
+ # pending_connections wedges at ≥1 and every wait_for_idle burns its
171
+ # full timeout after the first redirect (Ferrum's
172
+ # subscribe_request_will_be_sent does the same close). Deliberately NOT
173
+ # fed into @last_navigation_response: status_code must report the final
174
+ # hop, and the redirected requestWillBeSent resets it below anyway.
175
+ def build_request_handler
176
+ lambda do |params|
177
+ if params["type"] == "Document"
178
+ @document_request_id = params["requestId"]
179
+ @last_navigation_response = nil
180
+ end
119
181
  entry = {
120
182
  request_id: params["requestId"],
121
183
  url: params.dig("request", "url"),
@@ -123,24 +185,47 @@ module Capybara
123
185
  timestamp: params["timestamp"],
124
186
  response: nil,
125
187
  }
126
- @traffic_mutex.synchronize { @traffic << entry }
188
+ @traffic_mutex.synchronize do
189
+ if (redirect = params["redirectResponse"]) && (previous = last_open_entry(params["requestId"]))
190
+ previous[:response] = response_summary(redirect)
191
+ end
192
+ @traffic << entry
193
+ @traffic.shift(@traffic.size - TRAFFIC_LIMIT) if @traffic.size > TRAFFIC_LIMIT
194
+ end
127
195
  end
196
+ end
128
197
 
129
- @response_handler = lambda do |params|
198
+ def build_response_handler
199
+ lambda do |params|
200
+ if params["requestId"] == @document_request_id
201
+ @last_navigation_response = {
202
+ status: params.dig("response", "status"),
203
+ headers: params.dig("response", "headers") || {},
204
+ }
205
+ end
130
206
  @traffic_mutex.synchronize do
131
- request = @traffic.find { |t| t[:request_id] == params["requestId"] }
207
+ # Last open entry, not `find`: after a redirect chain several
208
+ # entries share the requestId and only the newest is still open.
209
+ request = last_open_entry(params["requestId"])
132
210
  next unless request
133
211
 
134
- request[:response] = {
135
- status: params.dig("response", "status"),
136
- headers: params.dig("response", "headers"),
137
- mime_type: params.dig("response", "mimeType"),
138
- }
212
+ request[:response] = response_summary(params["response"])
139
213
  end
140
214
  end
215
+ end
141
216
 
142
- browser.on("Network.requestWillBeSent", &@request_handler)
143
- browser.on("Network.responseReceived", &@response_handler)
217
+ # Caller holds @traffic_mutex.
218
+ def last_open_entry(request_id)
219
+ @traffic.reverse_each.find { |t| t[:request_id] == request_id && t[:response].nil? }
220
+ end
221
+
222
+ def response_summary(response)
223
+ response ||= {}
224
+ {
225
+ status: response["status"],
226
+ headers: response["headers"],
227
+ mime_type: response["mimeType"],
228
+ }
144
229
  end
145
230
 
146
231
  def unsubscribe