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,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