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.
@@ -58,6 +58,8 @@ module Capybara
58
58
  end
59
59
  end
60
60
 
61
+ # Test seam: no production caller, but subscriber_test asserts
62
+ # unsubscribe/clear semantics through it.
61
63
  def subscribed?(event)
62
64
  @mutex.synchronize { @subscriptions.key?(event) && @subscriptions[event].any? }
63
65
  end
@@ -4,6 +4,8 @@ require "json"
4
4
  require "socket"
5
5
  require "websocket/driver"
6
6
 
7
+ require_relative "../utils/attempt"
8
+
7
9
  module Capybara
8
10
  module Lightpanda
9
11
  class Client
@@ -36,7 +38,7 @@ module Capybara
36
38
 
37
39
  @status = :closing
38
40
  @messages.close
39
- @driver&.close
41
+ @driver_mutex.synchronize { @driver&.close }
40
42
  @thread&.join(1) || @thread&.kill
41
43
  @socket&.close
42
44
  @status = :closed
@@ -46,15 +48,10 @@ module Capybara
46
48
  @status == :closed || @status == :error
47
49
  end
48
50
 
49
- def open?
50
- @status == :open
51
- end
52
-
53
51
  def write(data)
54
52
  @socket.write(data)
55
53
  rescue Errno::EPIPE, Errno::ECONNRESET, IOError
56
- @status = :closed
57
- @messages.close
54
+ mark_dead
58
55
  end
59
56
 
60
57
  private
@@ -73,13 +70,9 @@ module Capybara
73
70
  start_reader_thread
74
71
  end
75
72
 
76
- def connect_with_retry(host, port, retries: 10, delay: 0.1)
77
- retries.times do |i|
78
- return TCPSocket.new(host, port)
79
- rescue Errno::ECONNREFUSED
80
- raise if i == retries - 1
81
-
82
- sleep delay
73
+ def connect_with_retry(host, port)
74
+ Utils::Attempt.with_retry(errors: Errno::ECONNREFUSED, max: 10, wait: 0.1) do
75
+ TCPSocket.new(host, port)
83
76
  end
84
77
  end
85
78
 
@@ -97,8 +90,7 @@ module Capybara
97
90
  end
98
91
 
99
92
  @driver.on(:close) do
100
- @status = :closed
101
- @messages.close
93
+ mark_dead
102
94
  end
103
95
 
104
96
  @driver.on(:error) do |event|
@@ -109,8 +101,7 @@ module Capybara
109
101
  # process. Mark the connection dead and let Client#command
110
102
  # surface DeadBrowserError on its next dispatch via closed?.
111
103
  @logger&.puts("✗ WebSocket error: #{event.message}")
112
- @status = :error
113
- @messages.close
104
+ mark_dead(:error)
114
105
  end
115
106
  end
116
107
 
@@ -127,8 +118,7 @@ module Capybara
127
118
  data = @socket.readpartial(4096)
128
119
  @driver_mutex.synchronize { @driver.parse(data) }
129
120
  rescue Errno::ECONNRESET, Errno::EPIPE, IOError
130
- @status = :closed
131
- @messages.close
121
+ mark_dead
132
122
  break
133
123
  end
134
124
  end
@@ -144,7 +134,7 @@ module Capybara
144
134
  begin
145
135
  data = @socket.readpartial(4096)
146
136
  @driver.parse(data)
147
- rescue EOFError
137
+ rescue Errno::ECONNRESET, Errno::EPIPE, IOError # IOError covers EOFError
148
138
  raise DeadBrowserError, "Connection closed during handshake"
149
139
  end
150
140
  end
@@ -154,6 +144,14 @@ module Capybara
154
144
  raise TimeoutError, "WebSocket handshake timed out after #{@options.handshake_timeout}s"
155
145
  end
156
146
 
147
+ # Single home for the "dead implies queue closed" invariant: every
148
+ # path that gives up on the connection must close @messages so the
149
+ # Client message thread's blocking pop returns.
150
+ def mark_dead(status = :closed)
151
+ @status = status
152
+ @messages.close
153
+ end
154
+
157
155
  def parse_message(data)
158
156
  JSON.parse(data, max_nesting: false)
159
157
  rescue JSON::ParserError => e
@@ -9,10 +9,7 @@ require_relative "client/subscriber"
9
9
  module Capybara
10
10
  module Lightpanda
11
11
  class Client
12
- attr_reader :ws_url, :options
13
-
14
12
  def initialize(ws_url, options)
15
- @ws_url = ws_url
16
13
  @options = options
17
14
  @ws = WebSocket.new(ws_url, options)
18
15
  @command_id = 0
