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
@@ -1,5 +1,7 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Capybara
2
4
  module Simulated
3
- VERSION = '0.0.7'
5
+ VERSION = '0.1.0'
4
6
  end
5
7
  end
@@ -0,0 +1,367 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openssl'
4
+ require 'securerandom'
5
+ require 'base64'
6
+ require 'json'
7
+
8
+ module Capybara
9
+ module Simulated
10
+ # Per-Browser virtual WebAuthn authenticator state. Mirrors the
11
+ # subset of the WebAuthn Level 2 spec real apps (Discourse's
12
+ # security key + passkey flows) exercise: ES256 keys, fmt="none"
13
+ # attestation, AAGUID per authenticator, excludeCredentials +
14
+ # userVerification + resident-key enforcement, and the CDP
15
+ # `WebAuthn.*` surface that tests drive through
16
+ # `cdp.with_virtual_authenticator`.
17
+ class WebauthnState
18
+ ES256_ALG = -7
19
+ P256_CRV = 1
20
+ KTY_EC2 = 2
21
+
22
+ # Flags byte in authenticator data (rfc8152 / WebAuthn level 2).
23
+ FLAG_UP = 0x01 # User Present
24
+ FLAG_UV = 0x04 # User Verified
25
+ FLAG_BE = 0x08 # Backup Eligible
26
+ FLAG_BS = 0x10 # Backup State
27
+ FLAG_AT = 0x40 # Attested credential data included
28
+ FLAG_ED = 0x80 # Extension data included
29
+
30
+ DEFAULT_AAGUID = ("\x00" * 16).b.freeze
31
+
32
+ # Mapped to DOMException name on the JS side. Apps branch on
33
+ # `err.name` (`'InvalidStateError'`, `'NotAllowedError'`, …) so
34
+ # the name has to survive the host-fn round-trip — `safe_call`
35
+ # would otherwise flatten exception classes to a plain nil. The
36
+ # host-fn wrapper in `runtime_shared.rb` rescues this class and
37
+ # returns `{error:, name:}` to the JS shim instead.
38
+ class Error < StandardError
39
+ attr_reader :webauthn_name
40
+ def initialize(name, message)
41
+ super(message)
42
+ @webauthn_name = name
43
+ end
44
+ end
45
+
46
+ def initialize
47
+ @authenticators = {}
48
+ @next_handle = 1
49
+ end
50
+
51
+ def add_virtual_authenticator(options)
52
+ opts = (options || {}).transform_keys(&:to_s)
53
+ handle = "csim-auth-#{@next_handle}"
54
+ @next_handle += 1
55
+ @authenticators[handle] = {
56
+ options: opts,
57
+ credentials: {},
58
+ aaguid: DEFAULT_AAGUID
59
+ }
60
+ handle
61
+ end
62
+
63
+ def remove_virtual_authenticator(handle)
64
+ @authenticators.delete(handle.to_s)
65
+ end
66
+
67
+ def add_credential(handle, credential)
68
+ auth = @authenticators[handle.to_s] or return
69
+ raw_id = Base64.urlsafe_decode64(credential['credentialId'].to_s)
70
+ priv = OpenSSL::PKey::EC.new(Base64.urlsafe_decode64(credential['privateKey'].to_s))
71
+ auth[:credentials][raw_id] = Credential.new(
72
+ raw_id: raw_id,
73
+ private_key: priv,
74
+ rp_id: credential['rpId'].to_s,
75
+ sign_count: credential['signCount'].to_i,
76
+ resident: !!credential['isResidentCredential'],
77
+ user_handle: credential['userHandle'] ? Base64.urlsafe_decode64(credential['userHandle'].to_s) : nil
78
+ )
79
+ raw_id
80
+ end
81
+
82
+ def remove_credential(handle, credential_id_b64)
83
+ auth = @authenticators[handle.to_s] or return
84
+ auth[:credentials].delete(Base64.urlsafe_decode64(credential_id_b64.to_s))
85
+ end
86
+
87
+ def get_credentials(handle)
88
+ auth = @authenticators[handle.to_s] or return []
89
+ auth[:credentials].values.map {|c|
90
+ {
91
+ 'credentialId' => Base64.urlsafe_encode64(c.raw_id.b, padding: false),
92
+ 'rpId' => c.rp_id,
93
+ 'isResidentCredential' => c.resident,
94
+ 'signCount' => c.sign_count,
95
+ 'userHandle' => c.user_handle ? Base64.urlsafe_encode64(c.user_handle.b, padding: false) : nil
96
+ }
97
+ }
98
+ end
99
+
100
+ def set_user_verified(handle, verified)
101
+ auth = @authenticators[handle.to_s] or return
102
+ auth[:options]['isUserVerified'] = !!verified
103
+ end
104
+
105
+ def create(json)
106
+ req = JSON.parse(json.to_s)
107
+ auth = pick_authenticator_for_create(req) or
108
+ raise Error.new('NotAllowedError', 'no compatible virtual authenticator')
109
+
110
+ rp_id = req.dig('rp', 'id').to_s
111
+ rp_id = host_from_origin(req['origin']) if rp_id.empty?
112
+ challenge = Base64.urlsafe_decode64(req['challenge'].to_s)
113
+ user_id = Base64.urlsafe_decode64(req.dig('user', 'id').to_s)
114
+ opts = auth[:options]
115
+
116
+ unless (req['pubKeyCredParams'] || []).any? {|p| p['alg'].to_i == ES256_ALG }
117
+ raise Error.new('NotSupportedError', 'no supported pubKeyCredParam (ES256 only)')
118
+ end
119
+
120
+ (req['excludeCredentials'] || []).each do |c|
121
+ if auth[:credentials][Base64.urlsafe_decode64(c['id'].to_s)]
122
+ raise Error.new('InvalidStateError', 'credential already registered on this authenticator')
123
+ end
124
+ end
125
+
126
+ sel = req['authenticatorSelection'] || {}
127
+ uv_req = sel['userVerification']
128
+ require_resident = %w[required preferred].include?(sel['residentKey']) ||
129
+ sel['requireResidentKey']
130
+ if require_resident && !opts['hasResidentKey']
131
+ raise Error.new('ConstraintError', 'resident-key required but authenticator does not support it')
132
+ end
133
+ if uv_req == 'required' && !user_verified?(opts)
134
+ raise Error.new('NotAllowedError', 'user verification required but not performed')
135
+ end
136
+
137
+ key = OpenSSL::PKey::EC.generate('prime256v1')
138
+ raw_id = SecureRandom.random_bytes(32)
139
+ is_resident = !!(opts['hasResidentKey'] && require_resident)
140
+ flags = FLAG_UP | FLAG_AT
141
+ flags |= FLAG_UV if user_verified?(opts)
142
+ # BE/BS mark the credential as syncable — clients use this to
143
+ # decide whether to surface "passkey" UX vs "this device only".
144
+ flags |= (FLAG_BE | FLAG_BS) if opts['hasResidentKey'] && opts['hasUserVerification']
145
+
146
+ auth_data = OpenSSL::Digest::SHA256.digest(rp_id) +
147
+ [flags].pack('C') +
148
+ [0].pack('N') +
149
+ auth[:aaguid] +
150
+ [raw_id.bytesize].pack('n') +
151
+ raw_id +
152
+ cose_ec2_pubkey(key)
153
+
154
+ attestation_object = cbor_encode(
155
+ 'fmt' => 'none',
156
+ 'attStmt' => {},
157
+ 'authData' => CborBytes.new(auth_data)
158
+ )
159
+
160
+ client_data = JSON.dump(
161
+ type: 'webauthn.create',
162
+ challenge: Base64.urlsafe_encode64(challenge.b, padding: false),
163
+ origin: req['origin'].to_s,
164
+ crossOrigin: false
165
+ )
166
+
167
+ auth[:credentials][raw_id] = Credential.new(
168
+ raw_id: raw_id,
169
+ private_key: key,
170
+ rp_id: rp_id,
171
+ sign_count: 0,
172
+ resident: is_resident || !!opts['hasResidentKey'],
173
+ user_handle: user_id
174
+ )
175
+
176
+ {
177
+ 'credentialId' => Base64.urlsafe_encode64(raw_id.b, padding: false),
178
+ 'clientDataJSON' => Base64.urlsafe_encode64(client_data.b, padding: false),
179
+ 'attestationObject' => Base64.urlsafe_encode64(attestation_object.b, padding: false)
180
+ }
181
+ end
182
+
183
+ def get(json)
184
+ req = JSON.parse(json.to_s)
185
+
186
+ rp_id = req['rpId'].to_s
187
+ rp_id = host_from_origin(req['origin']) if rp_id.empty?
188
+ challenge = Base64.urlsafe_decode64(req['challenge'].to_s)
189
+ allow = (req['allowCredentials'] || []).map {|c| Base64.urlsafe_decode64(c['id'].to_s) }
190
+ uv_req = req['userVerification']
191
+
192
+ pick = pick_credential_for_get(rp_id, allow)
193
+ raise Error.new('NotAllowedError', 'no matching credential') unless pick
194
+ auth, cred = pick
195
+ opts = auth[:options]
196
+
197
+ if uv_req == 'required' && !user_verified?(opts)
198
+ raise Error.new('NotAllowedError', 'user verification required but not performed')
199
+ end
200
+
201
+ cred.sign_count += 1
202
+
203
+ flags = FLAG_UP
204
+ flags |= FLAG_UV if user_verified?(opts)
205
+ flags |= (FLAG_BE | FLAG_BS) if cred.resident && opts['hasUserVerification']
206
+ auth_data = OpenSSL::Digest::SHA256.digest(rp_id) +
207
+ [flags].pack('C') +
208
+ [cred.sign_count].pack('N')
209
+
210
+ client_data = JSON.dump(
211
+ type: 'webauthn.get',
212
+ challenge: Base64.urlsafe_encode64(challenge.b, padding: false),
213
+ origin: req['origin'].to_s,
214
+ crossOrigin: false
215
+ )
216
+
217
+ signed_payload = auth_data + OpenSSL::Digest::SHA256.digest(client_data)
218
+ signature = cred.private_key.sign(OpenSSL::Digest::SHA256.new, signed_payload)
219
+
220
+ # WebAuthn level 2: userHandle is only returned for resident
221
+ # (discoverable) credentials. Real authenticators don't surface
222
+ # the bound user for plain server-side credentials.
223
+ user_handle_out = cred.resident && cred.user_handle && !cred.user_handle.empty? ?
224
+ Base64.urlsafe_encode64(cred.user_handle.b, padding: false) : nil
225
+
226
+ {
227
+ 'credentialId' => Base64.urlsafe_encode64(cred.raw_id.b, padding: false),
228
+ 'clientDataJSON' => Base64.urlsafe_encode64(client_data.b, padding: false),
229
+ 'authenticatorData' => Base64.urlsafe_encode64(auth_data.b, padding: false),
230
+ 'signature' => Base64.urlsafe_encode64(signature.b, padding: false),
231
+ 'userHandle' => user_handle_out
232
+ }
233
+ end
234
+
235
+ private
236
+
237
+ def pick_authenticator_for_create(req)
238
+ sel = req['authenticatorSelection'] || {}
239
+ uv_req = sel['userVerification']
240
+ require_resident = %w[required preferred].include?(sel['residentKey']) ||
241
+ sel['requireResidentKey']
242
+ compatible = @authenticators.values.select {|a|
243
+ opts = a[:options]
244
+ ok = true
245
+ ok &&= !!opts['hasResidentKey'] if require_resident
246
+ ok &&= user_verified?(opts) if uv_req == 'required'
247
+ ok
248
+ }
249
+ compatible.first || @authenticators.values.first
250
+ end
251
+
252
+ def pick_credential_for_get(rp_id, allow_ids)
253
+ rp_match = ->(c) { c.rp_id.empty? || rp_id.empty? || c.rp_id == rp_id }
254
+ if allow_ids.any?
255
+ allow_ids.each do |id|
256
+ @authenticators.each_value do |a|
257
+ c = a[:credentials][id]
258
+ return [a, c] if c && rp_match.call(c)
259
+ end
260
+ end
261
+ return nil
262
+ end
263
+ # Discoverable (resident) first, then any credential bound to
264
+ # this rpId — Chrome's virtual authenticator falls back the
265
+ # same way in 2FA-only flows.
266
+ [true, false].each do |resident_only|
267
+ @authenticators.each_value do |a|
268
+ c = a[:credentials].values.find {|x|
269
+ (!resident_only || x.resident) && rp_match.call(x)
270
+ }
271
+ return [a, c] if c
272
+ end
273
+ end
274
+ nil
275
+ end
276
+
277
+ def user_verified?(opts)
278
+ return false unless opts['hasUserVerification']
279
+ opts.fetch('isUserVerified', opts['automaticPresenceSimulation'])
280
+ end
281
+
282
+ def host_from_origin(origin)
283
+ return '' if origin.nil? || origin.to_s.empty?
284
+ URI.parse(origin.to_s).host.to_s
285
+ rescue URI::InvalidURIError
286
+ ''
287
+ end
288
+
289
+ def cose_ec2_pubkey(ec_key)
290
+ bytes = ec_key.public_key.to_octet_string(:uncompressed)
291
+ x = bytes[1, 32].b
292
+ y = bytes[33, 32].b
293
+ cbor_encode(
294
+ 1 => KTY_EC2,
295
+ 3 => ES256_ALG,
296
+ -1 => P256_CRV,
297
+ -2 => CborBytes.new(x),
298
+ -3 => CborBytes.new(y)
299
+ )
300
+ end
301
+
302
+ # No tags, no floats, no indefinite-length items — just the
303
+ # primitives WebAuthn attestation + COSE keys need.
304
+ def cbor_encode(value)
305
+ case value
306
+ when Integer
307
+ if value >= 0
308
+ cbor_head(value, 0x00)
309
+ else
310
+ cbor_head(-1 - value, 0x20)
311
+ end
312
+ when String
313
+ utf8 = value.encode(Encoding::UTF_8)
314
+ cbor_head(utf8.bytesize, 0x60) + utf8.b
315
+ when CborBytes
316
+ cbor_head(value.bytes.bytesize, 0x40) + value.bytes
317
+ when Hash
318
+ out = cbor_head(value.size, 0xA0)
319
+ value.each do |k, v|
320
+ out << cbor_encode(k)
321
+ out << cbor_encode(v)
322
+ end
323
+ out
324
+ when Array
325
+ out = cbor_head(value.size, 0x80)
326
+ value.each {|e| out << cbor_encode(e) }
327
+ out
328
+ when true, false
329
+ [value ? 0xF5 : 0xF4].pack('C').b
330
+ when nil
331
+ [0xF6].pack('C').b
332
+ else
333
+ raise "Unsupported CBOR type: #{value.class}"
334
+ end
335
+ end
336
+
337
+ def cbor_head(n, type_tag)
338
+ if n < 24
339
+ [type_tag | n].pack('C').b
340
+ elsif n < 256
341
+ [type_tag | 24, n].pack('CC').b
342
+ elsif n < 65_536
343
+ [type_tag | 25, n].pack('Cn').b
344
+ elsif n < 2**32
345
+ [type_tag | 26, n].pack('CN').b
346
+ else
347
+ [type_tag | 27, n].pack('CQ>').b
348
+ end
349
+ end
350
+
351
+ CborBytes = Struct.new(:bytes)
352
+
353
+ class Credential
354
+ attr_accessor :raw_id, :private_key, :rp_id, :sign_count, :resident, :user_handle
355
+
356
+ def initialize(raw_id:, private_key:, rp_id:, sign_count: 0, resident: false, user_handle: nil)
357
+ @raw_id = raw_id
358
+ @private_key = private_key
359
+ @rp_id = rp_id
360
+ @sign_count = sign_count
361
+ @resident = resident
362
+ @user_handle = user_handle
363
+ end
364
+ end
365
+ end
366
+ end
367
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Capybara
4
+ module Simulated
5
+ # `Capybara::Node::WhitespaceNormalizer` was extracted in capybara
6
+ # 3.40 — Forem (and other apps that pin `~> 3.37`) still ship the
7
+ # older release where the module doesn't exist. Inline copies of
8
+ # `normalize_spacing` / `normalize_visible_spacing` keep the gem
9
+ # working against the whole 3.37+ range; they're stable text-
10
+ # processing helpers, no behavioural drift to worry about.
11
+ module WhitespaceNormalizer
12
+ NON_BREAKING_SPACE = " "
13
+ LINE_SEPERATOR = "
"
14
+ PARAGRAPH_SEPERATOR = "
"
15
+ BREAKING_SPACES = "[[:space:]&&[^#{NON_BREAKING_SPACE}]]".freeze
16
+ SQUEEZED_SPACES = " \n\f\t\v#{LINE_SEPERATOR}#{PARAGRAPH_SEPERATOR}".freeze
17
+ LEADING_SPACES = /\A#{BREAKING_SPACES}+/
18
+ TRAILING_SPACES = /#{BREAKING_SPACES}+\z/
19
+ ZERO_WIDTH_SPACE = "​"
20
+ LEFT_TO_RIGHT_MARK = "‎"
21
+ RIGHT_TO_LEFT_MARK = "‏"
22
+ REMOVED_CHARACTERS = [ZERO_WIDTH_SPACE, LEFT_TO_RIGHT_MARK, RIGHT_TO_LEFT_MARK].join
23
+ EMPTY_LINES = /[\ \n]*\n[\ \n]*/
24
+
25
+ def normalize_spacing(text)
26
+ text
27
+ .delete(REMOVED_CHARACTERS)
28
+ .tr(SQUEEZED_SPACES, ' ')
29
+ .squeeze(' ')
30
+ .sub(LEADING_SPACES, '')
31
+ .sub(TRAILING_SPACES, '')
32
+ .tr(NON_BREAKING_SPACE, ' ')
33
+ end
34
+
35
+ def normalize_visible_spacing(text)
36
+ text
37
+ .squeeze(' ')
38
+ .gsub(EMPTY_LINES, "\n")
39
+ .sub(LEADING_SPACES, '')
40
+ .sub(TRAILING_SPACES, '')
41
+ .tr(NON_BREAKING_SPACE, ' ')
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Capybara
4
+ module Simulated
5
+ # Engine-uniform adapter Browser#run_worker drives. Each engine
6
+ # class (`V8Runtime`, `QuickJSRuntime`) has a `build_worker` class
7
+ # method that constructs the engine-specific Context/VM and wires
8
+ # it through these five callbacks. Worker thread doesn't care
9
+ # which engine it's running on; it just calls `eval` / `call` /
10
+ # `drain_microtasks` / `drain_timers` / `has_ready_timer?` /
11
+ # `dispose`.
12
+ class WorkerRuntime
13
+ def initialize(eval_fn:, call_fn:, drain_microtasks:, drain_timers:, has_ready_timer:, dispose:)
14
+ @eval = eval_fn
15
+ @call = call_fn
16
+ @drain_microtasks = drain_microtasks
17
+ @drain_timers = drain_timers
18
+ @has_ready_timer = has_ready_timer
19
+ @dispose = dispose
20
+ end
21
+
22
+ def eval(src) = @eval.call(src)
23
+ def call(name, *args) = @call.call(name, *args)
24
+ def drain_microtasks = @drain_microtasks.call
25
+ def drain_timers = @drain_timers.call
26
+ def has_ready_timer? = @has_ready_timer.call
27
+ def dispose = @dispose.call
28
+ end
29
+ end
30
+ end
@@ -1,10 +1,37 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'capybara'
2
4
  require 'capybara/simulated/version'
