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.
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 -190
  35. data/vendor/js/runtime.js +0 -2208
@@ -0,0 +1,110 @@
1
+ // Stub host fns so bridge.js can be baked into a Snapshot (V8) /
2
+ // compiled to bytecode (QuickJS) without the real Ruby-side hosts
3
+ // being attached yet. Real implementations land at Context build
4
+ // time via the engine's attach API. Only the host fns bridge.js
5
+ // actually invokes appear here — if you add a new host fn, add the
6
+ // matching stub here too.
7
+ Object.defineProperty(globalThis, Symbol.toStringTag, { value: 'Window' });
8
+
9
+ // The vendored whatwg-url engine captures `SharedArrayBuffer` and a
10
+ // `TextEncoder`/`TextDecoder` at module-load time = snapshot-BUILD time here —
11
+ // and the V8 snapshot context exposes neither (SharedArrayBuffer is
12
+ // Spectre-disabled; our real TextEncoder lives in bridge's encoding.js, which
13
+ // loads AFTER the vendor bundle). Provide them before the vendor bundle so
14
+ // whatwg-url bakes cleanly. SharedArrayBuffer is only touched by whatwg-url's
15
+ // (unused-for-URL) WebIDL buffer-type guard, so aliasing it to ArrayBuffer is
16
+ // fine. The TextEncoder/TextDecoder MUST be real UTF-8 — whatwg-url captures
17
+ // them in a module closure and uses them for percent-encoding at runtime (the
18
+ // bridge's full encoding.js later overrides `globalThis.TextEncoder` for app
19
+ // code, but whatwg-url keeps the instance it captured here).
20
+ globalThis.SharedArrayBuffer = globalThis.SharedArrayBuffer || globalThis.ArrayBuffer;
21
+ if (typeof globalThis.TextEncoder === 'undefined') {
22
+ globalThis.TextEncoder = class TextEncoder {
23
+ get encoding() { return 'utf-8'; }
24
+ encode(str) {
25
+ str = String(str === undefined ? '' : str);
26
+ const out = [];
27
+ for (let i = 0; i < str.length; i++) {
28
+ let c = str.codePointAt(i);
29
+ if (c > 0xffff) i++; // surrogate pair — skip the low half
30
+ else if (c >= 0xd800 && c <= 0xdfff) c = 0xfffd; // unpaired surrogate → U+FFFD (WHATWG)
31
+ if (c < 0x80) out.push(c);
32
+ else if (c < 0x800) out.push(0xc0 | (c >> 6), 0x80 | (c & 0x3f));
33
+ else if (c < 0x10000) out.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f));
34
+ else out.push(0xf0 | (c >> 18), 0x80 | ((c >> 12) & 0x3f), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f));
35
+ }
36
+ return new Uint8Array(out);
37
+ }
38
+ };
39
+ }
40
+ if (typeof globalThis.TextDecoder === 'undefined') {
41
+ globalThis.TextDecoder = class TextDecoder {
42
+ constructor() {}
43
+ get encoding() { return 'utf-8'; }
44
+ decode(input) {
45
+ if (!input) return '';
46
+ const b = input instanceof Uint8Array ? input : new Uint8Array(input.buffer || input);
47
+ let s = '';
48
+ for (let i = 0; i < b.length;) {
49
+ let c = b[i++];
50
+ if (c >= 0xf0) c = ((c & 0x07) << 18) | ((b[i++] & 0x3f) << 12) | ((b[i++] & 0x3f) << 6) | (b[i++] & 0x3f);
51
+ else if (c >= 0xe0) c = ((c & 0x0f) << 12) | ((b[i++] & 0x3f) << 6) | (b[i++] & 0x3f);
52
+ else if (c >= 0xc0) c = ((c & 0x1f) << 6) | (b[i++] & 0x3f);
53
+ s += String.fromCodePoint(c);
54
+ }
55
+ return s;
56
+ }
57
+ };
58
+ }
59
+ globalThis.__csim_parseUrl = function (input, base) {
60
+ return {
61
+ href: 'http://placeholder/', protocol: 'http:',
62
+ username: '', password: '', host: 'placeholder',
63
+ hostname: 'placeholder', port: '', pathname: '/',
64
+ search: '', hash: '', origin: 'http://placeholder'
65
+ };
66
+ };
67
+ globalThis.__csim_randomUUID = function () { return '00000000-0000-0000-0000-000000000000'; };
68
+ globalThis.__csim_randomBytes = function (n) { return new Array(n).fill(0); };
69
+ globalThis.__csim_atob = function (s) { return ''; };
70
+ globalThis.__csim_btoa = function (s) { return ''; };
71
+ globalThis.__csim_utf8Encode = function (s) { return []; };
72
+ globalThis.__csim_utf8Decode = function (a) { return ''; };
73
+ globalThis.__rackFetch = function () { return null; };
74
+ globalThis.__locationAssign = function () { return null; };
75
+ globalThis.__locationReload = function () { return null; };
76
+ globalThis.__setTimersActive = function () { return null; };
77
+ globalThis.__setCurrentUrl = function () { return null; };
78
+ globalThis.__pushHistoryEntry = function () { return null; };
79
+ globalThis.__historyLength = function () { return 1; };
80
+ globalThis.__csimReadFilePick = function () { return null; };
81
+ globalThis.__getDocumentCookie = function () { return ''; };
82
+ globalThis.__setDocumentCookie = function () { return null; };
83
+ globalThis.__csim_storageGet = function () { return null; };
84
+ globalThis.__csim_storageSet = function () { return null; };
85
+ globalThis.__csim_storageRemove = function () { return null; };
86
+ globalThis.__csim_storageClear = function () { return null; };
87
+ globalThis.__csim_storageKey = function () { return null; };
88
+ globalThis.__csim_storageLength = function () { return 0; };
89
+ // Geolocation override state; JSON string ('null' = no override configured).
90
+ globalThis.__csimGeolocationState = function () { return 'null'; };
91
+ globalThis.__modalDialog = function () { return null; };
92
+ globalThis.__csim_pushImportmap = function () { return null; };
93
+ globalThis.__csim_logConsole = function () { return null; };
94
+ globalThis.__csim_eventSourceOpen = function () { return 0; };
95
+ globalThis.__csim_eventSourceClose = function () { return null; };
96
+ globalThis.__csim_workerSpawn = function () { return 0; };
97
+ globalThis.__csim_workerPostToWorker= function () { return null; };
98
+ globalThis.__csim_workerTerminate = function () { return null; };
99
+ globalThis.__csim_workerPostMessage = function () { return null; };
100
+ globalThis.__csim_decodeImage = function () { return null; };
101
+ globalThis.__csim_blobRegister = function () { return null; };
102
+ globalThis.__csim_blobResolve = function () { return null; };
103
+ globalThis.__csim_blobUnregister = function () { return null; };
104
+ // Default classic-script runner: indirect-eval at global scope. Both
105
+ // runtimes override this with a bytecode-caching host fn at attach
106
+ // time. `//# sourceURL=…` labels eval'd content so stack traces
107
+ // report the script URL instead of `<anonymous>`.
108
+ globalThis.__csim_runScript = function (label, body) {
109
+ (0, eval)(body + '\n//# sourceURL=' + (label || 'csim-eval'));
110
+ };
@@ -1,235 +1,223 @@
1
- require 'capybara/driver/node'
2
- require 'capybara/node/whitespace_normalizer'
1
+ # frozen_string_literal: true
2
+
3
+ require 'capybara/node/base'
4
+ require_relative 'errors'
5
+ require_relative 'whitespace_normalizer'
3
6
 
