capybara-lightpanda 0.3.0 → 0.4.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.
@@ -43,7 +43,7 @@ module Capybara
43
43
  end
44
44
 
45
45
  def closed?
46
- @status == :closed
46
+ @status == :closed || @status == :error
47
47
  end
48
48
 
49
49
  def open?
@@ -102,10 +102,15 @@ module Capybara
102
102
  end
103
103
 
104
104
  @driver.on(:error) do |event|
105
+ # Do NOT raise here. This callback fires synchronously from
106
+ # @driver.parse(data) inside the reader thread, which sets
107
+ # abort_on_exception = true. Raising DeadBrowserError escapes
108
+ # the reader's narrow IO rescue and aborts the entire Ruby
109
+ # process. Mark the connection dead and let Client#command
110
+ # surface DeadBrowserError on its next dispatch via closed?.
111
+ @logger&.puts("✗ WebSocket error: #{event.message}")
105
112
  @status = :error
106
113
  @messages.close
107
-
108
- raise DeadBrowserError, "WebSocket error: #{event.message}"
109
114
  end
110
115
  end
111
116
 
@@ -152,10 +157,25 @@ module Capybara
152
157
  def parse_message(data)
153
158
  JSON.parse(data, max_nesting: false)
154
159
  rescue JSON::ParserError => e
155
- warn "Failed to parse WebSocket message: #{e.message}"
160
+ warn_parse_failure(e.message)
156
161
 
157
162
  nil
158
163
  end
164
+
165
+ # Dedupe identical parse-failure warnings per WebSocket instance.
166
+ # Lightpanda occasionally emits CDP frames that embed a bare
167
+ # `undefined` token (invalid JSON — see upstream-wishlist.md A41)
168
+ # and a complex page reproduces the same frame on every load,
169
+ # which previously flooded test output with one warn per frame.
170
+ # Surface the first occurrence per unique error so the upstream
171
+ # regression stays visible, then suppress repeats.
172
+ def warn_parse_failure(message)
173
+ @parse_warnings ||= {}
174
+ return if @parse_warnings[message]
175
+
176
+ @parse_warnings[message] = true
177
+ warn "Failed to parse WebSocket message: #{message}"
178
+ end
159
179
  end
160
180
  end
161
181
  end
@@ -71,6 +71,11 @@ module Capybara
71
71
  @ws&.close
72
72
  @message_thread&.join(1) || @message_thread&.kill
73
73
  @subscriber.clear
74
+ # Wake any in-flight callers blocked on `pending.value!(timeout)` so
75
+ # they raise DeadBrowserError immediately (via the `@ws.closed?` check
76
+ # in #command) instead of stalling for the full @options.timeout — a
77
+ # 30s freeze per pending command on a dying browser.
78
+ fail_pending_commands
74
79
  @pendings.clear
75
80
  end
76
81
 
@@ -92,6 +97,14 @@ module Capybara
92
97
  @mutex.synchronize { @command_id += 1 }
93
98
  end
94
99
 
100
+ # Resolve every pending IVar with nil so blocked callers fall through
101
+ # `pending.value!(timeout)` immediately. `try_set` is a no-op if the
102
+ # IVar already carries a real response (race-safe against an in-flight
103
+ # handle_message that landed just before close).
104
+ def fail_pending_commands
105
+ @pendings.each_value { |ivar| ivar.try_set(nil) }
106
+ end
107
+
95
108
  def start_message_thread
96
109
  @message_thread = Thread.new do
97
110
  Thread.current.abort_on_exception = true
@@ -77,7 +77,8 @@ module Capybara
77
77
  find { |cookie| cookie.name == name }
78
78
  end
79
79
 
80
- def set(name:, value:, domain: nil, path: "/", secure: false, http_only: false, expires: nil)
80
+ def set(name:, value:, domain: nil, path: "/", secure: false, http_only: false, # rubocop:disable Metrics/ParameterLists
81
+ same_site: nil, expires: nil)
81
82
  params = {
82
83
  name: name,
83
84
  value: value,
@@ -87,6 +88,10 @@ module Capybara
87
88
  }
