capybara-simulated 0.0.6 → 0.1.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 (35) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +303 -158
  3. data/lib/capybara/simulated/asset_cache.rb +232 -0
  4. data/lib/capybara/simulated/browser.rb +3409 -845
  5. data/lib/capybara/simulated/driver.rb +341 -134
  6. data/lib/capybara/simulated/errors.rb +9 -5
  7. data/lib/capybara/simulated/js/bridge.bundle.js +19409 -0
  8. data/lib/capybara/simulated/js/snapshot_stubs.js +110 -0
  9. data/lib/capybara/simulated/node.rb +151 -163
  10. data/lib/capybara/simulated/quickjs_runtime.rb +424 -0
  11. data/lib/capybara/simulated/runtime_shared.rb +183 -0
  12. data/lib/capybara/simulated/script_cache.rb +168 -0
  13. data/lib/capybara/simulated/sourcemap.rb +119 -0
  14. data/lib/capybara/simulated/stack_resolver.rb +97 -0
  15. data/lib/capybara/simulated/trace.rb +111 -0
  16. data/lib/capybara/simulated/v8_runtime.rb +987 -0
  17. data/lib/capybara/simulated/version.rb +3 -1
  18. data/lib/capybara/simulated/webauthn_state.rb +367 -0
  19. data/lib/capybara/simulated/whitespace_normalizer.rb +45 -0
  20. data/lib/capybara/simulated/worker_runtime.rb +30 -0
  21. data/lib/capybara/simulated.rb +31 -4
  22. data/lib/capybara-simulated.rb +2 -0
  23. data/vendor/js/vendor.bundle.js +13 -0
  24. metadata +24 -32
  25. data/vendor/esbuild-wasm/LICENSE.md +0 -21
  26. data/vendor/esbuild-wasm/bin/esbuild +0 -91
  27. data/vendor/esbuild-wasm/esbuild.wasm +0 -0
  28. data/vendor/esbuild-wasm/lib/main.js +0 -2337
  29. data/vendor/esbuild-wasm/wasm_exec.js +0 -575
  30. data/vendor/esbuild-wasm/wasm_exec_node.js +0 -40
  31. data/vendor/js/bundle-modules.mjs +0 -168
  32. data/vendor/js/csim.bundle.js +0 -91560
  33. data/vendor/js/entry.mjs +0 -23
  34. data/vendor/js/prelude.js +0 -186
  35. data/vendor/js/runtime.js +0 -2174
@@ -1,191 +1,398 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'capybara/driver/base'
4
+ require 'weakref'
5
+ require_relative 'browser'
6
+ require_relative 'node'
2
7
 
3
8
  module Capybara
4
9
  module Simulated
10
+ # User-intent `sleep(n)` from a test forwards to the active driver
11
+ # so any `setTimeout(n')` callbacks the user is waiting on fire on
12
+ # the next tick. The JS clock is otherwise wall-clock-independent
13
+ # for determinism; this is the bridge that lets `reload`-style
14
+ # specs still pace via `sleep`.
15
+ #
16
+ # Capybara's internal `sleep(retry_interval)` is 10–50 ms and
17
+ # doesn't represent test-author intent; forwarding it would add
18
+ # per-poll drain overhead. Test pacing sleeps (e.g. `sleep(0.3)`)
19
+ # land above the threshold.
20
+ USER_SLEEP_THRESHOLD_S = 0.1
21
+ module SleepHook
22
+ def sleep(seconds = nil)
23
+ return super if seconds.nil?
24
+ n = super
25
+ if seconds.to_f >= Capybara::Simulated::USER_SLEEP_THRESHOLD_S
26
+ # Broadcast to every Driver constructed on the current
27
+ # thread. Background threads (`MessageBus::TimerThread`,
28
+ # etc.) sleep too, but their Drivers — if any — were
29
+ # registered under a different thread, so they skip; the
30
+ # filter is load-bearing because rusty_racer / quickjs.rb
31
+ # VMs aren't thread-safe. Idle Drivers no-op
32
+ # (`tick_real_time` short-circuits when `@timers_active`
33
+ # is false), so the broadcast is cheap.
34
+ ms = (seconds.to_f * 1000).to_i
35
+ Capybara::Simulated::Driver.each_live_on_thread(Thread.current) {|d|
36
+ d.browser.advance_virtual_clock_ms(ms)
37
+ }
38
+ end
39
+ n
40
+ end
41
+ end
42
+ Kernel.prepend(SleepHook)
43
+
5
44
  class Driver < Capybara::Driver::Base