@@ -118,7 +115,11 @@ module Capybara
118
115
  def handle_message(message)
119
116
  if message["id"]
120
117
  pending = @pendings[message["id"]]
121
- pending&.set(message)
118
+ # try_set, not set: a duplicate frame for an already-answered id
119
+ # (Lightpanda emits occasional malformed/duplicate frames — see
120
+ # upstream-wishlist.md A41) would raise MultipleAssignmentError on
121
+ # this thread, and abort_on_exception would kill the whole process.
122
+ pending&.try_set(message)
122
123
  elsif message["method"]
123
124
  @subscriber.dispatch(message["method"], message["params"])
124
125
  end
@@ -10,7 +10,7 @@ module Capybara
10
10
 
11
11
  attr_reader :app, :options
12
12
 
13
- delegate %i[current_url title status_code response_headers] => :browser
13
+ delegate %i[current_url title status_code response_headers frame_url frame_title] => :browser
14
14
 
15
15
  def initialize(app, options = {})
16
16
  super()
@@ -26,9 +26,7 @@ module Capybara
26
26
  end
27
27
 
28
28
  def browser_alive?
29
- @browser.client && !@browser.client.closed?
30
- rescue StandardError
31
- false
29
+ !@browser.nil? && @browser.alive?
32
30
  end
33
31
 
34
32
  # Escape hatch to the underlying Browser for callers that need raw CDP
@@ -160,42 +158,20 @@ module Capybara
160
158
  end
161
159
  end
162
160
 
163
- # Capybara::Driver::Base falls back to running these via the top
164
- # execution context, which always reports the parent document. Resolve
165
- # them through the iframe element's contentWindow / contentDocument so
166
- # they reflect the active frame.
167
- def frame_url
168
- frame = browser.frame_stack.last
169
- return browser.current_url unless frame
170
-
171
- browser.call_function_on(frame.remote_object_id,
172
- "function() { return this.contentWindow.location.href }")
173
- end
174
-
175
- def frame_title
176
- frame = browser.frame_stack.last
177
- return browser.title unless frame
178
-
179
- browser.call_function_on(frame.remote_object_id,
180
- "function() { return this.contentDocument.title }")
181
- end
182
-
183
161
  # -- Modal/Dialog Support --
184
162
 
163
+ # find_modal owns the wait default (browser.options.timeout) — pass
164
+ # wait only when the caller overrode it.
185
165
  def accept_modal(type, **options, &block)
186
166
  browser.accept_modal(type, text: options[:with])
187
167
  block&.call
188
- browser.find_modal(type,
189
- text: options[:text],
190
- wait: options.fetch(:wait, browser.options.timeout))
168
+ browser.find_modal(type, **{ text: options[:text], wait: options[:wait] }.compact)
191
169
  end
192
170
 
193
171
  def dismiss_modal(type, **options, &block)
194
172
  browser.dismiss_modal(type)
195
173
  block&.call
196
- browser.find_modal(type,
197
- text: options[:text],
198
- wait: options.fetch(:wait, browser.options.timeout))
174
+ browser.find_modal(type, **{ text: options[:text], wait: options[:wait] }.compact)
199
175
  end
200
176
 
201
177
  # -- Screenshots --
@@ -241,12 +217,20 @@ module Capybara
241
217
  # Thin Cuprite-style wrapper. The interesting work — disposing the
242
218
  # BrowserContext (cookies, storage, all targets) and starting a fresh
243
219
  # one — happens in Browser#reset.
220
+ #
221
+ # Rescue is the gem hierarchy plus raw IO escapees only — NOT
222
+ # StandardError: reset! runs between every test, so a blanket rescue
223
+ # turns programmer errors (e.g. a NoMethodError in browser.rb) into a
224
+ # silent quit-and-respawn on every example with zero signal. The warn
225
+ # keeps repeated respawns visible.
244
226
  def reset!
245
227
  browser.reset
246
- @started = false
247
- rescue StandardError
228
+ rescue Error, SystemCallError, IOError => e
229
+ warn "[capybara-lightpanda] reset! failed (#{e.class}: #{e.message}); respawning browser"
248
230
  @browser&.quit
249
231
  @browser = nil
232
+ ensure
233
+ @started = false
250
234
  end
251
235
 
252
236
  def quit
@@ -315,14 +299,15 @@ module Capybara
315
299
  end
316
300
 
317
301
  # Walk through evaluate-script results turning DOM-node markers (the