88
89
 
89
90
  params[:domain] = domain if domain
91
+ # CDP rejects unknown SameSite values; pass through only the canonical
92
+ # spec strings ("Strict" / "Lax" / "None") so YAML noise from a hand-
93
+ # edited file doesn't reach the browser.
94
+ params[:sameSite] = same_site if %w[Strict Lax None].include?(same_site)
90
95
  params[:expires] = expires.to_i if expires
91
96
 
92
97
  browser.command("Network.setCookie", **params)
@@ -123,7 +128,7 @@ module Capybara
123
128
 
124
129
  # set() takes keyword args, but YAML round-trips give us a hash with the
125
130
  # raw CDP keys (camelCase). Normalize and forward.
126
- def restore_cookie(hash)
131
+ def restore_cookie(hash) # rubocop:disable Metrics/PerceivedComplexity
127
132
  attrs = hash.transform_keys(&:to_s)
128
133
  params = {
129
134
  name: attrs["name"],
@@ -133,6 +138,7 @@ module Capybara
133
138
  http_only: attrs["httpOnly"] || false,
134
139
  }
135
140
  params[:domain] = attrs["domain"] if attrs["domain"]
141
+ params[:same_site] = attrs["sameSite"] if attrs["sameSite"]
136
142
  exp = attrs["expires"]
137
143
  params[:expires] = Time.at(exp) if exp.is_a?(Numeric) && exp.positive?
138
144
  set(**params)
@@ -10,7 +10,7 @@ module Capybara
10
10
 
11
11
  attr_reader :app, :options
12
12
 
13
- delegate %i[current_url title] => :browser
13
+ delegate %i[current_url title status_code response_headers] => :browser
14
14
 
15
15
  def initialize(app, options = {})
16
16
  super()
@@ -31,6 +31,22 @@ module Capybara
31
31
  false
32
32
  end
33
33
 
34
+ # Escape hatch to the underlying Browser for callers that need raw CDP
35
+ # access — e.g. Lightpanda's `LP.*` extensions (`getMarkdown`,
36
+ # `getSemanticTree`, `detectForms`, …) that aren't worth exposing through
37
+ # the Capybara DSL. Mirrors `capybara-playwright-driver`'s
38
+ # `with_playwright_page`. Yields the Browser; returns whatever the block
39
+ # returns.
40
+ #
41
+ # driver.with_lightpanda_browser do |browser|
42
+ # browser.page_command("LP.getMarkdown")
43
+ # end
44
+ def with_lightpanda_browser(&block)
45
+ raise ArgumentError, "block must be given" unless block
46
+
47
+ block.call(browser)
48
+ end
49
+
34
50
  def visit(url)
35
51
  @started = true
36
52
  browser.go_to(url)
@@ -189,8 +205,11 @@ module Capybara
189
205
 
190
206
  def save_screenshot(path, **_options)
191
207
  browser.screenshot(path: path)
192
- rescue BinaryError, BinaryNotFoundError
193
- # Browser can't start (e.g., version too old) don't crash teardown
208
+ rescue BinaryError, BinaryNotFoundError, BrowserError, TimeoutError
209
+ # Browser can't start (version too old), is already dead (DeadBrowserError),
210
+ # the CDP call timed out, or returned any other CDP-level error. Teardown
211
+ # screenshots are best-effort — swallow so the real test failure surfaces
212
+ # instead of a "browser already gone" stack trace.
194
213
  nil
195
214
  end
196
215
 
@@ -221,12 +240,15 @@ module Capybara
221
240
  end
222
241
 
223
242
  # Expanded error list for Capybara retry logic (Cuprite pattern).
243
+ # MouseEventFailed is in Cuprite's list, but Lightpanda has no
244
+ # rendering engine and the gem dispatches clicks through JS — the
245
+ # underlying CDP Input.dispatchMouseEvent path doesn't run, so
246
+ # MouseEventFailed is never raised.
224
247
  def invalid_element_errors
225
248
  [
226
249
  NodeNotFoundError,
227
250
  NoExecutionContextError,
228
251
  ObsoleteNode,
229
- MouseEventFailed,
230
252
  ]
