capybara-lightpanda 0.9.0 → 0.11.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.
@@ -92,61 +92,25 @@ function isContentEditable(el) {
92
92
  return false;
93
93
  }
94
94
 
95
- // visibleText's block-detection tables. A node is block-level if its computed
96
- // `display` is in BLOCK_DISP or its tag is in BLOCK_TAG (the tag table catches
97
- // elements whose display we can't resolve and elements that are block by UA
98
- // default). Hoisted to IIFE scope so they're built once at injection, not
99
- // re-allocated on every visibleText() call.
100
- var BLOCK_DISP = { BLOCK:1, FLEX:1, GRID:1, 'LIST-ITEM':1, TABLE:1, 'TABLE-ROW':1,
101
- 'TABLE-CAPTION':1, 'TABLE-CELL':1 };
102
- var BLOCK_TAG = { ADDRESS:1, ARTICLE:1, ASIDE:1, BLOCKQUOTE:1, DETAILS:1, DIALOG:1,
103
- DIV:1, DL:1, DT:1, DD:1, FIELDSET:1, FIGCAPTION:1, FIGURE:1,
104
- FOOTER:1, FORM:1, H1:1, H2:1, H3:1, H4:1, H5:1, H6:1, HEADER:1,
105
- HGROUP:1, HR:1, LI:1, MAIN:1, NAV:1, OL:1, P:1, PRE:1, SECTION:1,
106
- TABLE:1, TR:1, UL:1 };
107
-
108
- // Walk descendants and accumulate text from visible nodes only. Inserts
109
- // newlines around block-display containers so paragraphs/lists render with
110
- // natural breaks (Capybara expects this from Chrome's innerText).
111
- // Lightpanda's innerText returns textContent verbatim (no rendering, so no
112
- // hidden-descendant filtering), so we DIY the visibility filtering here.
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.
113
103
  function visibleText(el) {
114
- // Collapse runs of ASCII whitespace (preserving NBSP) to a single space —
115
- // matches Chrome's innerText whitespace handling for text nodes.
116
- function normText(s) {
117
- return s.replace(/[\t\n\r\f\v ]+/g, ' ');
118
- }
119
-
120
- function walk(node) {
121
- if (node.nodeType === 3) return normText(node.nodeValue);
122
- // DocumentFragment / ShadowRoot — no element of its own to test
123
- // for visibility, just walk children.
124
- if (node.nodeType === 11) {
125
- var fout = '';
126
- for (var k = 0; k < node.childNodes.length; k++) fout += walk(node.childNodes[k]);
127
- return fout;
128
- }
129
- if (node.nodeType !== 1) return '';
130
- if (!isVisible(node)) return '';
131
- var tag = (node.tagName || '').toUpperCase();
132
- if (tag === 'TEXTAREA') return node.value || '';
133
- if (tag === 'BR') return '\n';
134
- var win = node.ownerDocument.defaultView || window;
135
- var style = win.getComputedStyle(node);
136
- var disp = (style.display || '').toUpperCase();
137
- var isBlock = BLOCK_DISP[disp] || BLOCK_TAG[tag];
138
- var out = '';
139
- for (var i = 0; i < node.childNodes.length; i++) {
140
- out += walk(node.childNodes[i]);
141
- }
142
- // Block-level elements get wrapped in \n…\n only when they actually
143
- // contribute visible text. An empty <div> between two inline siblings
144
- // would otherwise introduce a phantom line break that Chrome's
145
- // innerText algorithm collapses out (required line breaks around
146
- // empty blocks coalesce in the line-collapse pass).
147
- if (isBlock && /\S/.test(out)) out = '\n' + out + '\n';
148
- return out;
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;
149
114
  }
150
-
151
- return walk(el);
115
+ return out;
152
116
  }
@@ -150,10 +150,11 @@ module Capybara
150
150
  # The handlers also capture the last top-level navigation response
151
151
  # (mirrors capybara-playwright-driver's navigation_request? hook). CDP
152
152
  # normally marks the main-document response via
153
- # `Network.responseReceived.type`, but Lightpanda omits that field on
154
- # responses (only emits `type` on requestWillBeSent) — so match the
155
- # long way: remember the document requestId, store the response whose
156
- # requestId equals it.
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.
157
158
  def subscribe
158
159
  @request_handler = build_request_handler
159
160
  @response_handler = build_response_handler
@@ -161,6 +162,16 @@ module Capybara
161
162
  browser.on("Network.responseReceived", &@response_handler)
162
163
  end
163
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.
164
175
  def build_request_handler
165
176
  lambda do |params|
166
177
  if params["type"] == "Document"
@@ -175,6 +186,9 @@ module Capybara
175
186
  response: nil,
176
187
  }
177
188
  @traffic_mutex.synchronize do
189
+ if (redirect = params["redirectResponse"]) && (previous = last_open_entry(params["requestId"]))
190
+ previous[:response] = response_summary(redirect)
191
+ end
178
192
  @traffic << entry
179
193
  @traffic.shift(@traffic.size - TRAFFIC_LIMIT) if @traffic.size > TRAFFIC_LIMIT
180
194
  end
@@ -190,18 +204,30 @@ module Capybara
190
204
  }
191
205
  end
192
206
  @traffic_mutex.synchronize do
193
- 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"])
194
210
  next unless request
195
211
 
196
- request[:response] = {
197
- status: params.dig("response", "status"),
198
- headers: params.dig("response", "headers"),
199
- mime_type: params.dig("response", "mimeType"),
200
- }
212
+ request[:response] = response_summary(params["response"])
201
213
  end
202
214
  end
203
215
  end
204
216
 
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
+ }
229
+ end
230
+
205
231
  def unsubscribe
206
232
  browser.off("Network.requestWillBeSent", @request_handler) if @request_handler
207
233
  browser.off("Network.responseReceived", @response_handler) if @response_handler