capybara-lightpanda 0.8.0 → 0.9.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.
@@ -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,30 @@ 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`, 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.
114
157
  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|
158
+ @request_handler = build_request_handler
159
+ @response_handler = build_response_handler
160
+ browser.on("Network.requestWillBeSent", &@request_handler)
161
+ browser.on("Network.responseReceived", &@response_handler)
162
+ end
163
+
164
+ def build_request_handler
165
+ lambda do |params|
166
+ if params["type"] == "Document"
167
+ @document_request_id = params["requestId"]
168
+ @last_navigation_response = nil
169
+ end
119
170
  entry = {
120
171
  request_id: params["requestId"],
121
172
  url: params.dig("request", "url"),
@@ -123,10 +174,21 @@ module Capybara
123
174
  timestamp: params["timestamp"],
124
175
  response: nil,
125
176
  }
126
- @traffic_mutex.synchronize { @traffic << entry }
177
+ @traffic_mutex.synchronize do
178
+ @traffic << entry
179
+ @traffic.shift(@traffic.size - TRAFFIC_LIMIT) if @traffic.size > TRAFFIC_LIMIT
180
+ end
127
181
  end
182
+ end
128
183
 
129
- @response_handler = lambda do |params|
184
+ def build_response_handler
185
+ lambda do |params|
186
+ if params["requestId"] == @document_request_id
187
+ @last_navigation_response = {
188
+ status: params.dig("response", "status"),
189
+ headers: params.dig("response", "headers") || {},
190
+ }
191
+ end
130
192
  @traffic_mutex.synchronize do
131
193
  request = @traffic.find { |t| t[:request_id] == params["requestId"] }
132
194
  next unless request
@@ -138,9 +200,6 @@ module Capybara
138
200
  }
139
201
  end
140
202
  end
141
-
142
- browser.on("Network.requestWillBeSent", &@request_handler)
143
- browser.on("Network.responseReceived", &@response_handler)
144
203
  end
145
204
 
146
205
  def unsubscribe
@@ -69,14 +69,12 @@ module Capybara
69
69
  previous
70
70
  end
71
71
 
72
+ # Routed through #call (not a bare call_function_on) so a detached
73
+ # host raises ObsoleteNode like every other node operation — Capybara's
74
+ # automatic_reload then re-finds the host instead of silently reading
75
+ # a stale shadowRoot.
72
76
  def shadow_root
73
- result = driver.browser.with_default_context_wait do
74
- driver.browser.call_function_on(
75
- @remote_object_id,
76
- "function() { return this.shadowRoot }",
77
- return_by_value: false
78
- )
79
- end
77
+ result = call(SHADOW_ROOT_JS, return_by_value: false)
80
78
  return nil unless result.is_a?(Hash) && result["objectId"]
81
79
 
82
80
  self.class.new(driver, result["objectId"])
@@ -171,15 +169,9 @@ module Capybara
171
169
  end
172
170
 
173
171
  def unselect_option
174
- unless call("function() {
175
- var s = this.parentElement;
176
- while (s && (s.tagName || '').toUpperCase() !== 'SELECT') s = s.parentElement;
177
- return !!(s && s.multiple);
178
- }")
179
- raise Capybara::UnselectNotAllowed, "Cannot unselect option from single select box."
180
- end
172
+ return unless call(UNSELECT_OPTION_JS) == "not_multiple"
181
173
 
182
- call(UNSELECT_OPTION_JS)
174
+ raise Capybara::UnselectNotAllowed, "Cannot unselect option from single select box."
183
175
  end
184
176
 
185
177
  def send_keys(*)
@@ -437,22 +429,18 @@ module Capybara
437
429
  # a DOM mutation like `replaceWith`, the cached objectId still resolves
438
430
  # to the detached node, so reads succeed quietly and Capybara's
439
431
  # automatic_reload never re-runs the original query.
440
- def call(function_declaration, *args)
432
+ def call(function_declaration, *args, return_by_value: true)
441
433
  guarded = wrap_with_attached_guard(function_declaration)
442
434
  driver.browser.with_default_context_wait do
443
- driver.browser.call_function_on(@remote_object_id, guarded, *args)
435
+ driver.browser.call_function_on(@remote_object_id, guarded, *args,
436
+ return_by_value: return_by_value)
444
437
  end
445
438
  rescue JavaScriptError => e
446
439
  if e.message.include?(OBSOLETE_NODE_MARKER)
447
440
  raise ObsoleteNode.new(self, "Node is no longer attached to the document")
448
441
  end
449
442
 
450
- case e.class_name
451
- when "InvalidSelector"
452
- raise InvalidSelector.new(e.message, nil, args.first)
453
- else
454
- raise
455
- end
443
+ raise
456
444
  end
457
445
 
458
446
  OBSOLETE_NODE_MARKER = "LIGHTPANDA_OBSOLETE_NODE"
@@ -665,11 +653,14 @@ module Capybara
665
653
  }
666
654
  JS
667
655
 
656
+ # Returns 'not_multiple' (without mutating) when the owning <select>
657
+ # isn't multiple — Ruby raises Capybara::UnselectNotAllowed. One CDP
658
+ # round-trip instead of a separate ancestor-walk precheck.
668
659
  UNSELECT_OPTION_JS = <<~JS
669
660
  function() {
670
661
  var sel = this.parentElement;
671
662
  while (sel && (sel.tagName || '').toUpperCase() !== 'SELECT') sel = sel.parentElement;
672
- if (!sel || !sel.multiple) return;
663
+ if (!sel || !sel.multiple) return 'not_multiple';
673
664
  this.selected = false;
674
665
  sel.dispatchEvent(new Event('input', {bubbles: true}));
675
666
  sel.dispatchEvent(new Event('change', {bubbles: true}));
@@ -682,13 +673,7 @@ module Capybara
682
673
  }
683
674
  JS
684
675
 
685
- APPEND_KEYS_JS = <<~JS
686
- function(key) {
687
- this.focus();
688
- this.value += key;
689
- this.dispatchEvent(new Event('input', {bubbles: true}));
690
- }
691
- JS
676
+ SHADOW_ROOT_JS = "function() { return this.shadowRoot }"
692
677
 
693
678
  EDITABLE_HOST_JS = "function() { return _lightpanda.isContentEditable(this); }"
694
679
 
@@ -738,6 +723,14 @@ module Capybara
738
723
  return path.join(' > ');
739
724
  }
740
725
  JS
726
+
727
+ # Internal wire format, not API — aligned with browser.rb's convention
728
+ # of private_constant for its JS snippets.
729
+ private_constant :CLICK_JS, :TRIGGER_JS, :DROP_JS, :SHADOW_ROOT_JS, :VISIBLE_JS, :VISIBLE_TEXT_JS,
730
+ :PROPERTY_OR_ATTRIBUTE_JS, :GET_VALUE_JS, :SET_VALUE_JS,
731
+ :IMPLICIT_SUBMIT_JS, :SELECT_OPTION_JS, :UNSELECT_OPTION_JS,
732
+ :SET_CHECKBOX_JS, :EDITABLE_HOST_JS, :DISABLED_JS, :GET_STYLE_JS,
733
+ :GET_RECT_JS, :OBSCURED_JS, :GET_PATH_JS
741
734
  end
742
735
  end
743
736
  end
@@ -22,6 +22,10 @@ module Capybara
22
22
  DEFAULT_PORT = 0
23
23
  DEFAULT_WINDOW_SIZE = [1024, 768].freeze
24
24
 
25
+ # window_size and headless are accepted for Cuprite drop-in
26
+ # compatibility (standard options at driver registration) but are
27
+ # inert: Lightpanda has no rendering engine, so there is nothing to
28
+ # resize, and headless is the only mode it runs in.
25
29
  attr_accessor :host, :port, :timeout, :handshake_timeout, :process_timeout,
26
30
  :window_size, :browser_path, :headless, :logger
27
31
  attr_writer :ws_url
@@ -123,9 +123,7 @@ module Capybara
123
123
 
124
124
  check_minimum_version(binary_path)
125
125
  attempt_start(binary_path)
126
- rescue ProcessTimeoutError => e
127
- raise unless e.message.include?("already in use")
128
-
126
+ rescue PortInUseError
129
127
  kill_process_on_port(@options.port)
130
128
  attempt_start(binary_path)
131
129
  end
@@ -257,8 +255,8 @@ module Capybara
257
255
  end
258
256
 
259
257
  if output.match?(ADDRESS_IN_USE_PATTERN)
260
- cleanup_failed_process
261
- raise ProcessTimeoutError,
258
+ stop
259
+ raise PortInUseError,
262
260
  "Lightpanda failed to start: port #{@options.port} is already in use"
263
261
  end
264
262
  rescue IO::WaitReadable
@@ -275,19 +273,6 @@ module Capybara
275
273
  end
276
274
  end
277
275
 
278
- def cleanup_failed_process
279
- return unless @pid
280
-
281
- begin
282
- ::Process.wait(@pid, ::Process::WNOHANG)
283
- rescue Errno::ECHILD
284
- nil
285
- end
286
-
287
- cleanup_pipes
288
- @pid = nil
289
- end
290
-
291
276
  # Auto-recover when a previous Lightpanda is still bound to our port.
292
277
  # Best-effort: relies on `lsof` to map port → pid (macOS / most Linux
293
278
  # distros). Where `lsof` isn't on PATH, we surface a clear error rather
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Capybara
4
4
  module Lightpanda
5
- VERSION = "0.8.0"
5
+ VERSION = "0.9.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.8.0
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Navid Emad
@@ -74,6 +74,11 @@ files:
74
74
  - lib/capybara/lightpanda/auto_scripts.rb
75
75
  - lib/capybara/lightpanda/binary.rb
76
76
  - lib/capybara/lightpanda/browser.rb
77
+ - lib/capybara/lightpanda/browser/console.rb
78
+ - lib/capybara/lightpanda/browser/finder.rb
79
+ - lib/capybara/lightpanda/browser/modals.rb
80
+ - lib/capybara/lightpanda/browser/navigation.rb
81
+ - lib/capybara/lightpanda/browser/runtime.rb
77
82
  - lib/capybara/lightpanda/client.rb
78
83
  - lib/capybara/lightpanda/client/subscriber.rb
79
84
  - lib/capybara/lightpanda/client/web_socket.rb
@@ -82,7 +87,10 @@ files:
82
87
  - lib/capybara/lightpanda/element_extension.rb
83
88
  - lib/capybara/lightpanda/errors.rb
84
89
  - lib/capybara/lightpanda/headers.rb
85
- - lib/capybara/lightpanda/javascripts/index.js
90
+ - lib/capybara/lightpanda/javascripts/attach.js
91
+ - lib/capybara/lightpanda/javascripts/banner.js
92
+ - lib/capybara/lightpanda/javascripts/predicates.js
93
+ - lib/capybara/lightpanda/javascripts/turbo.js
86
94
  - lib/capybara/lightpanda/keyboard.rb
87
95
  - lib/capybara/lightpanda/logger.rb
88
96
  - lib/capybara/lightpanda/network.rb
@@ -1,226 +0,0 @@
1
- (function() {
2
- if (window._lightpanda) return;
3
-
4
- // --- Turbo activity tracking ---
5
- // Tracks pending Turbo operations so the driver can wait for Turbo to settle.
6
- // Inspired by the CapybaraLockstep approach for stabilizing Turbo integration tests.
7
- // Events are not perfectly symmetrical in Turbo, so we track multiple pairs
8
- // and use a counter to handle overlapping operations.
9
- //
10
- // Transitions across 0 emit `__lightpanda_turbo_busy` / `__lightpanda_turbo_idle`
11
- // sentinels via console.debug. Browser#wait_for_turbo subscribes to those
12
- // sentinels (Runtime.consoleAPICalled) and toggles a Concurrent::Event so the
13
- // Ruby side can wait event-driven instead of polling.
14
- //
15
- // Pages without Turbo never trigger _turboStart, so no sentinels fire and the
16
- // Ruby Event stays set (idle by default) — wait_for_turbo returns immediately.
17
- var _pendingTurboOps = 0;
18
- function _signalTurbo(state) {
19
- try { console.debug('__lightpanda_turbo_' + state); } catch (e) {}
20
- }
21
- function _turboStart() {
22
- _pendingTurboOps++;
23
- if (_pendingTurboOps === 1) _signalTurbo('busy');
24
- }
25
- function _turboEnd() {
26
- if (_pendingTurboOps > 0) {
27
- _pendingTurboOps--;
28
- if (_pendingTurboOps === 0) _signalTurbo('idle');
29
- }
30
- }
31
-
32
- // Fetch requests (covers Drive, Frames, and Form submission fetches)
33
- document.addEventListener('turbo:before-fetch-request', _turboStart);
34
- document.addEventListener('turbo:before-fetch-response', _turboEnd);
35
- document.addEventListener('turbo:fetch-request-error', _turboEnd);
36
-
37
- // Form submissions (can outlast their underlying fetch)
38
- document.addEventListener('turbo:submit-start', _turboStart);
39
- document.addEventListener('turbo:submit-end', _turboEnd);
40
-
41
- // Frame rendering (can outlast the fetch that triggered it)
42
- document.addEventListener('turbo:before-frame-render', _turboStart);
43
- document.addEventListener('turbo:frame-render', _turboEnd);
44
-
45
- // Stream rendering (no symmetric end event — wrap the render function)
46
- document.addEventListener('turbo:before-stream-render', function(event) {
47
- _turboStart();
48
- if (event.detail && event.detail.render) {
49
- var originalRender = event.detail.render;
50
- event.detail.render = function(streamElement) {
51
- var result = originalRender(streamElement);
52
- if (result && typeof result.then === 'function') {
53
- return result.finally(_turboEnd);
54
- }
55
- _turboEnd();
56
- return result;
57
- };
58
- } else {
59
- _turboEnd();
60
- }
61
- });
62
-
63
- // Drive page visits: turbo:load fires after the page is fully rendered.
64
- // Also serves as a safety reset — clears any counter leaks from aborted fetches.
65
- // Always re-signal idle so the Ruby Event re-arms even if some `_turboEnd`
66
- // call dropped on the floor mid-navigation.
67
- document.addEventListener('turbo:load', function() {
68
- _pendingTurboOps = 0;
69
- _signalTurbo('idle');
70
- });
71
-
72
- // --- Main API ---
73
-
74
- window._lightpanda = {
75
- turbo: {
76
- pending: function() { return _pendingTurboOps; },
77
- idle: function() { return _pendingTurboOps <= 0; }
78
- },
79
-
80
- // --- DOM visibility / state predicates ---
81
- // Centralized so node.rb's predicate methods (visible?, obscured?, disabled?)
82
- // and the visible_text walker share one implementation of the ancestor
83
- // cascade. Available in iframes too because index.js is registered via
84
- // Page.addScriptToEvaluateOnNewDocument.
85
-
86
- // Returns true if `el` is visible per Capybara's semantics. Lightpanda's
87
- // `checkVisibility()` walks ancestors and rejects `display:none` from
88
- // inline styles, stylesheets, and the UA sheet (PR #2294 — covers
89
- // HEAD/SCRIPT/STYLE/NOSCRIPT/TEMPLATE/TITLE, `[hidden]`, `[type=hidden]`,
90
- // closed-<details> children). `visibility:hidden|collapse` is handled
91
- // separately because checkVisibility's defaults (per spec) ignore it.
92
- //
93
- // Intentional upstream out-of-scope (upstream-wishlist.md C10):
94
- // Lightpanda is a headless agentic browser and deliberately doesn't
95
- // load external `<link rel=stylesheet>` or apply `@media` rules to
96
- // the cascade, and `matchMedia()` returns false for every query.
97
- // Responsive patterns that hide one of two mobile/desktop CTA
98
- // duplicates via `@media (min-width: …) { display: none }` leak
99
- // both variants past this check — Capybara then raises
100
- // `Ambiguous: found 2 elements`. There is no in-gem fix: rebuilding
101
- // the CSS cascade in JS would need a CSS parser, sync access to
102
- // remote stylesheets, and a real media-query evaluator. Run
103
- // cuprite for responsive-UI assertions.
104
- isVisible: function(el) {
105
- if (!el || el.nodeType !== 1) return false;
106
- var win = el.ownerDocument.defaultView || window;
107
- var style = win.getComputedStyle(el);
108
- if (style.visibility === 'hidden' || style.visibility === 'collapse') return false;
109
- return el.checkVisibility();
110
- },
111
-
112
- // Returns true if the element is obscured at its center point — i.e.
113
- // hit-testing elementFromPoint at the center returns something that is
114
- // not the element or its descendant. `visibility:hidden|collapse` is
115
- // short-circuited explicitly because those elements still produce a
116
- // layout box (so the rect-zero check below can't catch them); every
117
- // other "not rendered" case (display:none, [hidden], descendants of
118
- // either) falls out naturally because getBoundingClientRect returns
119
- // DOMRect{0,0,0,0} for elements with no layout box.
120
- isObscured: function(el) {
121
- var doc = el.ownerDocument;
122
- var win = doc.defaultView || window;
123
- var style = win.getComputedStyle(el);
124
- if (style.visibility === 'hidden' || style.visibility === 'collapse') return true;
125
- var r = el.getBoundingClientRect();
126
- if (r.width === 0 || r.height === 0) return true;
127
- var cx = r.left + (r.width / 2);
128
- var cy = r.top + (r.height / 2);
129
- var w = win.innerWidth || doc.documentElement.clientWidth;
130
- var h = win.innerHeight || doc.documentElement.clientHeight;
131
- if (cx < 0 || cy < 0 || cx > w || cy > h) return true;
132
- var hit = doc.elementFromPoint(cx, cy);
133
- if (!hit) return true;
134
- if (hit === el) return false;
135
- return !el.contains(hit);
136
- },
137
-
138
- // Capybara's `Node#disabled?` is more permissive than CSS `:disabled`:
139
- // an `<option>` inside a disabled `<select>` or a disabled `<fieldset>`
140
- // is reported as disabled, even though those don't match CSS `:disabled`
141
- // per the HTML spec. Mirrors Cuprite's behavior so the shared specs pass.
142
- isDisabled: function(el) {
143
- if (el.matches && el.matches(':disabled')) return true;
144
- if ((el.tagName || '').toUpperCase() === 'OPTION') {
145
- var p = el.parentElement;
146
- while (p) {
147
- if (p.disabled) return true;
148
- p = p.parentElement;
149
- }
150
- }
151
- return false;
152
- },
153
-
154
- // True if the element is the host of a contenteditable region: it (or any
155
- // ancestor) has a non-"false" `contenteditable` attribute. Lightpanda's
156
- // native `HTMLElement.isContentEditable` (PR #2310) is hardwired to return
157
- // `false` for every element — it has no caret/keyboard editing pipeline —
158
- // so the IDL property is useless here and we walk ancestors ourselves.
159
- isContentEditable: function(el) {
160
- var n = el;
161
- while (n && n.nodeType === 1) {
162
- if (n.hasAttribute && n.hasAttribute('contenteditable')) {
163
- var v = (n.getAttribute('contenteditable') || '').toLowerCase();
164
- return v !== 'false';
165
- }
166
- n = n.parentElement;
167
- }
168
- return false;
169
- },
170
-
171
- // Walk descendants and accumulate text from visible nodes only. Inserts
172
- // newlines around block-display containers so paragraphs/lists render with
173
- // natural breaks (Capybara expects this from Chrome's innerText).
174
- // Lightpanda's innerText returns textContent verbatim (no rendering, so no
175
- // hidden-descendant filtering), so we DIY the visibility filtering here.
176
- visibleText: function(el) {
177
- var BLOCK_DISP = { BLOCK:1, FLEX:1, GRID:1, 'LIST-ITEM':1, TABLE:1, 'TABLE-ROW':1,
178
- 'TABLE-CAPTION':1, 'TABLE-CELL':1 };
179
- var BLOCK_TAG = { ADDRESS:1, ARTICLE:1, ASIDE:1, BLOCKQUOTE:1, DETAILS:1, DIALOG:1,
180
- DIV:1, DL:1, DT:1, DD:1, FIELDSET:1, FIGCAPTION:1, FIGURE:1,
181
- FOOTER:1, FORM:1, H1:1, H2:1, H3:1, H4:1, H5:1, H6:1, HEADER:1,
182
- HGROUP:1, HR:1, LI:1, MAIN:1, NAV:1, OL:1, P:1, PRE:1, SECTION:1,
183
- TABLE:1, TR:1, UL:1 };
184
- var self = this;
185
-
186
- // Collapse runs of ASCII whitespace (preserving NBSP) to a single space —
187
- // matches Chrome's innerText whitespace handling for text nodes.
188
- function normText(s) {
189
- return s.replace(/[\t\n\r\f\v ]+/g, ' ');
190
- }
191
-
192
- function walk(node) {
193
- if (node.nodeType === 3) return normText(node.nodeValue);
194
- // DocumentFragment / ShadowRoot — no element of its own to test
195
- // for visibility, just walk children.
196
- if (node.nodeType === 11) {
197
- var fout = '';
198
- for (var k = 0; k < node.childNodes.length; k++) fout += walk(node.childNodes[k]);
199
- return fout;
200
- }
201
- if (node.nodeType !== 1) return '';
202
- if (!self.isVisible(node)) return '';
203
- var tag = (node.tagName || '').toUpperCase();
204
- if (tag === 'TEXTAREA') return node.value || '';
205
- if (tag === 'BR') return '\n';
206
- var win = node.ownerDocument.defaultView || window;
207
- var style = win.getComputedStyle(node);
208
- var disp = (style.display || '').toUpperCase();
209
- var isBlock = BLOCK_DISP[disp] || BLOCK_TAG[tag];
210
- var out = '';
211
- for (var i = 0; i < node.childNodes.length; i++) {
212
- out += walk(node.childNodes[i]);
213
- }
214
- // Block-level elements get wrapped in \n…\n only when they actually
215
- // contribute visible text. An empty <div> between two inline siblings
216
- // would otherwise introduce a phantom line break that Chrome's
217
- // innerText algorithm collapses out (required line breaks around
218
- // empty blocks coalesce in the line-collapse pass).
219
- if (isBlock && /\S/.test(out)) out = '\n' + out + '\n';
220
- return out;
221
- }
222
-
223
- return walk(el);
224
- }
225
- };
226
- })();