6
- DEFAULT_WINDOW_HANDLE = 'main'.freeze
45
+ attr_reader :app, :owner_thread
7
46
 
8
- attr_reader :app
47
+ @@live_lock = Mutex.new
48
+ @@live = [] # [WeakRef<Driver>] — dead refs filtered on read.
9
49
 
10
- def initialize(app)
11
- @app = app
50
+ def self.each_live_on_thread(thread)
51
+ drivers = @@live_lock.synchronize {
52
+ @@live.select!(&:weakref_alive?)
53
+ @@live.filter_map {|ref| ref.__getobj__ rescue nil }
54
+ }
55
+ drivers.each {|d| yield d if d.owner_thread == thread }
12
56
  end
13
57
 
14
- def browser
15
- @browser ||= begin
16
- b = Browser.new(app)
17
- b.driver_for_results = self
18
- b
19
- end
58
+ # `viewport: [w, h]` and `user_agent:` (typically supplied via
59
+ # `Capybara.register_driver`) force the JS-side
60
+ # `innerWidth`/`innerHeight` and `navigator.userAgent` (plus
61
+ # `HTTP_USER_AGENT` on Rack requests) before the first navigate,
62
+ # so `matchMedia` / mobile-breakpoint branches and server-side
63
+ # UA-based mobile detection both resolve before any document
64
+ # loads. The Browser tracks both as "defaults" so `reset!`
65
+ # (per-test teardown) restores them between specs.
66
+ def initialize(app, js_engine: nil, viewport: nil, user_agent: nil)
67
+ @app = app
68
+ @js_engine = js_engine
69
+ # Cookies + localStorage are origin-shared across windows
70
+ # (real browser semantics), so we own the jars at the Driver
71
+ # level and inject them into every per-window Browser. Each
72
+ # Browser still has its own sessionStorage + DOM + JS VM.
73
+ @cookies = {}
74
+ @local_storage = {}
75
+ @browser = build_window_browser
76
+ @aux_windows = [] # [{handle:, browser:}, …]
77
+ @active_handle = nil
78
+ @next_window_seq = 0
79
+ @owner_thread = Thread.current
80
+ @@live_lock.synchronize { @@live << WeakRef.new(self) }
81
+ @browser.default_viewport = viewport if viewport
82
+ @browser.default_user_agent = user_agent if user_agent
20
83
  end
21
84
 
22
- def needs_server? = false
23
- def javascript_enabled? = true
24
- # Capybara's synchronize loop will retry on ElementNotFound only
25
- # when the driver opts into waiting. Even though our DOM updates
26
- # happen in-process, async setTimeout-style behaviours need
27
- # Capybara to give them time.
28
- def wait? = true
29
-
30
- def visit(path) = browser.visit(path)
31
- def refresh = browser.refresh
32
- def go_back = browser.go_back
33
- def go_forward = browser.go_forward
34
- def current_url = browser.current_url
35
- def html = browser.html
36
- def title = browser.title
37
- def status_code = browser.status_code
38
- def response_headers = browser.response_headers || {}
39
-
40
- def find_xpath(query, **_)
41
- browser.find_xpath(query).map {|id| Node.new(self, id) }
85
+ private def build_window_browser
86
+ Browser.new(@app,
87
+ driver: self,
88
+ js_engine: @js_engine,
89
+ cookies: @cookies,
90
+ local_storage: @local_storage)
42
91
  end
43
92
 