4
7
  module Capybara
5
8
  module Simulated
6
9
  class Node < Capybara::Driver::Node
7
- include Capybara::Node::WhitespaceNormalizer
8
-
9
- def handle_id = native
10
-
10
+ include WhitespaceNormalizer
11
+
12
+ def initialize(driver, handle)
13
+ super(driver, self)
14
+ @handle_id = handle
15
+ # Pin the Browser at construction so nodes from one window
16
+ # stay valid even after `switch_to_window` flips to another.
17
+ @browser = driver.current_browser
18
+ @initial_node = @browser.lookup_node(handle)
19
+ @context_gen = @browser.context_gen
20
+ end
21
+
22
+ attr_reader :handle_id, :context_gen
23
+
24
+ # Tick the virtual clock unconditionally on the text path. Unlike
25
+ # `Node#[]` (attribute reads), the text readers are NOT preceded by
26
+ # a `find_css` that ticks under the wall throttle: `have_text` /
27
+ # `assert_text` polls `.text` directly against an already-found,
28
+ # cached scope node, so the find loop short-circuits without
29
+ # re-ticking. Gating these behind `timer_wait_elapsed?` therefore
30
+ # stalls a pure text-poll loop's virtual clock and scheduled
31
+ # `setTimeout`s never fire (smoke_spec virtual-clock contract).
32
+ # They run ~once per poll-scope (not once per matched result like
33
+ # the attribute filters the audit targeted), so they are not the
34
+ # O(N) hot path and are safe to tick every call.
11
35
  def all_text
