capybara-simulated 0.0.2

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.
@@ -0,0 +1,191 @@
1
+ require 'capybara/driver/base'
2
+
3
+ module Capybara
4
+ module Simulated
5
+ class Driver < Capybara::Driver::Base
6
+ DEFAULT_WINDOW_HANDLE = 'main'.freeze
7
+
8
+ attr_reader :app
9
+
10
+ def initialize(app)
11
+ @app = app
12
+ end
13
+
14
+ def browser
15
+ @browser ||= begin
16
+ b = Browser.new(app)
17
+ b.driver_for_results = self
18
+ b
19
+ end
20
+ end
21
+
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) }
42
+ end
43
+
44
+ def find_css(query, **_)
45
+ browser.find_css(query).map {|id| Node.new(self, id) }
46
+ end
47
+
48
+ def execute_script(script, *args)
49
+ browser.execute_script(script, args)
50
+ end
51
+
52
+ def evaluate_script(script, *args)
53
+ browser.evaluate_script(script, args)
54
+ end
55
+
56
+ def evaluate_async_script(script, *args)
57
+ browser.evaluate_async_script(script, args)
58
+ end
59
+
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)
68
+ end
69
+
70
+ def active_element
71
+ handle_id = browser.active_element
72
+ Node.new(self, handle_id) if handle_id
73
+ end
74
+
75
+ def save_screenshot(path, **_options)
76
+ File.write(path, html)
77
+ path
78
+ end
79
+
80
+ def reset!
81
+ return unless @browser
82
+ @browser.reset_state!
83
+ end
84
+
85
+ def invalid_element_errors
86
+ [Capybara::Simulated::StaleElementReferenceError]
87
+ end
88
+
89
+ def no_such_window_error
90
+ Capybara::WindowError
91
+ end
92
+
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'
97
+ end
98
+ def close_window(_handle)
99
+ raise NotImplementedError, 'capybara-simulated supports a single window'
100
+ 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
+
111
+ def switch_to_frame(_frame)
112
+ raise NotImplementedError, 'frames are not supported by capybara-simulated'
113
+ end
114
+
115
+ def frame_title
116
+ browser.title
117
+ end
118
+
119
+ def frame_url
120
+ browser.current_url
121
+ end
122
+
123
+ def accept_modal(type, **options, &blk)
124
+ push_modal_handler(type, options, accept: true)
125
+ wait_for_modal(type, options, &blk)
126
+ end
127
+
128
+ def dismiss_modal(type, **options, &blk)
129
+ push_modal_handler(type, options, accept: false)
130
+ wait_for_modal(type, options, &blk)
131
+ end
132
+
133
+ private
134
+
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']
155
+ end
156
+ break if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
157
+ browser.advance_virtual_clock_step(MODAL_POLL_STEP_MS)
158
+ 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
+ end
162
+
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
169
+ end
170
+
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)
182
+ end
183
+
184
+ def pop_modal_handler(type, options)
185
+ browser.remove_modal_handler(type: type.to_s, text: options[:text])
186
+ end
187
+
188
+ public
189
+ end
190
+ end
191
+ end
@@ -0,0 +1,9 @@
1
+ module Capybara
2
+ 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
8
+ end
9
+ end
@@ -0,0 +1,235 @@
1
+ require 'capybara/driver/node'
2
+ require 'capybara/node/whitespace_normalizer'
3
+
4
+ module Capybara
5
+ module Simulated
6
+ class Node < Capybara::Driver::Node
7
+ include Capybara::Node::WhitespaceNormalizer
8
+
9
+ def handle_id = native
10
+
11
+ def all_text
12
+ normalize_spacing(browser.all_text(handle_id))
13
+ end
14
+
15
+ def visible_text
16
+ normalize_visible_spacing(browser.visible_text(handle_id))
17
+ end
18
+
19
+ def [](name)
20
+ case name.to_s
21
+ when 'value' then value
22
+ when 'checked' then checked? ? 'true' : nil
23
+ when 'selected' then selected? ? 'true' : nil
24
+ else browser.attr(handle_id, name)
25
+ end
26
+ end
27
+
28
+ def value
29
+ browser.value(handle_id)
30
+ end
31
+
32
+ def set(value, **_opts)
33
+ return if disabled?
34
+ # readonly is meaningless on checkbox/radio; the HTML spec ignores
35
+ # it for those types, and Capybara's specs explicitly assert that.
36
+ type = (self[:type] || tag_name || '').to_s.downcase
37
+ return if readonly? && type != 'checkbox' && type != 'radio'
38
+ browser.set_value(handle_id, value)
39
+ end
40
+
41
+ def select_option
42
+ return if disabled?
43
+ browser.select_option(handle_id)
44
+ end
45
+
46
+ def unselect_option
47
+ raise Capybara::UnselectNotAllowed, 'Cannot unselect option from single select box.' unless select_node_multiple?
48
+ browser.unselect_option(handle_id)
49
+ end
50
+
51
+ def tag_name
52
+ browser.tag_name(handle_id)
53
+ end
54
+
55
+ def visible?
56
+ browser.visible?(handle_id)
57
+ end
58
+
59
+ def obscured?
60
+ false
61
+ end
62
+
63
+ def checked?
64
+ browser.checked?(handle_id)
65
+ end
66
+
67
+ def selected?
68
+ browser.selected?(handle_id)
69
+ end
70
+
71
+ def disabled?
72
+ browser.disabled?(handle_id)
73
+ end
74
+
75
+ def readonly?
76
+ browser.readonly?(handle_id)
77
+ end
78
+
79
+ def multiple?
80
+ browser.multiple?(handle_id)
81
+ end
82
+
83
+ def click(keys = [], **opts)
84
+ browser.click(handle_id, click_options(keys, opts))
85
+ end
86
+
87
+ def right_click(keys = [], **opts)
88
+ browser.right_click(handle_id, click_options(keys, opts))
89
+ end
90
+
91
+ def double_click(keys = [], **opts)
92
+ browser.double_click(handle_id, click_options(keys, opts))
93
+ end
94
+
95
+ private
96
+
97
+ # x/y from Capybara are *offsets* — from top-left when
98
+ # Capybara.w3c_click_offset is false, from center when true. The
99
+ # runtime reads `simRect` to translate these into absolute
100
+ # clientX/clientY before dispatching the mouse events. Defaults
101
+ # (no x/y) target the element's center.
102
+ def click_options(keys, opts)
103
+ out = modifier_options(keys)
104
+ if opts.key?(:x) || opts.key?(:y)
105
+ out['offsetX'] = opts[:x].to_f if opts.key?(:x)
106
+ out['offsetY'] = opts[:y].to_f if opts.key?(:y)
107
+ out['w3cOffset'] = !!Capybara.w3c_click_offset
108
+ end
109
+ out['delay'] = opts[:delay].to_f if opts.key?(:delay) && opts[:delay].to_f.positive?
110
+ out
111
+ end
112
+
113
+ def modifier_options(keys)
114
+ opts = {}
115
+ Array(keys).each do |k|
116
+ case k
117
+ when :shift then opts['shiftKey'] = true
118
+ when :ctrl, :control then opts['ctrlKey'] = true
119
+ when :alt then opts['altKey'] = true
120
+ when :meta, :command then opts['metaKey'] = true
121
+ end
122
+ end
123
+ opts
124
+ end
125
+
126
+ public
127
+
128
+ def hover
129
+ browser.hover(handle_id)
130
+ end
131
+
132
+ def trigger(event)
133
+ browser.trigger(handle_id, event)
134
+ end
135
+
136
+ def send_keys(*keys)
137
+ browser.send_keys(handle_id, keys)
138
+ end
139
+
140
+ def drag_to(other, **_opts)
141
+ browser.trigger(handle_id, 'dragstart')
142
+ browser.trigger(other.handle_id, 'dragenter')
143
+ browser.trigger(other.handle_id, 'dragover')
144
+ browser.trigger(other.handle_id, 'drop')
145
+ browser.trigger(handle_id, 'dragend')
146
+ end
147
+
148
+ def drop(*args)
149
+ items = args.flat_map {|arg| drop_items_for(arg) }
150
+ browser.drop(handle_id, items)
151
+ end
152
+
153
+ def scroll_by(_x, _y); end
154
+ def scroll_to(_element, _alignment, _position = nil); end
155
+
156
+ def submit
157
+ browser.submit(handle_id)
158
+ end
159
+
160
+ def find_xpath(xpath)
161
+ browser.find_xpath(xpath, handle_id).map {|id| self.class.new(driver, id) }
162
+ end
163
+
164
+ def find_css(css)
165
+ browser.find_css(css, handle_id).map {|id| self.class.new(driver, id) }
166
+ end
167
+
168
+ def rect
169
+ browser.rect(handle_id)
170
+ end
171
+
172
+ def path
173
+ browser.path(handle_id)
174
+ end
175
+
176
+ def shadow_root
177
+ id = browser.shadow_root(handle_id)
178
+ id ? Node.new(driver, id) : nil
179
+ end
180
+
181
+ def style(_styles)
182
+ raise NotImplementedError, 'The simulated driver does not process CSS'
183
+ end
184
+
185
+ def ==(other)
186
+ other.is_a?(Node) && other.handle_id == handle_id
187
+ end
188
+
189
+ def inspect
190
+ %(#<Capybara::Simulated::Node tag="#{tag_name}" id=#{handle_id}>)
191
+ rescue StandardError
192
+ super
193
+ end
194
+
195
+ private
196
+
197
+ def drop_items_for(arg)
198
+ case arg
199
+ when Hash
200
+ arg.map {|type, data|
201
+ {'kind' => 'string', 'type' => type.to_s, 'data' => data.to_s}
202
+ }
203
+ when String
204
+ [{'kind' => 'file', 'name' => File.basename(arg), 'type' => '', 'contents' => safely_read(arg)}]
205
+ else
206
+ []
207
+ end
208
+ end
209
+
210
+ def safely_read(path)
211
+ File.binread(path)
212
+ rescue StandardError
213
+ ''
214
+ end
215
+
216
+ def browser = driver.browser
217
+
218
+ def select_node_multiple?
219
+ browser.evaluate_script(<<~JS)
220
+ (() => {
221
+ const el = (#{select_lookup_js})
222
+ const sel = el.closest('select');
223
+ return !!(sel && sel.multiple);
224
+ })()
225
+ JS
226
+ end
227
+
228
+ def select_lookup_js
229
+ # The JS-side handles map is private; use evaluate via a tiny accessor.
230
+ # Cheaper: ask browser directly through path/tag inspection.
231
+ "document.evaluate(#{path.inspect}, document, null, 9, null).singleNodeValue"
232
+ end
233
+ end
234
+ end
235
+ end
@@ -0,0 +1,5 @@
1
+ module Capybara
2
+ module Simulated
3
+ VERSION = '0.0.2'
4
+ end
5
+ end
@@ -0,0 +1,10 @@
1
+ require 'capybara'
2
+ require 'capybara/simulated/version'
3
+ require 'capybara/simulated/errors'
4
+ require 'capybara/simulated/browser'
5
+ require 'capybara/simulated/driver'
6
+ require 'capybara/simulated/node'
7
+
8
+ Capybara.register_driver :simulated do |app|
9
+ Capybara::Simulated::Driver.new(app)
10
+ end
@@ -0,0 +1 @@
1
+ require 'capybara/simulated'
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2020 Evan Wallace
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,91 @@
1
+ #!/usr/bin/env node
2
+
3
+ // Forward to the automatically-generated WebAssembly loader from the Go compiler
4
+
5
+ const module_ = require('module');
6
+ const path = require('path');
7
+ const fs = require('fs');
8
+
9
+ const wasm_exec_node = path.join(__dirname, '..', 'wasm_exec_node.js');
10
+ const esbuild_wasm = path.join(__dirname, '..', 'esbuild.wasm');
11
+
12
+ const code = fs.readFileSync(wasm_exec_node, 'utf8');
13
+ const wrapper = new Function('require', 'WebAssembly', code);
14
+
15
+ function instantiate(bytes, importObject) {
16
+ // Using this API causes "./esbuild --version" to run around 1 second faster
17
+ // than using the "WebAssembly.instantiate()" API when run in node (v12.16.2)
18
+ const module = new WebAssembly.Module(bytes);
19
+ const instance = new WebAssembly.Instance(module, importObject);
20
+ return Promise.resolve({ instance, module });
21
+ }
22
+
23
+ // Node has another bug where using "fs.read" to read from stdin reads
24
+ // everything successfully and then throws an error, but only on Windows. Go's
25
+ // WebAssembly support uses "fs.read" so it hits this problem. This is a patch
26
+ // to try to work around the bug in node. This bug has been reported to node
27
+ // at least twice in https://github.com/nodejs/node/issues/35997 and in
28
+ // https://github.com/nodejs/node/issues/19831. This issue has also been
29
+ // reported to the Go project: https://github.com/golang/go/issues/43913.
30
+ const read = fs.read;
31
+ fs.read = function () {
32
+ const callback = arguments[5];
33
+ arguments[5] = function (err, count) {
34
+ if (count === 0 && err && err.code === 'EOF') {
35
+ arguments[0] = null;
36
+ }
37
+ return callback.apply(this, arguments);
38
+ };
39
+ return read.apply(this, arguments);
40
+ };
41
+
42
+ // Hack around a Unicode bug in node: https://github.com/nodejs/node/issues/24550.
43
+ // See this for the matching Go issue: https://github.com/golang/go/issues/43917.
44
+ const write = fs.write;
45
+ fs.write = function (fd, buf, offset, length, position, callback) {
46
+ if (offset === 0 && length === buf.length && position === null) {
47
+ if (fd === process.stdout.fd) {
48
+ try {
49
+ process.stdout.write(buf, err => err ? callback(err, 0, null) : callback(null, length, buf));
50
+ } catch (err) {
51
+ callback(err, 0, null);
52
+ }
53
+ return;
54
+ }
55
+ if (fd === process.stderr.fd) {
56
+ try {
57
+ process.stderr.write(buf, err => err ? callback(err, 0, null) : callback(null, length, buf));
58
+ } catch (err) {
59
+ callback(err, 0, null);
60
+ }
61
+ return;
62
+ }
63
+ }
64
+ return write.apply(this, arguments);
65
+ };
66
+ const writeSync = fs.writeSync;
67
+ fs.writeSync = function (fd, buf) {
68
+ if (fd === process.stdout.fd) return process.stdout.write(buf), buf.length;
69
+ if (fd === process.stderr.fd) return process.stderr.write(buf), buf.length;
70
+ return writeSync.apply(this, arguments);
71
+ };
72
+
73
+ // WASM code generated with Go 1.17.2+ will crash when run in a situation with
74
+ // many environment variables: https://github.com/golang/go/issues/49011. An
75
+ // example of this situation is running a Go-compiled WASM executable in GitHub
76
+ // Actions. Work around this by filtering node's copy of environment variables
77
+ // down to only include the environment variables that esbuild currently uses.
78
+ const esbuildUsedEnvVars = [
79
+ 'NO_COLOR',
80
+ 'NODE_PATH',
81
+ 'npm_config_user_agent',
82
+ 'WT_SESSION',
83
+ ]
84
+ for (let key in process.env) {
85
+ if (esbuildUsedEnvVars.indexOf(key) < 0) {
86
+ delete process.env[key]
87
+ }
88
+ }
89
+
90
+ process.argv.splice(2, 0, esbuild_wasm);
91
+ wrapper(module_.createRequire(wasm_exec_node), Object.assign(Object.create(WebAssembly), { instantiate }));
Binary file