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,232 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rack'
|
|
4
|
+
require 'time'
|
|
5
|
+
|
|
6
|
+
module Capybara
|
|
7
|
+
module Simulated
|
|
8
|
+
# HTTP/1.1 (RFC 9111) compliant response cache for `Browser#rack_fetch`.
|
|
9
|
+
# Process-wide, GET-only, no Vary support — enough to mirror what a
|
|
10
|
+
# real browser does for Rails-fingerprinted assets
|
|
11
|
+
# (`Cache-Control: public, max-age=31536000, immutable`) which is the
|
|
12
|
+
# request-volume difference behind Cuprite/Selenium being able to skip
|
|
13
|
+
# 80–90 % of repeat asset fetches across a suite.
|
|
14
|
+
#
|
|
15
|
+
# Not cached:
|
|
16
|
+
# - Non-GET methods
|
|
17
|
+
# - Responses with `Cache-Control: no-store`
|
|
18
|
+
# - Responses with `Cache-Control: private` — per-user responses that a
|
|
19
|
+
# SHARED cache MUST NOT store (RFC 9111 §5.2.2.7). This cache is
|
|
20
|
+
# process-wide and shared across test sessions, so it is a shared
|
|
21
|
+
# cache for that purpose. (Forem's `/async_info/base_data` etc. send
|
|
22
|
+
# `max-age=0, private`; storing them only wastes memory on data that
|
|
23
|
+
# is always revalidated.)
|
|
24
|
+
# - Responses with `Vary` listing anything other than `Accept-Encoding`
|
|
25
|
+
# (which we ignore because we never send it)
|
|
26
|
+
# - Responses with no freshness signal at all (no max-age, no Expires,
|
|
27
|
+
# no ETag/Last-Modified to revalidate against)
|
|
28
|
+
#
|
|
29
|
+
# Cached but always revalidated:
|
|
30
|
+
# - Responses with `Cache-Control: no-cache`
|
|
31
|
+
# - Stale entries with ETag or Last-Modified validators
|
|
32
|
+
#
|
|
33
|
+
# The cache hands the body bytes back to bridge.js verbatim; downstream
|
|
34
|
+
# `__csim_runScript` bytecode caching is content-addressable
|
|
35
|
+
# (sha256(body)), so identical body → identical bytecode falls out
|
|
36
|
+
# naturally.
|
|
37
|
+
#
|
|
38
|
+
# No Mutex: MRI's Hash `[]`/`[]=` are atomic under the GVL, and Capybara
|
|
39
|
+
# test suites are serial per-process (parallel_tests forks rather than
|
|
40
|
+
# threads). A reader briefly racing a `refresh` may see a partial
|
|
41
|
+
# `stored_at`/`max_age` pair — that just means freshness is computed
|
|
42
|
+
# against a transient mix, never corrupted.
|
|
43
|
+
class AssetCache
|
|
44
|
+
Entry = Struct.new(:status, :headers, :body, :stored_at, :max_age, :no_cache, :immutable, keyword_init: true) do
|
|
45
|
+
# `must-revalidate` (RFC 9111 §5.2.2.2) only forbids reusing a
|
|
46
|
+
# response *once it has become stale* without revalidation — it
|
|
47
|
+
# does NOT force revalidation while the entry is still fresh, and
|
|
48
|
+
# this cache never serves a stale entry without revalidating
|
|
49
|
+
# anyway (lookup falls through to a conditional re-dispatch). So
|
|
50
|
+
# `must-revalidate` has no effect on freshness here. Only
|
|
51
|
+
# `no-cache` (§5.2.2.4), which requires validation before EVERY
|
|
52
|
+
# use, blocks a fresh entry. Conflating the two made Vite/Rails
|
|
53
|
+
# assets (`max-age=2419200, must-revalidate`) revalidate on every
|
|
54
|
+
# fetch instead of being served fresh like a real browser does.
|
|
55
|
+
def fresh?(now = Time.now)
|
|
56
|
+
return false if no_cache
|
|
57
|
+
# `max-age=0` (or absent) means the response is stale on arrival —
|
|
58
|
+
# a cache MUST revalidate before reuse (RFC 9111 §5.2.1.1). Ruby's
|
|
59
|
+
# `0` is truthy, so the value must be guarded explicitly; without
|
|
60
|
+
# `positive?`, a `max-age=0, must-revalidate` response (e.g. Forem's
|
|
61
|
+
# per-user `/async_info/base_data`, `/notifications/counts`) would be
|
|
62
|
+
# treated as fresh and served stale instead of revalidated.
|
|
63
|
+
return false unless max_age&.positive?
|
|
64
|
+
(now - stored_at) < max_age
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# RFC 9111 §3: status codes cacheable by default.
|
|
69
|
+
CACHEABLE_STATUSES = [200, 203, 204, 206, 300, 301, 308, 404, 405, 410, 414, 501].freeze
|
|
70
|
+
|
|
71
|
+
# `Vary` fields we can safely ignore because Simulated never sends
|
|
72
|
+
# the corresponding request header — the cached "no value" variant
|
|
73
|
+
# is always applicable. `Accept-Encoding` is the common one Rails
|
|
74
|
+
# adds to sprockets-served assets.
|
|
75
|
+
SAFE_VARY_FIELDS = %w[accept-encoding].freeze
|
|
76
|
+
|
|
77
|
+
def initialize
|
|
78
|
+
@entries = {}
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def lookup(url) = @entries[url]
|
|
82
|
+
def clear = @entries.clear
|
|
83
|
+
|
|
84
|
+
# Per-test reset path: keep entries the server marked
|
|
85
|
+
# `Cache-Control: immutable` (declared not to change for their
|
|
86
|
+
# freshness lifetime, so a kept entry can't shadow a later test's
|
|
87
|
+
# response) and drop everything else, so test-local DB state
|
|
88
|
+
# reaches the app on the next visit.
|
|
89
|
+
def clear_volatile
|
|
90
|
+
@entries.reject! {|_, e| e.immutable }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def store(url, status, headers, body)
|
|
94
|
+
return unless CACHEABLE_STATUSES.include?(status)
|
|
95
|
+
h = ensure_lowercase(headers)
|
|
96
|
+
return unless vary_compatible?(h['vary'])
|
|
97
|
+
cc = parse_cache_control(h['cache-control'])
|
|
98
|
+
# Honour the server's explicit directives only (RFC 9111) — no
|
|
99
|
+
# URL-shape heuristic. `no-store` MUST NOT be stored (§5.2.2.5),
|
|
100
|
+
# full stop; whether a URL "looks fingerprinted" is a guess that
|
|
101
|
+
# can misfire and serve a genuinely no-store response stale.
|
|
102
|
+
return if cc[:no_store]
|
|
103
|
+
# `private` is a per-user response a shared cache MUST NOT store
|
|
104
|
+
# (§5.2.2.7); this process-wide cache is shared across sessions.
|
|
105
|
+
return if cc[:private]
|
|
106
|
+
max_age = freshness_seconds(cc, h)
|
|
107
|
+
# Nothing useful to cache without a freshness signal or a
|
|
108
|
+
# validator to revalidate against.
|
|
109
|
+
return if max_age.nil? && h['etag'].nil? && h['last-modified'].nil?
|
|
110
|
+
@entries[url] = Entry.new(
|
|
111
|
+
status: status,
|
|
112
|
+
headers: h,
|
|
113
|
+
body: body,
|
|
114
|
+
stored_at: Time.now,
|
|
115
|
+
max_age: max_age,
|
|
116
|
+
no_cache: cc[:no_cache],
|
|
117
|
+
immutable: cc[:immutable] == true
|
|
118
|
+
)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def revalidation_headers(entry)
|
|
122
|
+
h = {}
|
|
123
|
+
h['If-None-Match'] = entry.headers['etag'] if entry.headers['etag']
|
|
124
|
+
h['If-Modified-Since'] = entry.headers['last-modified'] if entry.headers['last-modified']
|
|
125
|
+
h
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# RFC 9111 §4.3.4: a 304 refreshes the cached entry's freshness
|
|
129
|
+
# window without replacing the body. Mutation under the GVL is fine
|
|
130
|
+
# — see class-level Mutex note.
|
|
131
|
+
def refresh(entry, new_headers)
|
|
132
|
+
h = ensure_lowercase(new_headers)
|
|
133
|
+
cc = parse_cache_control(h['cache-control'])
|
|
134
|
+
entry.stored_at = Time.now
|
|
135
|
+
entry.max_age = freshness_seconds(cc, h) || entry.max_age
|
|
136
|
+
# RFC 9111 §4.3.4: a 304 UPDATES stored header fields with the ones
|
|
137
|
+
# it carries; it does not delete them. A bare 304 (no Cache-Control —
|
|
138
|
+
# the common ETag / Last-Modified revalidation) must therefore PRESERVE
|
|
139
|
+
# the stored no-cache flag, otherwise a `no-cache` resource would stop
|
|
140
|
+
# revalidating after its first 304 and start serving fresh. Only a 304
|
|
141
|
+
# that actually resends Cache-Control re-derives it.
|
|
142
|
+
entry.no_cache = h['cache-control'] ? cc[:no_cache] : entry.no_cache
|
|
143
|
+
entry
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
private
|
|
147
|
+
|
|
148
|
+
def freshness_seconds(cc, headers)
|
|
149
|
+
cc[:max_age] || expires_to_max_age(headers['expires'], headers['date']) || heuristic_freshness(headers)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# RFC 9111 §4.2.2: a response with no explicit freshness (no max-age, no
|
|
153
|
+
# Expires) but a `Last-Modified` MAY be assigned a heuristic lifetime; the
|
|
154
|
+
# common browser choice is 10% of (now − Last-Modified), and browsers cap
|
|
155
|
+
# it (an old `Last-Modified` shouldn't grant years of freshness) — we cap
|
|
156
|
+
# at one day. Without this, a response carrying only `Last-Modified` is
|
|
157
|
+
# revalidated on every fetch, which is what a real browser AVOIDS for e.g.
|
|
158
|
+
# Discourse's content-hashed `/assets/*.js` (shipped with `Last-Modified`
|
|
159
|
+
# and no `Cache-Control`). In the volatile asset cache, cross-visit
|
|
160
|
+
# staleness is bounded by `clear_volatile` dropping non-immutable entries
|
|
161
|
+
# per visit; Browser's cross-visit `@@asset_src` cache has its own
|
|
162
|
+
# argument (it only holds content-stable assets at content-hashed URLs).
|
|
163
|
+
HEURISTIC_FRESHNESS_CAP = 24 * 60 * 60
|
|
164
|
+
|
|
165
|
+
def heuristic_freshness(headers)
|
|
166
|
+
lm = headers['last-modified'] or return nil
|
|
167
|
+
t = (Time.httpdate(lm) rescue nil) or return nil
|
|
168
|
+
age = Time.now - t
|
|
169
|
+
age.positive? ? [(age * 0.1).to_i, HEURISTIC_FRESHNESS_CAP].min : nil
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def vary_compatible?(vary)
|
|
173
|
+
return true unless vary
|
|
174
|
+
fields = vary.to_s.downcase.split(',').map(&:strip)
|
|
175
|
+
return false if fields.include?('*')
|
|
176
|
+
(fields - SAFE_VARY_FIELDS).empty?
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Apps may return mixed-case header hashes on Rack 2; Rack 3 ships
|
|
180
|
+
# lowercase. Normalise once per store/refresh so subsequent O(1)
|
|
181
|
+
# lookups work either way without per-call linear scans. On Rack 3
|
|
182
|
+
# delegate to `Rack::Headers` (its `[]=` canonicalises keys); Rack
|
|
183
|
+
# 2 has no such class — downcase keys into a plain Hash ourselves.
|
|
184
|
+
# The `defined?` check is per-call because Rack loads after the gem
|
|
185
|
+
# entry point requires this file.
|
|
186
|
+
def ensure_lowercase(headers)
|
|
187
|
+
if defined?(::Rack::Headers)
|
|
188
|
+
return headers if headers.is_a?(::Rack::Headers)
|
|
189
|
+
out = ::Rack::Headers.new
|
|
190
|
+
headers.each {|k, v| out[k] = v }
|
|
191
|
+
out
|
|
192
|
+
else
|
|
193
|
+
out = {}
|
|
194
|
+
headers.each {|k, v| out[k.to_s.downcase] = v }
|
|
195
|
+
out
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
DIRECTIVE_RE = /\A(?<key>[a-zA-Z-]+)(?:=(?<val>.+))?\z/
|
|
200
|
+
|
|
201
|
+
def parse_cache_control(value)
|
|
202
|
+
out = {}
|
|
203
|
+
return out unless value
|
|
204
|
+
value.to_s.split(',').each {|part|
|
|
205
|
+
m = part.strip.match(DIRECTIVE_RE)
|
|
206
|
+
next unless m
|
|
207
|
+
case m[:key].downcase
|
|
208
|
+
when 'no-store' then out[:no_store] = true
|
|
209
|
+
when 'no-cache' then out[:no_cache] = true
|
|
210
|
+
when 'private' then out[:private] = true
|
|
211
|
+
when 'immutable' then out[:immutable] = true
|
|
212
|
+
when 'max-age' then out[:max_age] = m[:val].to_i if m[:val]
|
|
213
|
+
# `s-maxage` applies to SHARED caches only (RFC 9111 §5.2.2.10); a
|
|
214
|
+
# browser is a private cache and MUST ignore it — otherwise an
|
|
215
|
+
# `s-maxage=N` response with no `max-age` would be served fresh for
|
|
216
|
+
# N instead of revalidated, diverging from a real browser.
|
|
217
|
+
end
|
|
218
|
+
}
|
|
219
|
+
out
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def expires_to_max_age(expires, date)
|
|
223
|
+
return nil unless expires
|
|
224
|
+
exp_t = Time.httpdate(expires) rescue nil
|
|
225
|
+
return nil unless exp_t
|
|
226
|
+
base_t = (date && (Time.httpdate(date) rescue nil)) || Time.now
|
|
227
|
+
diff = (exp_t - base_t).to_i
|
|
228
|
+
diff.positive? ? diff : 0
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
end
|