capybara-simulated 0.0.7 → 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.
- checksums.yaml +4 -4
- data/README.md +303 -158
- data/lib/capybara/simulated/asset_cache.rb +232 -0
- data/lib/capybara/simulated/browser.rb +3409 -845
- data/lib/capybara/simulated/driver.rb +341 -134
- data/lib/capybara/simulated/errors.rb +9 -5
- data/lib/capybara/simulated/js/bridge.bundle.js +19409 -0
- data/lib/capybara/simulated/js/snapshot_stubs.js +110 -0
- data/lib/capybara/simulated/node.rb +151 -163
- data/lib/capybara/simulated/quickjs_runtime.rb +424 -0
- data/lib/capybara/simulated/runtime_shared.rb +183 -0
- data/lib/capybara/simulated/script_cache.rb +168 -0
- data/lib/capybara/simulated/sourcemap.rb +119 -0
- data/lib/capybara/simulated/stack_resolver.rb +97 -0
- data/lib/capybara/simulated/trace.rb +111 -0
- data/lib/capybara/simulated/v8_runtime.rb +987 -0
- data/lib/capybara/simulated/version.rb +3 -1
- data/lib/capybara/simulated/webauthn_state.rb +367 -0
- data/lib/capybara/simulated/whitespace_normalizer.rb +45 -0
- data/lib/capybara/simulated/worker_runtime.rb +30 -0
- data/lib/capybara/simulated.rb +31 -4
- data/lib/capybara-simulated.rb +2 -0
- data/vendor/js/vendor.bundle.js +13 -0
- metadata +24 -32
- data/vendor/esbuild-wasm/LICENSE.md +0 -21
- data/vendor/esbuild-wasm/bin/esbuild +0 -91
- data/vendor/esbuild-wasm/esbuild.wasm +0 -0
- data/vendor/esbuild-wasm/lib/main.js +0 -2337
- data/vendor/esbuild-wasm/wasm_exec.js +0 -575
- data/vendor/esbuild-wasm/wasm_exec_node.js +0 -40
- data/vendor/js/bundle-modules.mjs +0 -168
- data/vendor/js/csim.bundle.js +0 -91560
- data/vendor/js/entry.mjs +0 -23
- data/vendor/js/prelude.js +0 -190
- data/vendor/js/runtime.js +0 -2208
|
@@ -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
|
-
|
|
45
|
+
attr_reader :app, :owner_thread
|
|
7
46
|
|
|
8
|
-
|
|
47
|
+
@@live_lock = Mutex.new
|
|
48
|
+
@@live = [] # [WeakRef<Driver>] — dead refs filtered on read.
|
|
9
49
|
|
|
10
|
-
def
|
|
11
|
-
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
49
|
-
|
|
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
|
|
53
|
-
|
|
54
|
-
end
|
|
105
|
+
def tracing? = !current_trace.nil?
|
|
106
|
+
def current_trace = browser.trace || browser.pending_trace
|
|
55
107
|
|
|
56
|
-
|
|
57
|
-
|
|
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
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
82
|
-
@
|
|
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
|
|
86
|
-
|
|
222
|
+
def find_xpath(query, **_)
|
|
223
|
+
current_browser.find_xpath(query).map {|id| Node.new(self, id) }
|
|
87
224
|
end
|
|
88
225
|
|
|
89
|
-
def
|
|
90
|
-
|
|
226
|
+
def find_css(query, **_)
|
|
227
|
+
current_browser.find_css(query).map {|id| Node.new(self, id) }
|
|
91
228
|
end
|
|
92
229
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
|
99
|
-
|
|
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
|
-
|
|
112
|
-
|
|
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
|
|
116
|
-
|
|
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
|
-
|
|
120
|
-
|
|
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
|
|
124
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
306
|
+
def evaluate_async_script(script, *args)
|
|
307
|
+
unwrap(current_browser.evaluate_async_script(script, args))
|
|
308
|
+
end
|
|
134
309
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
157
|
-
|
|
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
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
|
185
|
-
|
|
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
|
-
|
|
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
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
class
|
|
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
|