36
+ browser.tick_real_time
37
+ check_stale
12
38
  normalize_spacing(browser.all_text(handle_id))
13
39
  end
14
40
 
15
41
  def visible_text
42
+ browser.tick_real_time
43
+ check_stale
16
44
  normalize_visible_spacing(browser.visible_text(handle_id))
17
45
  end
18
46
 
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
47
  def value
48
+ check_stale
29
49
  browser.value(handle_id)
30
50
  end
31
51
 
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
52
  def visible?
53
+ check_stale
56
54
  browser.visible?(handle_id)
57
55
  end
58
56
 
59
- def obscured?
60
- false
61
- end
57
+ def tag_name = browser.tag_name(handle_id)
62
58
 
63
- def checked?
64
- browser.checked?(handle_id)
59
+ # Convenience accessors mirroring real browser nodes — Discourse
60
+ # tests reach for `.native.inner_html` (e.g. reviewables XSS
61
+ # checks); without this method `.native` (= self) raised
62
+ # NoMethodError.
63
+ def inner_html
64
+ check_stale
65
+ browser.inner_html(handle_id)
65
66
  end
66
67
 
67
- def selected?
68
- browser.selected?(handle_id)
68
+ def outer_html
69
+ check_stale
70
+ browser.outer_html(handle_id)
69
71
  end
70
72
 
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)
73
+ def [](name)
74
+ # Tick the virtual clock so Capybara's helpers that poll an
75
+ # attribute in a tight `sleep(0.1) until` loop (e.g. Avo's
76
+ # `wait_for_body_class_missing`) actually see the JS chain
77
+ # progress. Without this the body class never transitions
78
+ # because we only advance time during `find`, and the helper
79
+ # caches the body node before its poll loop.
80
+ #
81
+ # Gated behind the same wall-clock throttle `find_css` uses
82
+ # (`timer_wait_elapsed?`): on framework-runloop pages that keep
83
+ # `@timers_active` permanently true, an ungated tick here would
84
+ # drain timers on every per-result attribute filter / poll
85
+ # iteration. The throttle still ticks the first time and once
86
+ # per ~50 ms window thereafter, so poll loops that wait for a
87
+ # timer to fire between reads still make progress.
88
+ #
89
+ # `tick_real_time` also drains the Worker / EventSource / hijacked
90
+ # -fetch outboxes, so an attribute poll whose value is delivered
91
+ # only by one of those channels (with no active timer) would
92
+ # otherwise never see it. `async_io_pending?` is an O(1) gate
93
+ # (three empty? checks) that lets those cases drain without paying
94
+ # an unconditional tick on timer-driven runloop pages.
95
+ browser.tick_real_time if browser.timer_wait_elapsed? || browser.async_io_pending?
96
+ check_stale
97
+ browser.attr(handle_id, name.to_s)
81
98
  end
82
99
 
83
100
  def click(keys = [], **opts)
84
- browser.click(handle_id, click_options(keys, opts))
101
+ check_stale
102
+ browser.click(handle_id, keys, **opts)
85
103
  end
86
104
 
87
105
  def right_click(keys = [], **opts)
88
- browser.right_click(handle_id, click_options(keys, opts))
106
+ check_stale
107
+ browser.right_click(handle_id, keys, **opts)
89
108
  end
90
109
 
91
110
  def double_click(keys = [], **opts)
92
- browser.double_click(handle_id, click_options(keys, opts))
111
+ check_stale
112
+ browser.double_click(handle_id, keys, **opts)
93
113
  end
94
114
 
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
115
+ def hover(**_opts)
116
+ check_stale
129
117
  browser.hover(handle_id)
118
+ self
130
119
  end
131
120
 