44
- def find_css(query, **_)
45
- browser.find_css(query).map {|id| Node.new(self, id) }
46
- end
93
+ # Per-test trace recording. Mirrors capybara-playwright-driver's
94
+ # `start_tracing` / `stop_tracing` shape so suites can swap
95
+ # drivers without rewriting hooks.
96
+ def start_tracing(**metadata) = browser.start_trace(metadata)
47
97
 
48
- def execute_script(script, *args)
49
- browser.execute_script(script, args)
98
+ def stop_tracing(path: nil)
99
+ active = current_trace or return nil
100
+ result = path ? browser.finish_trace_to(path, active) : active
101
+ browser.clear_trace!
102
+ result
50
103
  end
51
104
 
52
- def evaluate_script(script, *args)
53
- browser.evaluate_script(script, args)
54
- end
105
+ def tracing? = !current_trace.nil?
106
+ def current_trace = browser.trace || browser.pending_trace
55
107
 
56
- def evaluate_async_script(script, *args)
57
- browser.evaluate_async_script(script, args)
108
+ attr_reader :browser
109
+
110
+ # Active window's Browser. Primary by default; switches when the
111
+ # test calls `switch_to_window(aux_handle)`. Every DOM / URL /
112
+ # JS-touching driver method routes through here so per-window
113
+ # state (DOM, sessionStorage, history) stays window-scoped.
114
+ def current_browser
115
+ return @browser unless @active_handle
116
+ w = @aux_windows.find {|win| win[:handle] == @active_handle }
117
+ w ? w[:browser] : @browser
58
118
  end
59
119
 
60
- def send_keys(*keys)
61
- # Capybara calls session-level `send_keys` to drive global key
62
- # navigation (typically `:tab`). Even when nothing has been
63
- # focused yet, `<body>` is a valid sink so Tab handling can
64
- # advance to the first focusable descendant.
65
- handle_id = browser.active_element || browser.find_css('body').first
66
- return unless handle_id
67
- browser.send_keys(handle_id, keys)
120
+ def needs_server? = false
121
+ def javascript_enabled? = true
122
+
123
+ # Playwright-driver compatibility shim. Discourse's system-spec
124
+ # `before(:each)` calls `page.driver.with_playwright_page` to
125
+ # install a JS-console logger, apply a CDP `setTimezoneOverride`,
126
+ # and (in dev_tools_spec) evaluate `window.enableDevTools()`.
127
+ # Yield a `FakePlaywrightPage` that delegates `evaluate(js)` to
128
+ # our JS engine and silently no-ops every other Playwright-only
129
+ # method via `method_missing → self`. Chained accessors like
130
+ # `pw.context.new_cdp_session(pw).send_message("…")` therefore
131
+ # propagate as a no-op rather than NoMethodError, while
132
+ # `pw.evaluate("…")` runs the JS where it matters.
133
+ def with_playwright_page
134
+ yield FakePlaywrightPage.new(current_browser) if block_given?
68
135
  end
69
136
 