3
- require 'capybara/simulated/errors'
4
- require 'capybara/simulated/browser'
5
5
  require 'capybara/simulated/driver'
6
- require 'capybara/simulated/node'
6
+
7
+ module Capybara
8
+ module Simulated
9
+ # Canonical list of JS-engine identifiers. Used by:
10
+ # - `Browser#build_runtime` to dispatch to the right Runtime class
11
+ # - `Browser#detect_js_engine` to pick a default when neither
12
+ # `CSIM_JS_ENGINE` nor `js_engine:` is given (order = preference,
13
+ # so V8 wins ties because it's faster per-spec)
14
+ # - vs-world's `csim_rspec.rb` / `csim_minitest.rb` to validate
15
+ # YAML `engine:` keys at load time
16
+ JS_ENGINES = %i[v8 quickjs].freeze
17
+
18
+ # Host wrappers (csim_rspec / csim_minitest) set these just before
19
+ # `driven_by :simulated` to seed the next constructed driver's
20
+ # viewport + user-agent — used when the host's spec asked for a
21
+ # mobile-shape driver (Discourse's `mobile: true`-tagged
22
+ # describes). Discourse uses BOTH viewport breakpoints AND UA
23
+ # sniffing to pick mobile/desktop rendering, so both have to be
24
+ # set together. Read-and-cleared by the register_driver block.
25
+ class << self
26
+ attr_accessor :next_driver_viewport, :next_driver_user_agent
27
+ end
28
+ end
29
+ end
7
30
 
8
31
  Capybara.register_driver :simulated do |app|
9
- Capybara::Simulated::Driver.new(app)
32
+ vp = Capybara::Simulated.next_driver_viewport
33
+ ua = Capybara::Simulated.next_driver_user_agent
34
+ Capybara::Simulated.next_driver_viewport = nil
35
+ Capybara::Simulated.next_driver_user_agent = nil
36
+ Capybara::Simulated::Driver.new(app, js_engine: ENV['CSIM_JS_ENGINE']&.to_sym, viewport: vp, user_agent: ua)
10
37
  end
@@ -1 +1,3 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'capybara/simulated'