318
- # `{ "__lightpanda_node__" => "..." }` hashes produced by `Browser#unwrap_call_result`)
319
- # into Lightpanda::Node instances so Capybara can wrap them as elements.
302
+ # `{ Browser::NODE_MARKER => "..." }` hashes produced by
303
+ # `Browser#unwrap_call_result`) into Lightpanda::Node instances so
304
+ # Capybara can wrap them as elements.
320
305
  def unwrap_script_result(value)
321
306
  case value
322
307
  when Array then value.map { |v| unwrap_script_result(v) }
323
308
  when Hash
324
- if value.size == 1 && value.key?("__lightpanda_node__")
325
- Node.new(self, value["__lightpanda_node__"])
309
+ if value.size == 1 && value.key?(Browser::NODE_MARKER)
310
+ Node.new(self, value[Browser::NODE_MARKER])
326
311
  else
327
312
  value.transform_values { |v| unwrap_script_result(v) }
328
313
  end
@@ -5,6 +5,9 @@ module Capybara
5
5
  class Error < StandardError; end
6
6
 
7
7
  class ProcessTimeoutError < Error; end
8
+ # Subclass so external `rescue ProcessTimeoutError` keeps catching it;
9
+ # Process#start dispatches on the class instead of message matching.
10
+ class PortInUseError < ProcessTimeoutError; end
8
11
  class BinaryNotFoundError < Error; end
9
12
  class BinaryError < Error; end
10
13
  class UnsupportedPlatformError < Error; end
@@ -88,6 +91,22 @@ module Capybara
88
91
  end
89
92
  end
90
93
 
94
+ class InvalidSelector < Error
95
+ attr_reader :method, :selector
96
+
97
+ def initialize(message, method = nil, selector = nil)
98
+ @method = method
99
+ @selector = selector
100
+ super(message)
101
+ end
102
+ end
103
+
104
+ # --- Cuprite/Ferrum drop-in compatibility surface ---
105
+ # This gem never raises the three classes below (no coordinate-based
106
+ # mouse path, no multi-page API, no page-status tracking). They mirror
107
+ # the peer taxonomy (Cuprite's MouseEventFailed, Ferrum's
108
+ # NoSuchPageError/StatusError) so suites migrating from those drivers
109
+ # don't NameError on rescue lists that reference them.
91
110
  class MouseEventFailed < BrowserError
92
111
  attr_reader :node, :selector, :position
93
112
 
@@ -103,16 +122,6 @@ module Capybara
103
122
  end
104
123
  end
105
124
 
106
- class InvalidSelector < Error
107
- attr_reader :method, :selector
108
-
109
- def initialize(message, method = nil, selector = nil)
110
- @method = method
111
- @selector = selector
112
- super(message)
113
- end
114
- end
115
-
116
125
  class NoSuchPageError < Error; end
117
126
  class StatusError < Error; end
118
127
  end
@@ -0,0 +1,16 @@
1
+ // --- Public surface ---
2
+ // The one place that names window._lightpanda. References the function/var
3
+ // declarations from turbo.js and predicates.js (hoisted into the same IIFE
4
+ // scope by AutoScripts). Runs last in the concatenation order.
5
+ window._lightpanda = {
6
+ turbo: {
7
+ pending: function() { return _pendingTurboOps; },
8
+ idle: function() { return _pendingTurboOps <= 0; }
9
+ },
10
+
11
+ isVisible: isVisible,
12
+ isObscured: isObscured,
13
+ isDisabled: isDisabled,
14
+ isContentEditable: isContentEditable,
15
+ visibleText: visibleText
16
+ };
@@ -0,0 +1,15 @@
1
+ // _lightpanda auto-injected bundle. Assembled by AutoScripts (auto_scripts.rb)
2
+ // from the sibling source files in this directory and registered once per
3
+ // session via Page.addScriptToEvaluateOnNewDocument, so it runs in every
4
+ // document — top frame and every iframe.
5
+ //
6
+ // Each source file is a plain sequence of declarations/statements: no IIFE,
7
+ // no `export`/`import`/`require`. AutoScripts wraps them in the IIFE and the
8
+ // `window._lightpanda` idempotency guard. Keeping the files free of module
9
+ // syntax means they parse identically as a classic browser script and as a
10
+ // `new Function(...)` body — which is exactly how the Bun harness (test/js/)
11
+ // loads predicates.js to test them without a build step.
12
+ //
13
+ // Concatenation order (set in auto_scripts.rb): banner, turbo, predicates,
14
+ // attach. `attach.js` reads the names defined by `turbo.js`/`predicates.js`,
15
+ // so it must come last.
@@ -0,0 +1,152 @@
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
+ // 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.
113
+ 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;
149
+ }
150
+
151
+ return walk(el);
152
+ }
@@ -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)