70
- def active_element
71
- handle_id = browser.active_element
72
- Node.new(self, handle_id) if handle_id
137
+ class FakePlaywrightPage
138
+ def initialize(browser) = (@browser = browser)
139
+ # Playwright's `page.evaluate` takes either a string expression
140
+ # or a function literal — when given a function it calls it
141
+ # and returns the result. The simulated driver's underlying
142
+ # `evaluate_script` just runs the source as an expression, so
143
+ # a function-literal payload would return the function object
144
+ # instead of its return value. Wrap arrow-function-shaped
145
+ # bodies in `(...)()` so the function is invoked and the test
146
+ # sees its result.
147
+ def evaluate(js, *)
148
+ src = js.to_s.strip
149
+ src = "(#{src})()" if src.match?(/\A(\(?\s*(async\s+)?(\(.*?\)|\w+)\s*=>|\(?\s*(async\s+)?function\s*\*?\s*\()/m)
150
+ @browser.evaluate_script(src)
151
+ end
152
+ # `pw_page.locator(selector)` returns a Locator that proxies
153
+ # click / fill / count / etc. through Capybara's current
154
+ # session. Discourse's SelectKit / system_helpers `locator`
155
+ # method drives the suspend / silence / penalize / dropdown
156
+ # chains via `pw_page.locator(...).click` — without a real
157
+ # locator the click is a no-op and the modal never advances.
158
+ def locator(selector) = FakePlaywrightLocator.new(selector)
159
+ def respond_to_missing?(*) = true
160
+ # Yield to the block when one is given so Playwright methods
161
+ # whose semantics live entirely in their block (the canonical
162
+ # case is `pw_page.expect_download { click_link "…" }` —
163
+ # `expect_download` arms a download watcher, *then* runs the
164
+ # block, *then* awaits the watcher). Returning `self` from a
165
+ # block-taking method-missing would skip the block entirely
166
+ # and the download never triggers. Pass the receiver in as
167
+ # the block argument so chained `|d| d.suggested_filename`
168
+ # readers see a no-op object.
169
+ def method_missing(*)
170
+ yield self if block_given?
171
+ self
172
+ end
73
173
  end
74
174
 
75
- def save_screenshot(path, **_options)
76
- File.write(path, html)
77
- path
175
+ class FakePlaywrightLocator
176
+ def initialize(selector, scope = nil)
177
+ @selector = selector
178
+ @scope = scope
179
+ end
180
+ def locator(child) = FakePlaywrightLocator.new(child, self)
181
+ def click = node.click
182
+ def fill(value) = node.set(value)
183
+ def click_via_js = node.click
184
+ def count = nodes.size
185
+ def first = FakePlaywrightLocator.new("#{@selector}:first-of-type", @scope)
186
+ def text_content = node.text
187
+ def inner_text = node.text
188
+ def visible? = node.visible?
189
+ def hover = node.hover
190
+ def press(key) = node.send_keys(key)
191
+ def get_attribute(name) = node[name]
192
+ def all = nodes.each_with_index.map {|_, i| FakePlaywrightLocator.new("#{@selector}:nth-of-type(#{i + 1})", @scope) }
193
+ private
194
+ def session = Capybara.current_session
195
+ def context = @scope ? @scope.send(:node) : session
196
+ def node = context.find(:css, @selector)
197
+ def nodes = context.all(:css, @selector)
78
198
  end
199
+ # Dynamic wait?: only poll when there's pending timer work that
200
+ # real-time advancement could resolve. With no timers queued,
201
+ # polling can't change anything, so we fail fast via the
202
+ # `wait? = false` synchronize path.
203
+ def wait? = current_browser.polling?
79
204
 
205
+ def visit(path) = current_browser.visit(path)
206
+ def refresh = current_browser.refresh
80
207
  def reset!
81
- return unless @browser
82
- @browser.reset_state!
208
+ @aux_windows.each {|w| w[:browser].dispose rescue nil }
209
+ @aux_windows.clear
210
+ @active_handle = nil
211
+ browser.reset!
83
212
  end
213
+ def go_back = current_browser.go_back
214
+ def go_forward = current_browser.go_forward
215
+ def current_url = current_browser.current_url || ''
216
+ def html = current_browser.html
217
+ def title = current_browser.title
218
+ def status_code = current_browser.status_code
219
+ def response_headers = current_browser.response_headers
220
+ def header(name, value) = current_browser.set_header(name, value)
84
221
 
85
- def invalid_element_errors
86
- [Capybara::Simulated::StaleElementReferenceError]
222
+ def find_xpath(query, **_)
223
+ current_browser.find_xpath(query).map {|id| Node.new(self, id) }
87
224
  end
88
225
 
89
- def no_such_window_error
90
- Capybara::WindowError
226
+ def find_css(query, **_)
227
+ current_browser.find_css(query).map {|id| Node.new(self, id) }
91
228
  end
92
229
 
93
- def current_window_handle = DEFAULT_WINDOW_HANDLE
94
- def window_handles = [DEFAULT_WINDOW_HANDLE]
95
- def open_new_window
96
- raise NotImplementedError, 'capybara-simulated supports a single window'
230
+ # Per-window Browser/VM. `open_aux_window` creates a fresh
231
+ # Browser sharing the Driver's cookie + localStorage jars
232
+ # (origin-shared in real browsers) and visits the target URL;
233
+ # `switch_to_window` flips `@active_handle` so subsequent driver
234
+ # ops route through `current_browser`. sessionStorage + DOM +
235
+ # history + the JS VM stay per-window.
236
+ PRIMARY_HANDLE = 'csim-window-0'
237
+ def current_window_handle = @active_handle || PRIMARY_HANDLE
238
+ def window_handles
239
+ [PRIMARY_HANDLE] + @aux_windows.map {|w| w[:handle] }
97
240
  end
98
- def close_window(_handle)
99
- raise NotImplementedError, 'capybara-simulated supports a single window'
241
+ def open_aux_window(url = nil)
242
+ @next_window_seq += 1
243
+ handle = "csim-window-#{@next_window_seq}"
244
+ aux = build_window_browser
245
+ aux.visit(url) if url && !url.empty?
246
+ @aux_windows << {handle: handle, browser: aux}
247
+ handle
248
+ rescue StandardError => e
249
+ # Aux window URL-load failure (binary content, network error,
250
+ # …) shouldn't tear down the test — record the handle so
251
+ # `window_opened_by` succeeds; within_window assertions on
252
+ # `current_url` may still pass through whatever `visit`
253
+ # managed to set before raising.
254
+ warn "[csim] open_aux_window(#{url.inspect}) raised: #{e.class}: #{e.message[0, 200]}"
255
+ @aux_windows << {handle: handle, browser: aux}
256
+ handle
100
257
  end
101
- def switch_to_window(handle)
102
- return if handle == DEFAULT_WINDOW_HANDLE || handle.respond_to?(:handle) && handle.handle == DEFAULT_WINDOW_HANDLE
103
- raise Capybara::WindowError, "no such window: #{handle.inspect}"
104
- end
105
-
106
- def window_size(_handle) = [1024, 768]
107
- def resize_window_to(_h, _w, _h2); end
108
- def maximize_window(_handle); end
109
- def fullscreen_window(_handle); end
110
258
 
111
- def switch_to_frame(_frame)
112
- raise NotImplementedError, 'frames are not supported by capybara-simulated'
259
+ # Capybara `Session#open_new_window(:tab)` entry point — visits
260
+ # `about:blank` so the test can `switch_to_window` then `visit`
261
+ # the real URL. We don't distinguish `:tab` from `:window` (no
262
+ # window-chrome semantics in this driver).
263
+ def open_new_window(_kind = :tab)
264
+ open_aux_window
113
265
  end
114
-
115
- def frame_title
116
- browser.title
266
+ def window_size(_) = [current_browser.viewport_width, current_browser.viewport_height]
267
+ def close_window(h)
268
+ return if h == PRIMARY_HANDLE
269
+ @aux_windows.reject! {|w|
270
+ next false unless w[:handle] == h
271
+ w[:browser].dispose rescue nil
272
+ true
273
+ }
274
+ @active_handle = nil if @active_handle == h
117
275
  end
118
-
119
- def frame_url
120
- browser.current_url
276
+ def switch_to_window(h)
277
+ if h == PRIMARY_HANDLE
278
+ @active_handle = nil
279
+ elsif @aux_windows.any? {|w| w[:handle] == h }
280
+ @active_handle = h
281
+ else
282
+ raise Capybara::WindowError, "Unknown window handle: #{h}"
283
+ end
121
284
  end
285
+ def resize_window_to(_, w, h) = current_browser.set_viewport(w, h)
286
+ # Forem's ahoy-tracking spec calls `driver.resize(w, h)` directly
287
+ # rather than through `current_window.resize_to`.
288
+ def resize(w, h) = current_browser.set_viewport(w, h)
289
+ def maximize_window(_) ; nil ; end
122
290
 
123
- def accept_modal(type, **options, &blk)
124
- push_modal_handler(type, options, accept: true)
125
- wait_for_modal(type, options, &blk)
291
+ def evaluate_script(script, *args)
292
+ unwrap(current_browser.evaluate_script(script, args))
126
293
  end
127
294
 
128
- def dismiss_modal(type, **options, &blk)
129
- push_modal_handler(type, options, accept: false)
130
- wait_for_modal(type, options, &blk)
295
+ # Capybara's `execute_script` contract is "run it, discard the
296
+ # return". Route through a no-return JS path so a script that
297
+ # returns a non-marshallable value (jQuery `$('…').text('…')`
298
+ # returns a chainable jQuery object that the engine's value
299
+ # filter recurses into until it stack-overflows) doesn't blow
300
+ # up on the way back.
301
+ def execute_script(script, *args)
302
+ current_browser.execute_script(script, args)
303
+ nil
131
304
  end
132
305
 
133
- private
306
+ def evaluate_async_script(script, *args)
307
+ unwrap(current_browser.evaluate_async_script(script, args))
308
+ end
134
309
 
135
- # Modals fired from `setTimeout` callbacks don't appear synchronously
136
- # when the block runs `click_link`. Advance the virtual clock in
137
- # short slices until either a matching modal arrives or the wait
138
- # budget runs out, matching Capybara's `accept_modal` semantics for
139
- # asynchronous alerts.
140
- MODAL_POLL_STEP_MS = 50
141
- def wait_for_modal(type, options, &blk)
142
- blk.call if blk
143
- deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) +
144
- (options[:wait] || Capybara.default_max_wait_time || 2).to_f
145
- text_matcher = options[:text]
146
- loop do
147
- browser.modal_inbox.concat(browser.drain_modal_queue)
148
- match = browser.modal_inbox.find {|m|
149
- m['type'].to_s == type.to_s && modal_text_matches?(m['message'], text_matcher)
150
- }
151
- if match
152
- browser.modal_inbox.delete(match)
153
- pop_modal_handler(type, options)
154
- return match['message']
310
+ private def unwrap(value)
311
+ case value
312
+ when Hash
313
+ if (h = value['__elementHandle']) then Node.new(self, h)
314
+ else value.transform_values {|v| unwrap(v) }
155
315
  end
156
- break if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
157
- browser.advance_virtual_clock_step(MODAL_POLL_STEP_MS)
316
+ when Array then value.map {|v| unwrap(v) }
317
+ else value
158
318
  end
159
- pop_modal_handler(type, options)
160
- raise Capybara::ModalNotFound, "Unable to find modal dialog#{" with text matching #{text_matcher.inspect}" if text_matcher}"
161
319
  end
162
320
 
163
- def modal_text_matches?(message, matcher)
164
- return true if matcher.nil?
165
- case matcher
166
- when Regexp then matcher.match?(message.to_s)
167
- else message.to_s.include?(matcher.to_s)
168
- end
321
+ def invalid_element_errors = [Capybara::Simulated::StaleElement]
322
+ def no_such_window_error = Capybara::WindowError
323
+
324
+ def save_screenshot(path, **_opts)
325
+ File.write(path, current_browser.html.to_s)
326
+ path
169
327
  end
170
328
 
171
- # Modal handlers stack in registration order; the JS modal stub
172
- # picks the first whose text predicate matches the firing message.
173
- # Required for nested `dismiss_confirm { accept_confirm { ... } }`
174
- # blocks where two confirms fire in one synchronous click handler.
175
- def push_modal_handler(type, options, accept:)
176
- response = case type
177
- when :alert then true
178
- when :confirm then accept
179
- when :prompt then accept ? options[:with] : false
180
- end
181
- browser.add_modal_handler(type: type.to_s, text: options[:text], response: response)
329
+ def active_element
330
+ handle = current_browser.active_element_handle
331
+ handle ? Node.new(self, handle) : nil
332
+ end
333
+
334
+ # CDP-ish geolocation override (Capybara driver-level API).
335
+ #
336
+ # page.driver.set_geolocation(latitude: 35.6, longitude: 139.7)
337
+ # page.driver.set_geolocation(denied: true) # PERMISSION_DENIED
338
+ # page.driver.set_geolocation # clear -> POSITION_UNAVAILABLE
339
+ def set_geolocation(latitude: nil, longitude: nil, accuracy: 10, denied: false, **rest)
340
+ current_browser.set_geolocation(latitude: latitude, longitude: longitude, accuracy: accuracy, denied: denied, **rest)
182
341
  end
183
342
 
184
- def pop_modal_handler(type, options)
185
- browser.remove_modal_handler(type: type.to_s, text: options[:text])
343
+ def send_keys(*keys)
344
+ # Selenium contract: top-level modifier symbols (`send_keys(
345
+ # :shift, :enter)`) press the modifier *and hold it* over the
346
+ # following key, releasing at the end of the call. Nested
347
+ # arrays (`send_keys([:control, "/"])`) are chords — modifiers
348
+ # combined with the final key in one press. Pass the whole
349
+ # batch to `Browser#send_session_keys` in one call so the
350
+ # JS-side handler can build a `combo` atom from the held
351
+ # modifiers + the next key. Iterating per-key would split the
352
+ # chord across calls and drop the modifier flags.
353
+ current_browser.send_session_keys(keys)
354
+ nil
186
355
  end
187
356
 
188
- public
357
+ def accept_modal(type, **options, &block) = run_modal(type, accept: true, **options, &block)
358
+ def dismiss_modal(type, **options, &block) = run_modal(type, accept: false, **options, &block)
359
+
360
+ private def run_modal(type, accept:, text: nil, with: nil, wait: nil)
361
+ captured = nil
362
+ # Dispatch by the *actual* modal type fired — `accept_alert
363
+ # do ... end` should also accept a confirm() raised by the
364
+ # block. Mirrors how selenium / cuprite route in real life.
365
+ handler = ->(actual_type, msg, default_value) {
366
+ captured = msg
367
+ case actual_type.to_sym
368
+ when :alert then nil
369
+ when :confirm then accept
370
+ when :prompt then accept ? (with.nil? ? default_value.to_s : with.to_s) : nil
371
+ end
372
+ }
373
+ current_browser.with_modal(handler) do
374
+ yield if block_given?
375
+ # Pump timers so a setTimeout-driven alert can land.
376
+ timeout = (wait || Capybara.default_max_wait_time).to_f
377
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
378
+ while captured.nil? && Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
379
+ sleep 0.01
380
+ current_browser.send(:tick_real_time)
381
+ end
382
+ end
383
+ if captured.nil?
384
+ raise Capybara::ModalNotFound, "Unable to find modal dialog with #{text.inspect}"
385
+ end
386
+ if text && !modal_text_matches?(text, captured)
387
+ raise Capybara::ModalNotFound,
388
+ "Unable to find modal dialog with #{text.inspect} (got #{captured.inspect})"
389
+ end
390
+ captured
391
+ end
392
+
393
+ private def modal_text_matches?(matcher, message)
394
+ matcher.is_a?(Regexp) ? matcher.match?(message) : message.include?(matcher.to_s)
395
+ end
189
396
  end
190
397
  end
191
398
  end
@@ -1,9 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'capybara'
4
+
1
5
  module Capybara
2
6
  module Simulated
3
- # Raised by the runtime when a Ruby caller hands back a handle id that
4
- # no longer maps to a live DOM node usually because the page was
5
- # reloaded between the find and the action. Capybara's synchronize
6
- # block catches these via the driver's `invalid_element_errors`.
7
- class StaleElementReferenceError < StandardError; end
7
+ # Raised when an Element handle no longer refers to a node attached
8
+ # to the document. Driver lists this as an `invalid_element_error`,
9
+ # so Capybara's `synchronize` wrapper catches it and reloads the
10
+ # cached element.
11
+ class StaleElement < Capybara::ElementNotFound; end
8
12
  end
9
13
  end