Class: Capybara::Simulated::AssetCache

Inherits:
Object
  • Object
show all
Defined in:
lib/capybara/simulated/asset_cache.rb

Overview

HTTP/1.1 (RFC 9111) compliant response cache for ‘Browser#rack_fetch`. Process-wide, GET-only, no Vary support — enough to mirror what a real browser does for Rails-fingerprinted assets (`Cache-Control: public, max-age=31536000, immutable`) which is the request-volume difference behind Cuprite/Selenium being able to skip 80–90 % of repeat asset fetches across a suite.

Not cached:

- Non-GET methods
- Responses with `Cache-Control: no-store`
- Responses with `Cache-Control: private` — per-user responses that a
  SHARED cache MUST NOT store (RFC 9111 §5.2.2.7). This cache is
  process-wide and shared across test sessions, so it is a shared
  cache for that purpose. (Forem's `/async_info/base_data` etc. send
  `max-age=0, private`; storing them only wastes memory on data that
  is always revalidated.)
- Responses with `Vary` listing anything other than `Accept-Encoding`
  (which we ignore because we never send it)
- Responses with no freshness signal at all (no max-age, no Expires,
  no ETag/Last-Modified to revalidate against)

Cached but always revalidated:

- Responses with `Cache-Control: no-cache`
- Stale entries with ETag or Last-Modified validators

The cache hands the body bytes back to bridge.js verbatim; downstream ‘__csim_runScript` bytecode caching is content-addressable (sha256(body)), so identical body → identical bytecode falls out naturally.

No Mutex: MRI’s Hash ‘[]`/`[]=` are atomic under the GVL, and Capybara test suites are serial per-process (parallel_tests forks rather than threads). A reader briefly racing a `refresh` may see a partial `stored_at`/`max_age` pair — that just means freshness is computed against a transient mix, never corrupted.

Defined Under Namespace

Classes: Entry

Constant Summary collapse

CACHEABLE_STATUSES =

RFC 9111 §3: status codes cacheable by default.

[200, 203, 204, 206, 300, 301, 308, 404, 405, 410, 414, 501].freeze
SAFE_VARY_FIELDS =

‘Vary` fields we can safely ignore because Simulated never sends the corresponding request header — the cached “no value” variant is always applicable. `Accept-Encoding` is the common one Rails adds to sprockets-served assets.

%w[accept-encoding].freeze

Instance Method Summary collapse

Constructor Details

#initializeAssetCache

Returns a new instance of AssetCache.



77
78
79
# File 'lib/capybara/simulated/asset_cache.rb', line 77

def initialize
  @entries = {}
end

Instance Method Details

#clearObject



82
# File 'lib/capybara/simulated/asset_cache.rb', line 82

def clear      = @entries.clear

#clear_volatileObject

Per-test reset path: keep entries the server marked ‘Cache-Control: immutable` (declared not to change for their freshness lifetime, so a kept entry can’t shadow a later test’s response) and drop everything else, so test-local DB state reaches the app on the next visit.



89
90
91
# File 'lib/capybara/simulated/asset_cache.rb', line 89

def clear_volatile
  @entries.reject! {|_, e| e.immutable }
end

#lookup(url) ⇒ Object



81
# File 'lib/capybara/simulated/asset_cache.rb', line 81

def lookup(url) = @entries[url]

#refresh(entry, new_headers) ⇒ Object

RFC 9111 §4.3.4: a 304 refreshes the cached entry’s freshness window without replacing the body. Mutation under the GVL is fine — see class-level Mutex note.



131
132
133
134
135
136
137
138
139
140
141
142
143
144
# File 'lib/capybara/simulated/asset_cache.rb', line 131

def refresh(entry, new_headers)
  h  = ensure_lowercase(new_headers)
  cc = parse_cache_control(h['cache-control'])
  entry.stored_at = Time.now
  entry.max_age   = freshness_seconds(cc, h) || entry.max_age
  # RFC 9111 §4.3.4: a 304 UPDATES stored header fields with the ones
  # it carries; it does not delete them. A bare 304 (no Cache-Control —
  # the common ETag / Last-Modified revalidation) must therefore PRESERVE
  # the stored no-cache flag, otherwise a `no-cache` resource would stop
  # revalidating after its first 304 and start serving fresh. Only a 304
  # that actually resends Cache-Control re-derives it.
  entry.no_cache  = h['cache-control'] ? cc[:no_cache] : entry.no_cache
  entry
end

#revalidation_headers(entry) ⇒ Object



121
122
123
124
125
126
# File 'lib/capybara/simulated/asset_cache.rb', line 121

def revalidation_headers(entry)
  h = {}
  h['If-None-Match']     = entry.headers['etag']          if entry.headers['etag']
  h['If-Modified-Since'] = entry.headers['last-modified'] if entry.headers['last-modified']
  h
end

#store(url, status, headers, body) ⇒ Object



93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
# File 'lib/capybara/simulated/asset_cache.rb', line 93

def store(url, status, headers, body)
  return unless CACHEABLE_STATUSES.include?(status)
  h = ensure_lowercase(headers)
  return unless vary_compatible?(h['vary'])
  cc = parse_cache_control(h['cache-control'])
  # Honour the server's explicit directives only (RFC 9111) — no
  # URL-shape heuristic. `no-store` MUST NOT be stored (§5.2.2.5),
  # full stop; whether a URL "looks fingerprinted" is a guess that
  # can misfire and serve a genuinely no-store response stale.
  return if cc[:no_store]
  # `private` is a per-user response a shared cache MUST NOT store
  # (§5.2.2.7); this process-wide cache is shared across sessions.
  return if cc[:private]
  max_age = freshness_seconds(cc, h)
  # Nothing useful to cache without a freshness signal or a
  # validator to revalidate against.
  return if max_age.nil? && h['etag'].nil? && h['last-modified'].nil?
  @entries[url] = Entry.new(
    status:    status,
    headers:   h,
    body:      body,
    stored_at: Time.now,
    max_age:   max_age,
    no_cache:  cc[:no_cache],
    immutable: cc[:immutable] == true
  )
end