capybara-simulated 0.8.0 → 0.10.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 +43 -18
- data/lib/capybara/simulated/asset_cache.rb +25 -12
- data/lib/capybara/simulated/browser.rb +4182 -338
- data/lib/capybara/simulated/driver.rb +205 -30
- data/lib/capybara/simulated/errors.rb +12 -0
- data/lib/capybara/simulated/js/bridge.bundle.js +14151 -3473
- data/lib/capybara/simulated/minitest.rb +22 -0
- data/lib/capybara/simulated/node.rb +18 -13
- data/lib/capybara/simulated/quickjs_runtime.rb +55 -10
- data/lib/capybara/simulated/runtime_shared.rb +71 -33
- data/lib/capybara/simulated/stack_resolver.rb +5 -0
- data/lib/capybara/simulated/trace.rb +38 -11
- data/lib/capybara/simulated/trace_persistence.rb +30 -4
- data/lib/capybara/simulated/trace_viewer.html +561 -207
- data/lib/capybara/simulated/v8_runtime.rb +265 -70
- data/lib/capybara/simulated/version.rb +1 -1
- data/lib/capybara/simulated/worker_runtime.rb +34 -11
- data/lib/capybara/simulated.rb +14 -0
- data/vendor/js/vendor.bundle.js +14 -14
- metadata +15 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d70e169d98f5ead094c27e37f8f19fa01a3ea84aa7d808cf62a5d6dcbe3bf811
|
|
4
|
+
data.tar.gz: 50c9c13da2f7dfcc558fd7b92617bcf39ac45db930965e7152b632951bfc3770
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c7856b38b6376c6d5ac68065757f4beccac1b13a1bfd97c4453d2c8f1597ba0c82de66ec5e227ee703a185ca76758f8484e6e5b7ac72a5f90f493bb8d30f5049
|
|
7
|
+
data.tar.gz: b66c174eca3a6f89bdbc0df104cf302e212a0f3a63d72872b41a11b44d9fcd0b5d18c77d17ce0cb8f99b7bab24b01a4c81085942835deed19b66084a3d7a961b
|
data/README.md
CHANGED
|
@@ -10,11 +10,11 @@ Its correctness is held continuously to the same
|
|
|
10
10
|
[web-platform-tests](https://github.com/web-platform-tests/wpt) that
|
|
11
11
|
Chromium and Firefox use.
|
|
12
12
|
|
|
13
|
-
capybara-simulated is not a complete replacement for real-browser testing. But it sits between `rack_test` and a real browser, running the majority of system tests that don't depend on
|
|
13
|
+
capybara-simulated is not a complete replacement for real-browser testing. But it sits between `rack_test` and a real browser, running the majority of system tests that don't depend on pixel-accurate rendering.
|
|
14
14
|
|
|
15
15
|
## Is it a fit?
|
|
16
16
|
|
|
17
|
-
**A good fit when** your tests are JavaScript-driven but don't depend on
|
|
17
|
+
**A good fit when** your tests are JavaScript-driven but don't depend on pixel-accurate rendering:
|
|
18
18
|
|
|
19
19
|
- **No browser to install or boot** — no Chrome, no WebDriver, no Node toolchain; everything runs in-process. Execution is about **1.9×** faster than a headless browser on server-rendered / Hotwire apps and roughly at parity on JS-heavy SPAs (rusty_racer) — but the real win is skipping the browser's install, boot, and driver setup, not raw speed.
|
|
20
20
|
- **Deterministic** — a virtual clock and synchronous in-process execution remove the wall-clock timing, network, and rendering races that make headless-browser suites flaky.
|
|
@@ -22,9 +22,9 @@ capybara-simulated is not a complete replacement for real-browser testing. But i
|
|
|
22
22
|
- **Drop-in**: the Capybara DSL is unchanged — register `:simulated` and go. Just this gem plus one JS-engine gem.
|
|
23
23
|
- **Held to spec**: a vendored [web-platform-tests](https://github.com/web-platform-tests/wpt) conformance gate (the same DOM / HTML tests Chromium and Firefox hold themselves to), plus the full system suites of five real apps — Redmine / Forem / Avo / Mastodon / Discourse — run against the driver in [capybara-simulated-vs-world](https://github.com/ursm/capybara-simulated-vs-world).
|
|
24
24
|
|
|
25
|
-
**Reach for a real browser** (Selenium / Cuprite) **when** your tests need what this driver doesn't simulate **by design** — there's no rendering engine
|
|
25
|
+
**Reach for a real browser** (Selenium / Cuprite) **when** your tests need what this driver doesn't simulate **by design** — there's no rendering engine, so **pixel-accurate rendering** (glyph shaping — kerning, ligatures, bidi — the real line-breaking algorithm, multi-line `flex-wrap`) is out. `save_screenshot` does paint a real PNG, but it paints what the layout engine believes — enough to see what a test saw, not a visual-regression baseline. There *is* a coarse box-layout engine — enough that `getBoundingClientRect()`, `elementFromPoint()`, `obscured?`, the spatial selectors, scrolling, and drag-and-drop all work against real boxes — but it answers "where is this, roughly, and what's on top", not "how would this render".
|
|
26
26
|
|
|
27
|
-
Most of the rest runs in-process — including the things that usually mean "you need a real browser": **`within_frame`**, **multiple windows / tabs**, **WebSocket + Action Cable**, **EventSource**, and **Web Workers** all work. Each has constraints (JS engine, settle-timing,
|
|
27
|
+
Most of the rest runs in-process — including the things that usually mean "you need a real browser": **`within_frame`**, **multiple windows / tabs**, **WebSocket + Action Cable**, **EventSource**, and **Web Workers** all work. Each has constraints (JS engine, settle-timing, coarse layout); see [Capabilities & limits](#capabilities--limits).
|
|
28
28
|
|
|
29
29
|
## Install
|
|
30
30
|
|
|
@@ -35,19 +35,30 @@ gem 'rusty_racer', group: :test # JS engine — pick one
|
|
|
35
35
|
|
|
36
36
|
`bundle install`. Requires Ruby ≥ 3.3. The gem ships its JS bridge under `lib/capybara/simulated/js/` and the vendored JS deps under `vendor/js/`, so there's no Node toolchain at consume time.
|
|
37
37
|
|
|
38
|
+
### System libraries
|
|
39
|
+
|
|
40
|
+
**libvips** — Debian/Ubuntu `libvips42`, Homebrew `vips`, Gentoo `media-libs/vips`. The `ruby-vips`
|
|
41
|
+
gem comes with the driver and binds to it; the driver names the package it wants if the library is
|
|
42
|
+
missing.
|
|
43
|
+
|
|
44
|
+
**fontconfig** — text is MEASURED from the font file fontconfig resolves each CSS family to (the
|
|
45
|
+
same face a browser gets on the same machine), so a box's height and a line's wrapping depend on
|
|
46
|
+
the fonts installed. A machine with no fonts falls back to an estimate and measures text wider or
|
|
47
|
+
narrower than a real browser would.
|
|
48
|
+
|
|
38
49
|
### JS engine
|
|
39
50
|
|
|
40
51
|
The gem treats the JS engine as a soft dependency. Pick one of:
|
|
41
52
|
|
|
42
53
|
```ruby
|
|
43
|
-
gem 'rusty_racer', '>= 0.1
|
|
54
|
+
gem 'rusty_racer', '>= 0.2.1' # V8 (JIT, fastest per spec) — default
|
|
44
55
|
gem 'quickjs', '>= 0.19' # QuickJS (interpreter, smaller per-VM RAM —
|
|
45
56
|
gem 'quickjs-polyfill-intl' # wins when scaling parallel workers under
|
|
46
57
|
# a fixed memory budget). Intl lives in the
|
|
47
58
|
# companion gem since quickjs 0.19.
|
|
48
59
|
```
|
|
49
60
|
|
|
50
|
-
The V8 engine comes from [rusty_racer](https://github.com/ursm/rusty_racer), a rusty_v8-based Ruby binding with the native ES Module API, `ScriptCompiler::CachedData` snapshots, and per-frame realm contexts the driver builds on.
|
|
61
|
+
The V8 engine comes from [rusty_racer](https://github.com/ursm/rusty_racer), a rusty_v8-based Ruby binding with the native ES Module API, `ScriptCompiler::CachedData` snapshots, and per-frame realm contexts the driver builds on. 0.2.1 is the floor: the driver needs `Context#eval_void` (0.2.1) to wire a frame's `parent`/`top` without marshalling the WindowProxy it just assigned, and `Module#graph_async?` (0.2.0) to reject top-level await in a service worker.
|
|
51
62
|
|
|
52
63
|
The engine is auto-detected at boot; if both gems are present V8 wins. Override explicitly with `CSIM_JS_ENGINE=v8|quickjs` or `Capybara::Simulated::Driver.new(app, js_engine: :quickjs)`.
|
|
53
64
|
|
|
@@ -122,17 +133,23 @@ puts page.text
|
|
|
122
133
|
|
|
123
134
|
## Trace
|
|
124
135
|
|
|
125
|
-
Each Capybara action (`visit`, `click`, `set`, …) is recorded as a step in a per-test trace: URL before / after, console output and network requests during the step, plus elapsed and per-step durations. On action failure (and only then, by default) the post-action DOM is captured too.
|
|
136
|
+
Each Capybara action (`visit`, `click`, `set`, …) is recorded as a step in a per-test trace: URL before / after, console output and network requests during the step, plus elapsed and per-step durations. On action failure (and only then, by default) the post-action DOM is captured too, and a failing example gets one **screenshot** of the state it ended in.
|
|
126
137
|
|
|
127
|
-
Recording is **on by default** — fully in-memory, no files written unless you opt in via `CSIM_TRACE_DIR`. Wall-time overhead is run-to-run-variance equivalent because the expensive
|
|
138
|
+
Recording is **on by default** — fully in-memory, no files written unless you opt in via `CSIM_TRACE_DIR`. Wall-time overhead is run-to-run-variance equivalent because the expensive parts fire only where they cannot cost a test anything: the DOM is serialized on an action error and only once per action however many times Capybara retries it, and the screenshot is painted after the example.
|
|
128
139
|
|
|
129
140
|
### Modes (`CSIM_TRACE=…`)
|
|
130
141
|
|
|
131
|
-
| value | recording | DOM snapshot |
|
|
132
|
-
|
|
133
|
-
| (unset) / `on-failure` | yes (default) | per step on action error only |
|
|
134
|
-
| `full` | yes | after every action — debug-heavy |
|
|
135
|
-
| `off` | nothing recorded, `record_action` early-exits | — |
|
|
142
|
+
| value | recording | DOM snapshot | screenshot |
|
|
143
|
+
|---|---|---|---|
|
|
144
|
+
| (unset) / `on-failure` | yes (default) | per step on action error only | one, of the state a failing example ended in |
|
|
145
|
+
| `full` | yes | after every action — debug-heavy | per action too — debug-heavy |
|
|
146
|
+
| `off` | nothing recorded, `record_action` early-exits | — | — |
|
|
147
|
+
|
|
148
|
+
A screenshot is painted from the layout the driver already holds (see [Screenshots](#works-with-constraints)), so it shows what the test *saw*, and rides inline as a `data:` URL so a trace stays one file and the viewer still opens from `file://`.
|
|
149
|
+
|
|
150
|
+
**Where** it is taken matters more than it sounds. A paint is 33 ms on V8 and 517 ms on QuickJS for a small page — 236 ms and 1.6 s for a 2000-row table — so painting an action's *failure* would put it inside Capybara's retry window. Capybara retries for the whole wait: one failing click records **183 attempts** in its 2 s window here, and photographing them turned a click waiting on an overlay to clear from 35 ms into 563 ms, which is enough to turn an action a retry would have rescued into a failure.
|
|
151
|
+
|
|
152
|
+
So by default the trace paints exactly once, **after** the example and only if it failed, where no wait window is running. `CSIM_TRACE=full` adds a shot per action that *succeeded* — never a failing attempt, for the reason above, and the successful attempt is the interesting frame of a retried action anyway. Each shot is ~60-75 KB of inline base64, so a long trace in `full` mode is a multi-MB single-file HTML.
|
|
136
153
|
|
|
137
154
|
### Inspecting traces
|
|
138
155
|
|
|
@@ -176,7 +193,9 @@ capybara-simulated trace tmp/csim-traces/checkout_flow.json
|
|
|
176
193
|
# wrote /tmp/checkout_flow.html (then opens it in your browser)
|
|
177
194
|
```
|
|
178
195
|
|
|
179
|
-
By default the HTML is written to a temp file and opened in your browser. The viewer works straight from `file://` — the trace JSON is embedded inline,
|
|
196
|
+
By default the HTML is written to a temp file and opened in your browser. The viewer works straight from `file://` — the trace JSON is embedded inline, and there is no webfont or CDN to reach for, so it opens offline from a CI download.
|
|
197
|
+
|
|
198
|
+
It opens **on the step that failed** (that is what you came for, and on a long trace it is nowhere near the top), keeps that failure in a banner while you read any other step, and marks the step list: `!` where an action failed, `·` where one only logged a warning or error. Per step you get the URL before / after, console output, network requests — click a row for its headers and bodies — the error, and the post-action DOM snapshot as HTML. Screenshots sit in a side rail, which distinguishes the state the example ENDED in from the state at one step. `j` / `k` move, `f` jumps to the failure, and **Load JSON…** / drag-and-drop swaps in any other trace file.
|
|
180
199
|
|
|
181
200
|
`-o PATH` writes the HTML somewhere specific (`-o -` to stdout); `--no-open` skips launching the browser. Browser launching uses [launchy](https://rubygems.org/gems/launchy) when it's installed (`gem 'launchy'`, recommended for reliable cross-platform / WSL opening) and falls back to the platform opener (`xdg-open` / `open` / `start`) otherwise.
|
|
182
201
|
|
|
@@ -212,6 +231,10 @@ end
|
|
|
212
231
|
"url_before": null,
|
|
213
232
|
"url_after": "http://www.example.com/checkout",
|
|
214
233
|
"dom_after": null, // populated only on action error or in `full` mode
|
|
234
|
+
"shot_after": null, // …and the same moment PAINTED, as a `data:image/png;base64,…`
|
|
235
|
+
// URL — `CSIM_TRACE=full` only, and only for an action that
|
|
236
|
+
// SUCCEEDED. A failing example's final state is painted once
|
|
237
|
+
// into `metadata.screenshot` instead (see above).
|
|
215
238
|
"console": [{ "severity": "info", "message": "Stripe.js loaded" }],
|
|
216
239
|
"network": [{ "method": "GET", "url": "/checkout", "status": 200 }],
|
|
217
240
|
"elapsed_ms": 0,
|
|
@@ -234,6 +257,7 @@ Per visit, `<script src>`-referenced libraries (jQuery, Stimulus, …) re-evalua
|
|
|
234
257
|
|
|
235
258
|
- **`<script src>` parsing** dominates `visit` on JS-heavy pages. Each external script is fetched through the in-process Rack app, compiled, and run in the JS engine with bytecode cache hits from the base snapshot warmup.
|
|
236
259
|
- **CSS cascade resolution**: stylesheets are parsed once per distinct set of sources and cached content-addressably, so repeat visits and subsequent finds on the same page reuse the resolved cascade instead of re-parsing.
|
|
260
|
+
- **HTTP cache**: in-process fetches go through an RFC 9111 cache that is process-wide, like a persistent browser profile. `Capybara.reset_sessions!` keeps what that profile would: fresh `Cache-Control: immutable` responses (fingerprinted bundles), still-fresh `<script src>` / `<link rel=stylesheet>` sources and `@font-face` files; other responses are dropped so test-local server state reaches the app. A test whose app serves *new* bytes at a cacheable URL it already served — a stylesheet digested from a DB row that a rolled-back example reuses — gets the cold cache a fresh Playwright / Cuprite context starts with via `page.driver.clear_http_cache` (or `Capybara::Simulated.clear_http_cache` from a hook). Parsed stylesheets, compiled bytecode and decoded images are memoized by content, so they never go stale and are not affected.
|
|
237
261
|
- **DOM ops stay inside the JS engine** — find / has_? / event dispatch never cross the Ruby ↔ JS boundary for the actual tree walk; only the resulting handle ids do. Modify-heavy tests (SortableJS dragging thousands of items) run at JS-engine speed, not at host-call-IPC speed.
|
|
238
262
|
- **Polling** (Capybara `default_max_wait_time`) advances a *virtual* JS clock — timers fire as polling steps the clock forward, not in real time. A page that schedules `setTimeout(2000, x)` doesn't block for 2 s; the callback fires once polling has advanced the clock past it.
|
|
239
263
|
|
|
@@ -243,20 +267,21 @@ Most features run in-process; the notes below are mostly "works, but…", follow
|
|
|
243
267
|
|
|
244
268
|
### Works, with constraints
|
|
245
269
|
|
|
270
|
+
- **Layout-backed geometry** — a coarse box-layout engine (block flow, absolute / relative / fixed, flex and grid track sizing, percentage and viewport units, overflow clipping, the flat tree through shadow roots and slots, and cross-realm frames) backs the page-visible geometry, so there is *one* geometry that both the driver and the page's own JS read. That gives you `obscured?` (real occlusion, including out through nested iframes), the spatial selectors (`:above` / `:below` / `:left_of` / `:right_of` / `:near`), `scroll_to` / `scroll_by` clamped to the real scrollable range, geometry that follows `resize_to` (so a mobile-breakpoint test measures mobile boxes), and `drag_to` — which drives jQuery UI, SortableJS, Dragula and jsTree. Text runs are measured with the font's own advance widths, inline content shares a line, tables get real CSS Tables 3 column sizing, and a flex line resolves its items together (CSS Flexbox §9.7: bases, grow / shrink, the automatic minimum, gaps, `auto` margins, `justify-content` / `align-items`), so on the block, inline, absolute, flex and table shapes an app page is built from the boxes it reports match Chrome's to the sub-pixel (measured against headless Chrome, fixture by fixture). It is laid out once per mutation generation and only when something asks for geometry. What it does *not* do: glyph shaping (kerning / ligatures / bidi), the real line-breaking algorithm, multi-line flex (`flex-wrap`) — see [Out of scope](#out-of-scope-by-design--use-selenium--cuprite).
|
|
271
|
+
- **Screenshots** — `save_screenshot` (and `full: true` for the whole document) rasters the laid-out page: backgrounds, borders, images, text runs, `overflow` clipping, scroll offsets and `z-index` order. It reads the same boxes every geometry query reads, so a screenshot shows what the driver believes — which makes it useful for seeing what a failing test saw, and unsuitable as a pixel baseline. Not painted: `background-image`, `border-radius`, `opacity`, dashed / dotted borders (drawn solid), SVG, and stacking CONTEXTS (`z-index` is compared globally, not within a parent context). Form controls carry the UA box a browser gives them — border, padding, background, and their own font — so a `<button>` paints as one; what they don't paint is the WIDGET a browser draws inside it (a checkbox's tick, a select's arrow, the value inside a text field).
|
|
246
272
|
- **`within_frame` / `switch_to_frame`** (V8 engine) — each `<iframe>` runs its own scripts in its own per-frame realm; the DSL routes finds, reads, interactions, `evaluate_script`, and navigation into the active frame, nested frames included — the target frame's realm is rebuilt from the fetched document, the top page untouched. QuickJS has no nested browsing context, so `within_frame` raises there.
|
|
247
|
-
- **Multiple windows / tabs** (both engines) — each window is its own Browser + JS VM (own DOM, sessionStorage, history; cookies + localStorage shared). `open_new_window` / `within_window` / `switch_to_window` / `window_opened_by` drive them; JS `window.open` opens a real window, `window.opener` links back, and `postMessage` crosses windows. Only the active window's event loop runs, so a message is delivered when you switch to its window. `target="_blank"` opens with no opener (modern-browser default). `postMessage` carries real structured data (not a lossy JSON hop) — `Map` / `Set` / `Date` / `BigInt` / typed arrays / cyclic graphs all round-trip on V8 — and a `transfer`-list buffer moves **zero-copy** (backing store by token, source detached); only bare `undefined` collapses to `null` (Ruby has no distinct `undefined`).
|
|
273
|
+
- **Multiple windows / tabs** (both engines) — each window is its own Browser + JS VM (own DOM, sessionStorage, history; cookies + localStorage shared). `open_new_window` / `within_window` / `switch_to_window` / `window_opened_by` drive them; JS `window.open` opens a real window, `window.opener` links back, and `postMessage` crosses windows. Only the active window's event loop runs, so a message is delivered when you switch to its window. `target="_blank"` opens with no opener (modern-browser default). `postMessage` carries real structured data (not a lossy JSON hop) — `Map` / `Set` / `Date` / `BigInt` / typed arrays / cyclic graphs all round-trip on V8 — and a `transfer`-list buffer moves **zero-copy** (backing store by token, source detached); only bare `undefined` collapses to `null` (Ruby has no distinct `undefined`). `resize_to` moves the whole viewport: `innerWidth` / `innerHeight`, the `@media` cascade, `matchMedia` change + `resize` events, **and** the layout the geometry surface reports — so a mobile-breakpoint test measures mobile boxes. Each window has its own viewport. `maximize` / `fullscreen` restore the display size (a fixed 1024×768 — that part isn't configurable).
|
|
248
274
|
- **WebSocket + Action Cable** — `new WebSocket(url)` works in-process over the `rack.hijack` socket the Rack app hijacks (hand-rolled RFC6455, including subprotocol negotiation). The real `@rails/actioncable` consumer connects, subscribes, and receives broadcasts, so `turbo_stream_from` live updates work. Constraints: server pushes land at settle (not instant); the Cable app must use the **async / in-process** adapter (a real Redis adapter needs real Redis); binary frames are V8-only (QuickJS corrupts raw bytes across the host boundary — text, hence Action Cable, works on both engines). `EventSource` and Web Workers are likewise real (background reader threads draining at settle).
|
|
249
275
|
- **`fetch` / XHR** — synchronous through Rack: HTML / JSON round-trips work, but there's no streaming, no `Request#body` ReadableStream, and no concurrent requests.
|
|
250
276
|
- **`:hover` / `:focus-within`-gated content** — reachable two ways: call `element.hover` explicitly (we track the most-recently-hovered element and propagate `:hover` up its chain), or rely on the candidate-chain fallback (when the stateless cascade reports `display: none`, we re-evaluate with the candidate itself in the `:hover` set). Symmetric peers — N rows each with `tr:hover .icon` revealing `.icon`, queried as a bare `find('.icon')` — reveal all and Capybara raises `Capybara::Ambiguous`; scope the test (`find('tr', text: 'foo').hover` then `find('.icon')`), which is also more robust against real-browser flake.
|
|
251
277
|
|
|
252
278
|
### Out of scope (by design — use Selenium / Cuprite)
|
|
253
279
|
|
|
254
|
-
- **
|
|
255
|
-
- **Screenshots.**
|
|
280
|
+
- **Pixel-accurate rendering.** The layout engine (above) is coarse by design: text is measured from the font's advance widths but not SHAPED (no kerning, ligatures or bidi), lines wrap on an estimate rather than the real line-breaking algorithm, and a wrapping flex line (`flex-wrap`) stays on one line. (`resize_to` *does* move the viewport — what's fixed is the display it sits on, 1024×768.) Anything asserting *rendered appearance* — exact text wrapping, a sticky header's pixel offset, whether two boxes overlap by 3px — needs a real browser. That includes comparing screenshots: `save_screenshot` paints from this same coarse layout, so it will differ from a browser's PNG wherever the layout does.
|
|
256
281
|
|
|
257
282
|
## Architecture
|
|
258
283
|
|
|
259
|
-
- `lib/capybara/simulated/js/src/` — the entire DOM lives here, split across ~50 ES modules bundled into `bridge.bundle.js` (esbuild; no Node toolchain at consume time). `Document` / `Element` / `Text` / `DocumentFragment` / `ShadowRoot` classes; event dispatch (capture / target / bubble with shadow retargeting, via `dispatchEvent(target, event)`); a virtual `setTimeout` / `setInterval` / `requestAnimationFrame` clock; MutationObserver; custom-element registry; `Range` / `Selection`;
|
|
284
|
+
- `lib/capybara/simulated/js/src/` — the entire DOM lives here, split across ~50 ES modules bundled into `bridge.bundle.js` (esbuild; no Node toolchain at consume time). `Document` / `Element` / `Text` / `DocumentFragment` / `ShadowRoot` classes; event dispatch (capture / target / bubble with shadow retargeting, via `dispatchEvent(target, event)`); a virtual `setTimeout` / `setInterval` / `requestAnimationFrame` clock; MutationObserver; custom-element registry; `Range` / `Selection`; the cascade resolver for `display` / `visibility` / `text-transform`; and `layout.js`, the coarse box-layout engine the geometry surface reads from. Capybara's finds run through the vendored css-select (with css-what / css-tree) for CSS and xpathway for XPath — both true third parties under `vendor/js/`, executing in the same context as the page's JS.
|
|
260
285
|
- `lib/capybara/simulated/browser.rb` — Rack client, history stack, modal handler queue, virtual-clock anchor, trace recorder. Owns the JS runtime via `V8Runtime` or `QuickJSRuntime`. The hot operations (`find_css` / `find_xpath` / DOM ops / event dispatch) are single-`Context#call` round-trips returning handle id arrays; per-result iteration stays Ruby-side.
|
|
261
286
|
- `lib/capybara/simulated/v8_runtime.rb` / `quickjs_runtime.rb` — per-engine wrappers, common bits in `runtime_shared.rb`. The V8 base-snapshot (and the QuickJS bytecode equivalent) bakes in the bundled bridge + vendored deps, so a per-navigation context reset (V8) or pooled VM checkout (QuickJS) is sub-millisecond.
|
|
262
287
|
- `lib/capybara/simulated/driver.rb` — Capybara `Driver::Base` surface (visit / find / execute_script / window handling / modal / tracing API).
|
|
@@ -35,11 +35,12 @@ module Capybara
|
|
|
35
35
|
# (sha256(body)), so identical body → identical bytecode falls out
|
|
36
36
|
# naturally.
|
|
37
37
|
#
|
|
38
|
-
# No Mutex: MRI's Hash `[]`/`[]=` are atomic under the GVL
|
|
39
|
-
#
|
|
40
|
-
# threads
|
|
41
|
-
#
|
|
42
|
-
#
|
|
38
|
+
# No Mutex: MRI's Hash `[]`/`[]=` are atomic under the GVL. Concurrent access is
|
|
39
|
+
# real — async image loads (and keepalive fetches) run `rack_fetch` on background
|
|
40
|
+
# threads while the main thread fetches too — but every operation here is a single
|
|
41
|
+
# GVL-atomic Hash read or write of an immutable-once-built Entry, so the worst a
|
|
42
|
+
# racing reader sees is a partial `stored_at`/`max_age` pair on `refresh`: freshness
|
|
43
|
+
# computed against a transient mix, never a corrupted structure.
|
|
43
44
|
class AssetCache
|
|
44
45
|
Entry = Struct.new(:status, :headers, :body, :stored_at, :max_age, :no_cache, :immutable, keyword_init: true) do
|
|
45
46
|
# `must-revalidate` (RFC 9111 §5.2.2.2) only forbids reusing a
|
|
@@ -83,6 +84,12 @@ module Capybara
|
|
|
83
84
|
# adds to sprockets-served assets.
|
|
84
85
|
SAFE_VARY_FIELDS = %w[accept-encoding].freeze
|
|
85
86
|
|
|
87
|
+
# Immutable entries live for the PROCESS now (they survive every reset), and a long app
|
|
88
|
+
# suite mints fresh content-addressed URLs per example (Discourse's per-test themes and
|
|
89
|
+
# colour schemes → new `/theme-javascripts/<sha1>.js` / `/stylesheets/*_<hash>.css`), so
|
|
90
|
+
# the map is bounded the way `@@asset_src` is: wipe and restart when it fills.
|
|
91
|
+
MAX_ENTRIES = 4096
|
|
92
|
+
|
|
86
93
|
def initialize
|
|
87
94
|
@entries = {}
|
|
88
95
|
end
|
|
@@ -93,10 +100,15 @@ module Capybara
|
|
|
93
100
|
# Per-test reset path: keep entries the server marked
|
|
94
101
|
# `Cache-Control: immutable` (declared not to change for their
|
|
95
102
|
# freshness lifetime, so a kept entry can't shadow a later test's
|
|
96
|
-
# response
|
|
97
|
-
#
|
|
103
|
+
# response — a real browser keeps them across navigations the same
|
|
104
|
+
# way) and drop everything else, so test-local DB state reaches the
|
|
105
|
+
# app on the next visit. This read `reject!` — the exact inverse —
|
|
106
|
+
# from the day it was written: every immutable asset was re-fetched
|
|
107
|
+
# once per example (Discourse's `/extra-locales/<sha1>/en/mf.js`
|
|
108
|
+
# alone is a 41 ms Rails render), while max-age'd non-immutable
|
|
109
|
+
# responses survived into the next test.
|
|
98
110
|
def clear_volatile
|
|
99
|
-
@entries.
|
|
111
|
+
@entries.select! {|_, e| e.immutable }
|
|
100
112
|
end
|
|
101
113
|
|
|
102
114
|
def store(url, status, headers, body)
|
|
@@ -116,6 +128,7 @@ module Capybara
|
|
|
116
128
|
# Nothing useful to cache without a freshness signal or a
|
|
117
129
|
# validator to revalidate against.
|
|
118
130
|
return if max_age.nil? && h['etag'].nil? && h['last-modified'].nil?
|
|
131
|
+
@entries.clear if @entries.size >= MAX_ENTRIES && !@entries.key?(url)
|
|
119
132
|
@entries[url] = Entry.new(
|
|
120
133
|
status: status,
|
|
121
134
|
headers: h,
|
|
@@ -166,10 +179,10 @@ module Capybara
|
|
|
166
179
|
# at one day. Without this, a response carrying only `Last-Modified` is
|
|
167
180
|
# revalidated on every fetch, which is what a real browser AVOIDS for e.g.
|
|
168
181
|
# Discourse's content-hashed `/assets/*.js` (shipped with `Last-Modified`
|
|
169
|
-
# and no `Cache-Control`).
|
|
170
|
-
#
|
|
171
|
-
#
|
|
172
|
-
#
|
|
182
|
+
# and no `Cache-Control`). Here, cross-test staleness is bounded by
|
|
183
|
+
# `clear_volatile` dropping non-immutable entries at `reset!`; Browser's
|
|
184
|
+
# cross-session `@@asset_src` cache has its own argument (it only holds
|
|
185
|
+
# content-stable assets at content-hashed URLs).
|
|
173
186
|
HEURISTIC_FRESHNESS_CAP = 24 * 60 * 60
|
|
174
187
|
|
|
175
188
|
def heuristic_freshness(headers)
|