132
- def trigger(event)
133
- browser.trigger(handle_id, event)
121
+ def scroll_to(*, **) ; self ; end
122
+
123
+ # Capybara's standard rect API. No layout engine — but
124
+ # Discourse's `wait_for_animation` helper polls `element.rect[:x]`
125
+ # twice and waits for the values to stabilise, which they
126
+ # immediately do here (constant zeros) since we never animate.
127
+ # That unblocks every test guarded by an animation settle.
128
+ def rect
129
+ check_stale
130
+ {x: 0, y: 0, width: 0, height: 0, top: 0, left: 0, bottom: 0, right: 0}
134
131
  end
135
132
 
136
133
  def send_keys(*keys)
134
+ check_stale
137
135
  browser.send_keys(handle_id, keys)
136
+ true
138
137
  end
139
138
 
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')
139
+ def trigger(event)
140
+ check_stale
141
+ browser.dispatch_event(handle_id, event.to_s)
142
+ true
146
143
  end
147
-
148
144
  def drop(*args)
149
- items = args.flat_map {|arg| drop_items_for(arg) }
150
- browser.drop(handle_id, items)
145
+ check_stale
146
+ browser.drop(handle_id, args)
147
+ true
151
148
  end
152
149
 
153
- def scroll_by(_x, _y); end
154
- def scroll_to(_element, _alignment, _position = nil); end
150
+ def drag_to(target_node, **opts)
151
+ check_stale
152
+ target_node.check_stale if target_node.respond_to?(:check_stale)
153
+ target_handle = target_node.respond_to?(:handle_id) ? target_node.handle_id : target_node.native
154
+ browser.drag_to(handle_id, target_handle, **opts)
155
+ self
156
+ end
157
+ def set(value, **_)
158
+ check_stale
159
+ browser.set_value_with_events(handle_id, value)
160
+ end
155
161
 
156
- def submit
157
- browser.submit(handle_id)
162
+ def select_option
163
+ check_stale
164
+ browser.select_option(handle_id)
158
165
  end
159
166
 
160
- def find_xpath(xpath)
161
- browser.find_xpath(xpath, handle_id).map {|id| self.class.new(driver, id) }
167
+ def unselect_option
168
+ check_stale
169
+ browser.unselect_option(handle_id)
162
170
  end
163
171
 
164
- def find_css(css)
165
- browser.find_css(css, handle_id).map {|id| self.class.new(driver, id) }
172
+ def submit(*_)
173
+ check_stale
174
+ browser.submit_form(handle_id)
166
175
  end
167
176
 
168
- def rect
169
- browser.rect(handle_id)
177
+ def find_xpath(query)
178
+ browser.find_xpath(query, handle_id).map {|id| self.class.new(driver, id) }
170
179
  end
171
180
 
172
- def path
173
- browser.path(handle_id)
181
+ def find_css(query)
182
+ browser.find_css(query, handle_id).map {|id| self.class.new(driver, id) }
174
183
  end
175
184
 
176
185
  def shadow_root
177
- id = browser.shadow_root(handle_id)
178
- id ? Node.new(driver, id) : nil
186
+ check_stale
187
+ h = browser.shadow_root_handle(handle_id)
188
+ h && self.class.new(driver, h)
189
+ end
190
+ def disabled? = browser.disabled?(handle_id)
191
+ def selected? = browser.option_selected?(handle_id)
192
+ def checked? = !!self['checked']
193
+ def readonly? = !!self['readonly']
194
+ def obscured?(*) = !visible?
195
+ def synchronize(*) = yield
196
+ def style(names = [])
197
+ check_stale
198
+ browser.computed_style(handle_id, Array(names))
179
199
  end
180
-
181
- def style(_styles)
182
- raise NotImplementedError, 'The simulated driver does not process CSS'
200
+ def path
201
+ check_stale
202
+ browser.node_path(handle_id)
183
203
  end
184
204
 
205
+ # Both handle_id and context_gen are part of identity: after a
206
+ # cross-page reload the new element can land on the same handle
207
+ # id (JS-side counter resets per ctx) but a different generation.
208
+ # Capybara's synchronize uses `old_base == @base` to decide
209
+ # whether reload made progress, so id-only equality would mark
210
+ # a successful reload as a no-op and raise the original error.
185
211
  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
212
+ other.is_a?(Node) &&
213
+ other.handle_id == @handle_id &&
214
+ other.context_gen == @context_gen
193
215
  end
194
216
 
195
217
  private
196
218
 
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
219
+ def browser = @browser
220
+ def check_stale = browser.check_stale(handle_id, @initial_node, @context_gen)
233
221
  end
234
222
  end
235
223
  end