231
253
  end
232
254
 
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Escape hatch onto Capybara::Node::Element so users can reach our driver-level
4
+ # Node (and its remote_object_id, `call`, etc.) without going through
5
+ # `element.base`. Mirrors capybara-playwright-driver's
6
+ # `with_playwright_element_handle` (lib/capybara/playwright/node.rb). The is_a?
7
+ # guard keeps the patch safe when both this gem and another Capybara driver
8
+ # are loaded in the same process.
9
+ module Capybara
10
+ module Lightpanda
11
+ module ElementExtension
12
+ def with_lightpanda_node(&block)
13
+ raise ArgumentError, "block must be given" unless block
14
+ raise "#{base.inspect} is not a Capybara::Lightpanda::Node" unless base.is_a?(Capybara::Lightpanda::Node)
15
+
16
+ block.call(base)
17
+ end
18
+ end
19
+ end
20
+ end
21
+ Capybara::Node::Element.prepend(Capybara::Lightpanda::ElementExtension)
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Capybara
4
+ module Lightpanda
5
+ # Hash subclass that downcases the lookup key. CDP returns response headers
6
+ # with lowercased names ("content-type"), but Capybara callers reach for
7
+ # the canonical casing ("Content-Type"). Mirrors capybara-playwright-driver's
8
+ # Headers class (lib/capybara/playwright/page.rb).
9
+ class Headers < Hash
10
+ def [](key)
11
+ super(key.to_s.downcase)
12
+ end
13
+ end
14
+ end
15
+ end
@@ -84,56 +84,44 @@
84
84
  // Page.addScriptToEvaluateOnNewDocument.
85
85
 
86
86
  // Returns true if `el` is visible per Capybara's semantics. Lightpanda's
87
- // UA stylesheet (PR #2294, nightly ≥5918) puts `display:none` on
88
- // HEAD/SCRIPT/STYLE/NOSCRIPT/TEMPLATE/TITLE and `[type=hidden]`, and
89
- // `[hidden]` / closed-<details> children also resolve to `display:none`
90
- // through the cascade — so a single display:none walk catches the
91
- // unrendered-element cases without an explicit tag list. `visibility`
92
- // and offsetParent stay explicit because they aren't covered by display.
93
- // `checkVisibility()` does the parent walk for us when available.
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.
94
92
  //
95
- // `opts.checkOffsetParent` (default true): when true, also rejects
96
- // elements with `offsetParent === null` as a fallback for ancestor
97
- // `display:none`. The visible_text walker passes false because it
98
- // already short-circuits when descending into hidden subtrees.
99
- isVisible: function(el, opts) {
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) {
100
105
  if (!el || el.nodeType !== 1) return false;
101
- var checkOffsetParent = !opts || opts.checkOffsetParent !== false;
102
- var TAG = (el.tagName || '').toUpperCase();
103
-
104
106
  var win = el.ownerDocument.defaultView || window;
105
107
  var style = win.getComputedStyle(el);
106
108
  if (style.visibility === 'hidden' || style.visibility === 'collapse') return false;
107
- if (typeof el.checkVisibility === 'function') {
108
- if (!el.checkVisibility()) return false;
109
- } else {
110
- var node = el;
111
- while (node && node.nodeType === 1) {
112
- if (win.getComputedStyle(node).display === 'none') return false;
113
- node = node.parentElement;
114
- }
115
- }
116
- if (checkOffsetParent && el.offsetParent === null && style.position !== 'fixed' &&
117
- TAG !== 'BODY' && TAG !== 'HTML') return false;
118
- return true;
109
+ return el.checkVisibility();
119
110
  },
120
111
 
121
112
  // Returns true if the element is obscured at its center point — i.e.
122
113
  // hit-testing elementFromPoint at the center returns something that is
