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
|
@@ -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
|
data/lib/capybara/simulated.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
data/lib/capybara-simulated.rb
CHANGED