123
- // not the element or its descendant. Display:none / visibility:hidden /
124
- // [hidden] short-circuit to true because Lightpanda returns a fake
125
- // bounding rect for display:none elements.
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.
126
120
  isObscured: function(el) {
127
121
  var doc = el.ownerDocument;
128
122
  var win = doc.defaultView || window;
129
123
  var style = win.getComputedStyle(el);
130
- if (style.display === 'none') return true;
131
124
  if (style.visibility === 'hidden' || style.visibility === 'collapse') return true;
132
- var anc = el;
133
- while (anc && anc.nodeType === 1) {
134
- if (anc.hasAttribute && anc.hasAttribute('hidden')) return true;
135
- anc = anc.parentNode;
136
- }
137
125
  var r = el.getBoundingClientRect();
138
126
  if (r.width === 0 || r.height === 0) return true;
139
127
  var cx = r.left + (r.width / 2);
@@ -163,13 +151,12 @@
163
151
  return false;
164
152
  },
165
153
 
166
- // True if the element is the host of a contenteditable region either
167
- // its own isContentEditable is true, or any ancestor has a non-"false"
168
- // `contenteditable` attribute. Lightpanda doesn't expose
169
- // `isContentEditable` on every element, so we walk ancestors as a
170
- // fallback.
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.
171
159
  isContentEditable: function(el) {
172
- if (el.isContentEditable) return true;
173
160
  var n = el;
174
161
  while (n && n.nodeType === 1) {
175
162
  if (n.hasAttribute && n.hasAttribute('contenteditable')) {
@@ -212,7 +199,7 @@
212
199
  return fout;
213
200
  }
214
201
  if (node.nodeType !== 1) return '';
215
- if (!self.isVisible(node, { checkOffsetParent: false })) return '';
202
+ if (!self.isVisible(node)) return '';
216
203
  var tag = (node.tagName || '').toUpperCase();
217
204
  if (tag === 'TEXTAREA') return node.value || '';
218
205
  if (tag === 'BR') return '\n';
@@ -69,6 +69,23 @@ module Capybara
69
69
  shift: 8,
70
70
  }.freeze
71
71
 
72
+ # US-layout shifted variants of the printable ASCII characters that
73
+ # don't just upcase. Used by dispatch_modified_char so
74
+ # send_keys([:shift, "1"]) types "!" instead of "1". Letters fall
75
+ # through to String#upcase via the default branch.
76
+ SHIFT_MAP = {
77
+ "1" => "!", "2" => "@", "3" => "#", "4" => "$", "5" => "%",
78
+ "6" => "^", "7" => "&", "8" => "*", "9" => "(", "0" => ")",
79
+ "-" => "_", "=" => "+", "[" => "{", "]" => "}", "\\" => "|",
80
+ ";" => ":", "'" => "\"", "," => "<", "." => ">", "/" => "?",
81
+ "`" => "~",
82
+ }.freeze
83
+ private_constant :SHIFT_MAP
84
+
85
+ def self.shifted(char)
86
+ SHIFT_MAP[char] || char.upcase
87
+ end
88
+
72
89
  def initialize(browser)
73
90
  @browser = browser
74
91
  end
@@ -155,7 +172,7 @@ module Capybara
155
172
  end
156
173
 
157
174
  def dispatch_modified_char(char, modifier_value, modifiers)
158
- text = modifiers.include?(:shift) ? char.upcase : char
175
+ text = modifiers.include?(:shift) ? self.class.shifted(char) : char
159
176
  send_key_event("keyDown", { key: text, text: text, unmodifiedText: char }, modifiers: modifier_value)
160
177
  send_key_event("keyUp", { key: text }, modifiers: modifier_value)
161
178
  end
@@ -8,6 +8,7 @@ module Capybara
8
8
  def initialize(browser)
9
9
  @browser = browser
10
10
  @traffic = []
11
+ @traffic_mutex = Mutex.new
11
12
  @enabled = false
12
13
  @request_handler = nil
13
14
  @response_handler = nil
@@ -35,11 +36,11 @@ module Capybara
35
36
  end
36
37
 
37
38
  def traffic
38
- @traffic.dup
39
+ @traffic_mutex.synchronize { @traffic.dup }
39
40
  end
40
41
 
41
42
  def clear
42
- @traffic.clear
43
+ @traffic_mutex.synchronize { @traffic.clear }
43
44
  end
44
45
 
45
46
  # Wipe local state without sending Network.disable. Called by
@@ -50,61 +51,89 @@ module Capybara
50
51
  # cleared the Subscriber first.
51
52
  def reset
52
53
  unsubscribe
53
- @traffic.clear
54
+ @traffic_mutex.synchronize { @traffic.clear }
54
55
  @enabled = false
55
56
  end
56
57
 
58
+ # Setting extra headers also lazily enables the Network domain. Without
59
+ # this, headers were silently ignored until the caller separately ran
60
+ # `network.enable` (or `wait_for_network_idle`). Cuprite/Ferrum parity.
57
61
  def headers=(headers)
62
+ enable
58
63
  @extra_headers = headers
59
64
  browser.page_command("Network.setExtraHTTPHeaders", headers: headers)
60
65
  end
61
66
 
62
67
  def add_headers(headers)
68
+ enable
63
69
  @extra_headers = (@extra_headers || {}).merge(headers)
64
70
  browser.page_command("Network.setExtraHTTPHeaders", headers: @extra_headers)
65
71
  end
66
72
 
67
73
  def clear_headers
74
+ enable
68
75
  @extra_headers = {}
69
76
  browser.page_command("Network.setExtraHTTPHeaders", headers: {})
70
77
  end
71
78
 
72
- def wait_for_idle(timeout: 5, connections: 0) # rubocop:disable Naming/PredicateMethod
73
- started_at = Time.now
74
-
75
- while Time.now - started_at < timeout
76
- pending = @traffic.count { |t| t[:response].nil? }
77
- return true if pending <= connections
79
+ # Count of in-flight requests (those with no response yet recorded).
80
+ # Cheap predicate-friendly accessor (ferrum parity).
81
+ def pending_connections
82
+ @traffic_mutex.synchronize { @traffic.count { |t| t[:response].nil? } }
83
+ end
78
84
 
79
- sleep 0.1
80
- end
85
+ # True when no more than `connections` requests are in-flight.
86
+ def idle?(connections = 0)
87
+ pending_connections <= connections
88
+ end
81
89
 
90
+ def wait_for_idle(timeout: 5, connections: 0)
91
+ wait_for_idle!(timeout: timeout, connections: connections)
92
+ rescue TimeoutError
82
93
  false
83
94
  end
84
95
 
96
+ # Raising variant of #wait_for_idle (ferrum parity). Returns true on
97
+ # success, raises TimeoutError on timeout so callers that treat the
98
+ # idle wait as a precondition don't have to remember to check a bool.
99
+ def wait_for_idle!(timeout: 5, connections: 0) # rubocop:disable Naming/PredicateMethod
100
+ Utils::Wait.until(
101
+ timeout: timeout,
102
+ interval: 0.1,
103
+ message: "Network did not become idle within #{timeout}s " \
104
+ "(pending=#{pending_connections}, allowed=#{connections})"
105
+ ) { idle?(connections) }
106
+ true
107
+ end
108
+
85
109
  private
86
110
 
87
111
  def subscribe
112
+ # CDP events arrive on the message thread while wait_for_idle /
113
+ # pending_connections read from the main thread; serialize all
114
+ # @traffic mutations and reads through @traffic_mutex.
88
115
  @request_handler = lambda do |params|
89
- @traffic << {
116
+ entry = {
90
117
  request_id: params["requestId"],
91
118
  url: params.dig("request", "url"),
92
119
  method: params.dig("request", "method"),
93
120
  timestamp: params["timestamp"],
94
121
  response: nil,
95
122
  }
123
+ @traffic_mutex.synchronize { @traffic << entry }
96
124
  end
97
125
 
98
126
  @response_handler = lambda do |params|
99
- request = @traffic.find { |t| t[:request_id] == params["requestId"] }
100
-
101
- next unless request
102
-
103
- request[:response] = {
104
- status: params.dig("response", "status"),
105
- headers: params.dig("response", "headers"),
106
- mime_type: params.dig("response", "mimeType"),
107
- }
127
+ @traffic_mutex.synchronize do
128
+ request = @traffic.find { |t| t[:request_id] == params["requestId"] }
129
+ next unless request
130
+
131
+ request[:response] = {
132
+ status: params.dig("response", "status"),
133
+ headers: params.dig("response", "headers"),
134
+ mime_type: params.dig("response", "mimeType"),
135
+ }
136
+ end
108
137
  end
109
138
 
110
139
  browser.on("Network.requestWillBeSent", &@request_handler)
@@ -14,12 +14,10 @@ module Capybara
14
14
  end
15
15
 
16
16
  def text
17
- ensure_connected
18
17
  call("function() { return this.textContent }")
19
18
  end
20
19
 
21
20
  def all_text
22
- ensure_connected
23
21
  filter_text(call("function() { return this.textContent }"))
24
22
  end
25
23
 
@@ -28,7 +26,6 @@ module Capybara
28
26
  # that fail VISIBLE_JS, and emit newlines around block-display elements
29
27
  # (the part of innerText behavior we still need).
30
28
  def visible_text
31
- ensure_connected
32
29
  call(VISIBLE_TEXT_JS).to_s
33
30
  .gsub(/\A[[:space:]&&[^\u00A0]]+/, "")
34
31
  .gsub(/[[:space:]&&[^\u00A0]]+\z/, "")
@@ -116,6 +113,17 @@ module Capybara
116
113
  call("function() { this.dispatchEvent(new MouseEvent('mouseover', {bubbles: true, cancelable: true})) }")
117
114
  end
118
115
 
116
+ # Lightpanda has no rendering engine — `window.scrollTo` / `scrollIntoView`
117
+ # are no-ops at the browser level, and `getBoundingClientRect` reflects
118
+ # logical-DOM geometry rather than scroll-aware viewport coords. So
119
+ # there's nothing to scroll. Silently succeed so callers like
120
+ # `session.scroll_to(find('#thing'))` (Selenium-flavoured specs leaning
121
+ # on real layout) don't crash with NotImplementedError; assertions that
122
+ # depend on post-scroll visibility are already gated by the cuprite
123
+ # fallback in dual-driver setups.
124
+ def scroll_to(*); end
125
+ def scroll_by(*); end
126
+
119
127
  # Dispatch an arbitrary DOM event by name. Mirrors Cuprite's Node#trigger
120
128
  # — picks the right Event constructor for known mouse/focus/form names
121
129
  # and falls back to a generic Event for everything else (so callers can
@@ -199,6 +207,13 @@ module Capybara
199
207
  call(GET_PATH_JS)
200
208
  end
201
209
 
210
+ # Ancestor chain from `parentNode` up to (but not including) `document`,
211
+ # returned as Lightpanda::Node wrappers. Mirrors Cuprite's `Node#parents`.
212
+ def parents
213
+ oids = driver.browser.parents_of(@remote_object_id)
214
+ oids.map { |oid| self.class.new(driver, oid) }
215
+ end
216
+
202
217
  def find_xpath(selector)
203
218
  object_ids = driver.browser.find_within(@remote_object_id, "xpath", selector)
204
219
  object_ids.map { |oid| self.class.new(driver, oid) }
@@ -243,19 +258,6 @@ module Capybara
243
258
 
244
259
  private
245
260
 
246
- # Capybara's `automatic_reload` re-runs the original query when an element
247
- # access raises one of the driver's `invalid_element_errors`. After a DOM
248
- # mutation like `replaceWith`, our cached objectId still resolves to the
249
- # detached node, so reads succeed (with stale data) and the auto-reload
250
- # never fires. Detect detachment via `isConnected` and raise so the
251
- # synchronize-loop notices and triggers a re-find.
252
- def ensure_connected
253
- connected = call("function() { return this.isConnected }")
254
- return if connected
255
-
256
- raise ObsoleteNode.new(self, "Node is no longer attached to the document")
257
- end
258
-
259
261
  def implicit_submit
260
262
  call(IMPLICIT_SUBMIT_JS)
261
263
  driver.browser.wait_for_idle
@@ -277,6 +279,10 @@ module Capybara
277
279
  call(SET_VALUE_JS, format_time_value(value))
278
280
  when "datetime-local"
279
281
  call(SET_VALUE_JS, format_datetime_value(value))
282
+ when "month"
283
+ call(SET_VALUE_JS, format_month_value(value))
284
+ when "week"
285
+ call(SET_VALUE_JS, format_week_value(value))
280
286
  else
281
287
  fill_text_input(type, value.to_s)
282
288
  end
@@ -316,6 +322,22 @@ module Capybara
316
322
  value.to_time.strftime("%Y-%m-%dT%H:%M")
317
323
  end
318
324
 
325
+ def format_month_value(value)
326
+ return value.to_s if value.is_a?(String) || !value.respond_to?(:to_date)
327
+
328
+ value.to_date.strftime("%Y-%m")
329
+ end
330
+
331
+ # ISO 8601 week-of-year, "%G" giving the ISO week-numbering year so that
332
+ # the last days of December that belong to week 1 of the next year are
333
+ # rendered with the correct year. Matches Cuprite's `Node#set` for week
334
+ # inputs and what the user would type into a `<input type=week>` field.
335
+ def format_week_value(value)
336
+ return value.to_s if value.is_a?(String) || !value.respond_to?(:to_date)
337
+
338
+ value.to_date.strftime("%G-W%V")
339
+ end
340
+
319
341
  # `maxlength` only constrains user typing, not direct value assignment, but
320
342
  # Selenium-style drivers truncate to match what a user would have ended up
321
343
  # with. Honor it explicitly so Capybara-shared specs behave the same.
@@ -345,24 +367,41 @@ module Capybara
345
367
  # JS bodies may reference `_lightpanda.*` helpers — they're registered via
346
368
  # Page.addScriptToEvaluateOnNewDocument in every document (top frame and
347
369
  # iframes alike), so the namespace is available wherever `this` lives.
370
+ #
371
+ # The supplied declaration is wrapped with an `isConnected` guard so a
372
+ # detached node raises ObsoleteNode (in Driver#invalid_element_errors)
373
+ # instead of returning stale data from V8's still-live reference. After
374
+ # a DOM mutation like `replaceWith`, the cached objectId still resolves
375
+ # to the detached node, so reads succeed quietly and Capybara's
376
+ # automatic_reload never re-runs the original query.
348
377
  def call(function_declaration, *args)
378
+ guarded = wrap_with_attached_guard(function_declaration)
349
379
  driver.browser.with_default_context_wait do
350
- driver.browser.call_function_on(@remote_object_id, function_declaration, *args)
380
+ driver.browser.call_function_on(@remote_object_id, guarded, *args)
351
381
  end
352
382
  rescue JavaScriptError => e
383
+ if e.message.include?(OBSOLETE_NODE_MARKER)
384
+ raise ObsoleteNode.new(self, "Node is no longer attached to the document")
385
+ end
386
+
353
387
  case e.class_name
354
388
  when "InvalidSelector"
355
389
  raise InvalidSelector.new(e.message, nil, args.first)
356
390
  else
357
391
  raise
358
392
  end
359
- rescue BrowserError => e
360
- case e.message
361
- when /MouseEventFailed/i
362
- raise MouseEventFailed.new(self, e.response&.dig("message"))
363
- else
364
- raise
365
- end
393
+ end
394
+
395
+ OBSOLETE_NODE_MARKER = "LIGHTPANDA_OBSOLETE_NODE"
396
+ private_constant :OBSOLETE_NODE_MARKER
397
+
398
+ def wrap_with_attached_guard(function_declaration)
399
+ <<~JS
400
+ function() {
401
+ if (!this.isConnected) throw new Error(#{OBSOLETE_NODE_MARKER.inspect});
402
+ return (#{function_declaration}).apply(this, arguments);
403
+ }
404
+ JS
366
405
  end
367
406
 
368
407
  # We dispatch a `MouseEvent` (not a generic `Event`) because Turbo's link