Skip to main content

tropel_engine/
js_bootstrap.rs

1//! Per-VU QuickJS context bootstrap.
2//!
3//! Moved out of the former `engine.rs` god-file.
4
5use std::borrow::Cow;
6use std::sync::atomic::{AtomicBool, Ordering};
7use std::sync::{Arc, Mutex};
8use std::time::Duration;
9
10/// P2 line 286: per-VU QuickJS heap cap (bytes). Configurable via
11/// `TROPEL_JS_HEAP_MB` env var (default 10 MB).
12pub(crate) fn js_heap_bytes() -> usize {
13    std::env::var("TROPEL_JS_HEAP_MB")
14        .ok()
15        .and_then(|s| s.parse::<usize>().ok())
16        .map(|mb| mb * 1024 * 1024)
17        .unwrap_or(10 * 1024 * 1024)
18}
19
20/// P2 line 286: per-eval JS execution deadline (seconds). Configurable via
21/// `TROPEL_JS_DEADLINE_SECS` env var (default 10 s).
22pub(crate) fn js_deadline_secs() -> Duration {
23    std::env::var("TROPEL_JS_DEADLINE_SECS")
24        .ok()
25        .and_then(|s| s.parse::<u64>().ok())
26        .map(Duration::from_secs)
27        .unwrap_or(Duration::from_secs(10))
28}
29use tropel_sandbox::config::SandboxConfig;
30use tropel_sandbox::state::SharedPmState;
31use tropel_sdk::error::TropelError;
32use tropel_sdk::traits::DriverHttpClient;
33use tropel_sdk::Result;
34
35/// Version of the shim bundle, INDEPENDENT of the engine version (P4b).
36///
37/// The shims (`js/`) are JS-only and can ship as assets without a Tropel
38/// release — so a handshake that compares engine version alone can't tell
39/// whether two runs used the same `pm.*`/`trp.*` semantics. Bump this on any
40/// behavioural change to the bundle. Surfaced in `tropel version`; the
41/// engine↔shim comparison itself is the P6 version-handshake work.
42pub(crate) const SHIM_BUNDLE_VERSION: &str = "0.1.0";
43
44/// One shim library in the embedded set.
45///
46/// W2 line 182 used to be a live bug class here: a `const JS_SHIM_BUNDLE`
47/// concat and `ShimBundle::default()` were TWO hand-maintained lists of the
48/// same shims, and they drifted — the concat carried 5 while the default
49/// carried 6, so bru.js was compiled into the binary but NEVER evaluated
50/// (`typeof bru === 'undefined'` in every engine VU). This enum is now the
51/// single list; every bundle, including the one that gets compiled to
52/// bytecode, is derived from it, so the two can no longer disagree.
53#[derive(Clone, Copy, PartialEq, Eq, Debug)]
54pub enum Shim {
55    /// `globalThis.__tropelDeepEqual` — the one canonical deep-equality
56    /// implementation. A HARD dependency of [`Shim::Pm`], [`Shim::Chai`] and
57    /// [`Shim::Lodash`], all three of which call it by name, so it must be
58    /// present in, and first in, every bundle that carries any of them.
59    DeepEqual,
60    /// `check`, `group`, `Counter`, `Gauge`, `Rate`, `Trend` — the k6
61    /// builtins a script can call WITHOUT importing anything.
62    ///
63    /// These used to be installed by pm.js, which forced every non-Postman
64    /// format to load the whole 70 KB Postman shim just to get `check()`.
65    /// Extracted so a format bundle can drop pm.js without breaking them, so
66    /// this variant belongs in EVERY bundle (TR-501).
67    K6Core,
68    /// `pm`, `postman`, the configured canonical namespace (`trp` by
69    /// default), and the k6-style `check` / `group` / `Counter` / `Gauge` /
70    /// default) — the Postman surface only. The k6-style `check` / `group` /
71    /// `Counter` / `Gauge` / `Rate` / `Trend` globals used to live here too,
72    /// which is what coupled every format to pm.js; they are [`Shim::K6Core`]
73    /// now.
74    Pm,
75    /// `chai` / `expect`. Soft-referenced by pm.js at CALL time
76    /// (`typeof chai !== 'undefined' && chai.expect`, pm.js:832), which falls
77    /// back to pm.js's own `AssertChain`, so dropping chai degrades assertion
78    /// fidelity but never throws.
79    Chai,
80    /// `_` (lodash subset).
81    Lodash,
82    /// `CryptoJS` — a dispatcher over the `__tropel_native_*` bridges.
83    CryptoJs,
84    /// `exec` (k6's `exec` module surface) and the bare `test` global.
85    Exec,
86    /// `bru`, `req`, `res` — the Bruno scripting API.
87    Bru,
88    /// `fetch` — the same HTTP client `pm.sendRequest` rides, wearing the
89    /// interface every modern script is written against (TR-475). QuickJS
90    /// ships none, so a script using one got a bare ReferenceError. Last in
91    /// the bundle: it defines a global and depends on no other shim.
92    Fetch,
93}
94
95impl Shim {
96    /// Canonical evaluation order, and the full embedded set.
97    ///
98    /// [`Shim::DeepEqual`] MUST stay first: pm, chai and lodash all call
99    /// `globalThis.__tropelDeepEqual`. The remaining order is preserved from
100    /// the pre-TR-501 bundle so no behaviour moves with this refactor.
101    pub const ALL: [Shim; 9] = [
102        Shim::DeepEqual,
103        Shim::K6Core,
104        Shim::Pm,
105        Shim::Chai,
106        Shim::Lodash,
107        Shim::CryptoJs,
108        Shim::Exec,
109        Shim::Bru,
110        Shim::Fetch,
111    ];
112
113    /// The section-header name this shim is rendered under.
114    pub fn name(self) -> &'static str {
115        match self {
116            Shim::DeepEqual => "deep-equal-shim",
117            Shim::K6Core => "k6-core-shim",
118            Shim::Pm => "pm-shim",
119            Shim::Chai => "chai-shim",
120            Shim::Lodash => "lodash-shim",
121            Shim::CryptoJs => "cryptojs-shim",
122            Shim::Exec => "exec-shim",
123            Shim::Bru => "bru-shim",
124            Shim::Fetch => "fetch-shim",
125        }
126    }
127
128    /// The embedded source text.
129    pub fn source(self) -> &'static str {
130        match self {
131            Shim::DeepEqual => include_str!("../js/shared/deep-equal.js"),
132            Shim::K6Core => include_str!("../js/shared/k6-core.js"),
133            Shim::Pm => include_str!("../js/scripting-api/pm.js"),
134            Shim::Chai => include_str!("../js/chai/chai-shim.js"),
135            Shim::Lodash => include_str!("../js/lodash/lodash-shim.js"),
136            Shim::CryptoJs => include_str!("../js/cryptojs-shim/cryptojs.js"),
137            Shim::Exec => include_str!("../js/exec/exec.js"),
138            Shim::Bru => include_str!("../js/scripting-api/bru.js"),
139            Shim::Fetch => include_str!("../js/scripting-api/fetch.js"),
140        }
141    }
142}
143
144/// One shim library: a name + its source text.
145pub struct ShimEntry(pub &'static str, pub Cow<'static, str>);
146
147/// The shim bundle for a JS context (P4b: injectable, defaults to the
148/// embedded set).
149///
150/// - **Native / CLI keeps the embedded default** — reproducibility matters; a
151///   load test's semantics must not change because someone dropped a
152///   different `pm.js` beside the binary.
153/// - **The web client supplies its own** — a `pm.*` fix ships as a JS asset
154///   with the web app: no wasm rebuild, no Tropel release.
155pub struct ShimBundle(pub Vec<ShimEntry>);
156
157impl ShimBundle {
158    /// Build a bundle from an explicit shim list, in the order given.
159    pub fn from_shims(shims: &[Shim]) -> Self {
160        Self(
161            shims
162                .iter()
163                .map(|s| ShimEntry(s.name(), Cow::Borrowed(s.source())))
164                .collect(),
165        )
166    }
167
168    /// Render the bundle to source text, concatenated with section headers.
169    ///
170    /// This is the ONLY thing that turns a bundle into JS — the bytecode path
171    /// compiles this string too, so there is no second, hand-maintained
172    /// concatenation that can drift out of step with the entry list (W2 line
173    /// 182: that drift is what left bru.js compiled but never evaluated).
174    pub fn render(&self) -> String {
175        let mut out = String::new();
176        for ShimEntry(name, src) in &self.0 {
177            out.push_str(&format!("// ==== shim: {name} ====\n"));
178            out.push_str(src);
179            out.push('\n');
180        }
181        out
182    }
183
184    /// Stable process-lifetime identity of this bundle, used to key the
185    /// compiled-bytecode cache ([`shim_bytecode_for`]).
186    ///
187    /// Before TR-501 the cache was a single `OnceLock<Option<Vec<u8>>>` keyed
188    /// on nothing, so `bootstrap_shims` could only take the bytecode path when
189    /// `shim.is_default()` — reusing that static for any other bundle would
190    /// have served the wrong bytecode. The effect was a PESSIMISATION: every
191    /// gated bundle fell through to per-VU source eval, which cost more heap
192    /// than the shims the gating dropped (✅MEAS on master, Apple M2:
193    /// default 497,584 B/VU vs http-only-gated 557,824 B/VU).
194    ///
195    /// Keying: for a `Cow::Borrowed` the source is `&'static str`, so
196    /// `(address, length)` is a SOUND identity — a `'static` string is never
197    /// freed, so its address cannot be recycled, and two `&'static str` with
198    /// the same address and length are the same bytes. (`is_default()` used
199    /// exactly this `std::ptr::eq` argument before it was deleted.) For a
200    /// `Cow::Owned` the address CAN be recycled after a free, so the content
201    /// is hashed instead. The variant is folded in so the two can never
202    /// collide.
203    ///
204    /// This is O(number of shims), not O(bundle bytes) — it runs once per VU
205    /// spawn, so hashing ~220 KB of source per VU would have been its own
206    /// regression.
207    pub(crate) fn key(&self) -> BundleKey {
208        use std::hash::{Hash, Hasher};
209        let mut h = std::collections::hash_map::DefaultHasher::new();
210        self.0.len().hash(&mut h);
211        for ShimEntry(name, src) in &self.0 {
212            name.hash(&mut h);
213            match src {
214                Cow::Borrowed(s) => {
215                    0u8.hash(&mut h);
216                    (s.as_ptr() as usize).hash(&mut h);
217                    s.len().hash(&mut h);
218                }
219                Cow::Owned(s) => {
220                    1u8.hash(&mut h);
221                    s.hash(&mut h);
222                }
223            }
224        }
225        BundleKey(h.finish())
226    }
227}
228
229impl Default for ShimBundle {
230    fn default() -> Self {
231        Self::from_shims(&Shim::ALL)
232    }
233}
234
235/// TR-501: the shims an input FORMAT can reach at all.
236///
237/// The declarative engine path (`run_scenario_vus`) runs exactly one kind of
238/// JS: the `prerequest` / `test` scripts carried by `ScenarioItem`s, via
239/// `ScenarioRunner::run_script`. Nothing else in that path evaluates JS, so a
240/// shim no script of that format can name is pure per-VU heap.
241///
242/// `None` means "this table does not know that format" and the caller MUST
243/// fall back to the full [`ShimBundle::default`]. Formats are opt-in: an
244/// unknown or newly registered adapter id can never silently lose a shim —
245/// a missing shim is a `ReferenceError` in a customer's script, which is far
246/// worse than the memory an unnecessary one costs.
247///
248/// What is deliberately NOT excluded, and why:
249///
250/// - **`Shim::Pm` is in every row.** The obvious next win (70,197 B of
251///   source, the largest single shim) is dropping it from the four
252///   script-free formats, and it was measured (TR-501). It is NOT taken here:
253///   pm.js also installs the configured canonical namespace from
254///   `__tropel_sandbox_config` (the `SandboxConfig` preamble in
255///   [`create_vu_js_context`] is written on the assumption that pm.js
256///   consumes it), and it is the only definer of `check` / `group` /
257///   `Counter` / `Gauge` / `Rate` / `Trend` (pm.js:1625). Proving no script
258///   exists is an adapter-local argument; proving nothing else wants the
259///   namespace is not.
260/// - **`k6` keeps chai, lodash and cryptojs.** k6's own `check` and the
261///   metric constructors come from pm.js, not from `js/k6-shim/`, so the
262///   "a k6 run does not need pm.js" intuition is backwards for this bundle.
263/// - **`bru` keeps pm.** Bruno's own adapter test fixture carries `pm.*`
264///   scripts (`tropel-input-bru/src/lib.rs:619`) — Bruno collections
265///   migrated from Postman really do use them.
266fn format_shims(format: &str) -> Option<&'static [Shim]> {
267    use Shim::*;
268    Some(match format {
269        // Postman scripts are arbitrary JS: `pm.expect(...)` (chai-style),
270        // `_.map`, `CryptoJS.MD5` are all documented Postman sandbox
271        // globals. Only Bruno's `bru`/`req`/`res` is unreachable — a
272        // Postman collection has no syntax that produces it.
273        //
274        // TR-475: `Fetch` belongs here for the same reason Chai and Lodash
275        // do — a Postman script is arbitrary JS, and `fetch` is how arbitrary
276        // JS makes a request. Leaving it out made a pre-script using `fetch`
277        // work through the API client's agent (whose bundle is the default)
278        // and die on a bare ReferenceError under a Postman LOAD run. One
279        // script, two answers, decided by which bundle the format picked.
280        "postman" => &[DeepEqual, K6Core, Pm, Chai, Lodash, CryptoJs, Exec, Fetch],
281        // Bruno scripts reach the same library surface plus `bru`.
282        "bru" => &[
283            DeepEqual, K6Core, Pm, Chai, Lodash, CryptoJs, Exec, Bru, Fetch,
284        ],
285        // The k6 InputAdapter fallback (used when the k6 Driver is not
286        // registered) wraps the transpiled script as one item's `test`.
287        // Arbitrary JS again — minus Bruno's API.
288        // `k6` is deliberately NOT narrowed.
289        //
290        // A k6 script is arbitrary user JS. Unlike the collection formats,
291        // there is no structural guarantee about what it references — it can
292        // reach `pm.*`, `trp.*`, chai, lodash, CryptoJS, or anything the
293        // Driver installs. Narrowing it caused
294        // `cookie_jar_set_reaches_the_wire_and_reads_back_server_cookies` to
295        // fire ZERO of its four requests: the script failed at load and the
296        // run silently did nothing, which is exactly the failure mode this
297        // whole table has to avoid.
298        //
299        // Real k6 runs take the k6 Driver, which has its own bundle and does
300        // not consult this table at all; this row only covers the adapter
301        // fallback. Narrowing it buys little and risks a silent no-op, so it
302        // returns None and gets the full default bundle.
303        "k6" => return None,
304        // These four adapters construct every `ScenarioItem` with
305        // `prerequest: vec![]` and `test: vec![]`, at every construction
306        // site — har/lib.rs:358, openapi/lib.rs:551, http/lib.rs:272,
307        // insomnia/lib.rs:261+339 — so they cannot emit a script, so no
308        // script can reference the user-facing assertion/utility libraries.
309        // `assertion_libraries_are_unreachable_for_script_free_formats`
310        // re-derives that from the parsed Scenario, so an adapter that
311        // starts emitting scripts breaks the test instead of the customer.
312        //
313        // `Pm` stays here too, for the same reason as the `k6` row: pm.js
314        // installs the canonical `trp` namespace, not only `pm`/`postman`.
315        // The further -49% (280,480 -> 142,976 B/VU) that dropping it would
316        // buy is real and measured, but it needs `trp` extracted first —
317        // exactly the way `check`/`group` were extracted into K6Core.
318        "har" | "openapi" | "http" | "insomnia" => &[DeepEqual, K6Core, Pm, Exec],
319        _ => return None,
320    })
321}
322
323/// P-B + TR-501: only materialise shims a run can actually use.
324///
325/// Two independent, individually-safe layers:
326///
327/// 1. **Format** ([`format_shims`]) — what the input format can name at all.
328/// 2. **Content** — a conservative keyword scan of the input file for the two
329///    optional libraries. The scan reads the WHOLE input file, not just the
330///    script text, so it is deliberately over-inclusive: a Postman collection
331///    whose URL happens to contain `crypto.` pulls cryptojs it does not need,
332///    which costs memory. The reverse — scanning only the extracted scripts
333///    and missing one — costs a `ReferenceError`.
334impl ShimBundle {
335    /// Build a bundle for a known input format, gated further by a keyword
336    /// scan of the input. An unrecognised `format` yields the full default.
337    pub fn for_format(format: &str, input: &[u8]) -> Self {
338        let Some(allowed) = format_shims(format) else {
339            tracing::debug!(
340                "TR-501: no shim table for input format '{format}' — using the full default bundle"
341            );
342            return Self::default();
343        };
344        // Convert to str for scanning; lossy is fine — we're looking for
345        // ASCII keywords, not parsing UTF-8.
346        let src = String::from_utf8_lossy(input);
347        let needs_crypto =
348            src.contains("CryptoJS") || src.contains("crypto.") || src.contains("crypto ");
349        let needs_lodash = src.contains("_.") || src.contains("lodash");
350
351        let kept: Vec<Shim> = allowed
352            .iter()
353            .copied()
354            .filter(|s| match s {
355                Shim::Lodash => needs_lodash,
356                Shim::CryptoJs => needs_crypto,
357                _ => true,
358            })
359            .collect();
360        Self::from_shims(&kept)
361    }
362
363    /// [`Self::for_format`] against a file on disk. Reads the file once per
364    /// scenario (the bundle is then shared by every VU via `Arc`); an
365    /// unreadable file falls back to the full default bundle, since a bundle
366    /// cannot be narrowed on evidence that could not be read.
367    pub fn for_format_path(format: &str, path: &std::path::Path) -> Self {
368        match std::fs::read(path) {
369            Ok(bytes) => Self::for_format(format, &bytes),
370            Err(e) => {
371                tracing::debug!(
372                    "TR-501: could not read '{}' for shim gating ({e}) — using the full default bundle",
373                    path.display()
374                );
375                Self::default()
376            }
377        }
378    }
379
380    /// Content-only gating, with no format knowledge: the full shim set
381    /// minus lodash/cryptojs when the input never names them. This is what
382    /// `for_format` degrades to for an unknown format, and what the
383    /// measurement harness uses as the "gated" comparison point.
384    pub fn from_script(script: &[u8]) -> Self {
385        let src = String::from_utf8_lossy(script);
386        let needs_crypto =
387            src.contains("CryptoJS") || src.contains("crypto.") || src.contains("crypto ");
388        let needs_lodash = src.contains("_.") || src.contains("lodash");
389        let kept: Vec<Shim> = Shim::ALL
390            .iter()
391            .copied()
392            .filter(|s| match s {
393                Shim::Lodash => needs_lodash,
394                Shim::CryptoJs => needs_crypto,
395                _ => true,
396            })
397            .collect();
398        Self::from_shims(&kept)
399    }
400}
401
402/// Identity of a shim bundle — see [`ShimBundle::key`].
403#[derive(Clone, Copy, PartialEq, Eq, Debug)]
404pub(crate) struct BundleKey(u64);
405
406/// One cache slot: the compiled bytecode for ONE bundle, plus the two sticky
407/// failure flags that were previously process-global `AtomicBool`s. Making
408/// them per-bundle matters: a run failure on bundle A used to disable the
409/// bytecode path for every other bundle in the process.
410struct ShimBytecodeSlot {
411    key: BundleKey,
412    /// `None` once compilation failed for THIS bundle — sticky, so a VU does
413    /// not retry a compile that is deterministically broken.
414    bytecode: Option<Arc<Vec<u8>>>,
415    /// Set once this bundle's bytecode failed to RUN in some context. A run
416    /// failure is deterministic (same blob, same bundle, every VU), so after
417    /// the first one every VU short-circuits to the source-eval fallback.
418    run_failed: bool,
419}
420
421/// How many DISTINCT shim bundles keep a compiled-bytecode slot.
422///
423/// Sizing: one bundle is built per scenario (`run_scenario_vus`), and a run
424/// resolves a single input format, so the live set is one per scenario — 1 in
425/// the common case, a handful for a multi-scenario config. The format table
426/// can produce at most 7 formats x 4 content-gate shapes = 28 distinct
427/// bundles, but only a process that ran every format and every gate
428/// combination (i.e. the test suite) reaches that. 16 covers every realistic
429/// run with room to spare and bounds the cache at 16 x ~200 KB ~= 3 MB
430/// PROCESS-wide — not per VU, which is the number this task exists to reduce.
431///
432/// Past the cap, further bundles fall back to per-VU source eval and warn
433/// once. That is correct, just slower — and it is exactly what the pre-TR-501
434/// code did for EVERY non-default bundle.
435const SHIM_BYTECODE_CACHE_CAP: usize = 16;
436
437/// Process-wide cache of compiled shim-bundle bytecode, keyed by bundle.
438///
439/// Each distinct bundle is compiled ONCE (qjsc-style: `JS_Eval` with
440/// COMPILE_ONLY, then `JS_WriteObject`), and every VU using that bundle loads
441/// the blob and runs it instead of re-parsing + re-compiling the source.
442/// QuickJS bytecode is tied to the build (version + feature flags), not to a
443/// particular context, so one compilation is valid for every VU context in
444/// the process.
445///
446/// TR-501: this replaces a single `OnceLock<Option<Vec<u8>>>` keyed on
447/// nothing. Its own comment noted that reusing it for a different bundle
448/// "would silently serve the wrong bytecode", so `bootstrap_shims` took the
449/// bytecode path ONLY when `shim.is_default()` — which made shim gating a
450/// net loss, because every gated bundle then paid per-VU source eval.
451static SHIM_BYTECODE_CACHE: Mutex<Vec<ShimBytecodeSlot>> = Mutex::new(Vec::new());
452
453/// Warn once, not once per VU, when the cache cap is reached.
454static SHIM_BYTECODE_CACHE_FULL_LOGGED: AtomicBool = AtomicBool::new(false);
455
456/// Fetch this bundle's compiled bytecode, compiling it once if this is the
457/// first VU to ask for it. `None` means "use the source-eval fallback".
458///
459/// The lock is held across `compile_global_bytecode` (which is synchronous —
460/// no await point inside the critical section), so concurrently spawning VUs
461/// that want the SAME bundle block until the first finishes compiling rather
462/// than each compiling their own copy. That is the same serialisation
463/// `OnceLock::get_or_init` provided, now per bundle instead of per process.
464fn shim_bytecode_for(
465    ctx: &mut tropel_js::JsContext,
466    bundle: &ShimBundle,
467    key: BundleKey,
468) -> Option<Arc<Vec<u8>>> {
469    // A panic in another VU's compile must not wedge every remaining VU into
470    // the slow path forever; recover the guard and carry on.
471    let mut cache = SHIM_BYTECODE_CACHE
472        .lock()
473        .unwrap_or_else(|poisoned| poisoned.into_inner());
474
475    if let Some(slot) = cache.iter().find(|s| s.key == key) {
476        if slot.run_failed {
477            return None;
478        }
479        return slot.bytecode.clone();
480    }
481
482    if cache.len() >= SHIM_BYTECODE_CACHE_CAP {
483        if !SHIM_BYTECODE_CACHE_FULL_LOGGED.swap(true, Ordering::Relaxed) {
484            tracing::warn!(
485                "Shim bytecode cache is full ({SHIM_BYTECODE_CACHE_CAP} distinct bundles); \
486                 further bundles fall back to per-VU source eval"
487            );
488        }
489        return None;
490    }
491
492    let rendered = bundle.render();
493    let compiled = match ctx.compile_global_bytecode(&rendered) {
494        Ok(bc) => {
495            tracing::info!(
496                "Compiled shim bundle [{}] to bytecode once ({} B from {} B of source) — reusing across VUs",
497                bundle
498                    .0
499                    .iter()
500                    .map(|e| e.0)
501                    .collect::<Vec<_>>()
502                    .join("+"),
503                bc.len(),
504                rendered.len()
505            );
506            Some(Arc::new(bc))
507        }
508        Err(e) => {
509            tracing::warn!(
510                "Shim bytecode compilation failed ({e}); falling back to per-VU source eval"
511            );
512            None
513        }
514    };
515    cache.push(ShimBytecodeSlot {
516        key,
517        bytecode: compiled.clone(),
518        run_failed: false,
519    });
520    compiled
521}
522
523/// Mark a bundle's bytecode as unrunnable, so subsequent VUs go straight to
524/// source eval instead of re-attempting a deterministically failing blob.
525fn mark_shim_bytecode_run_failed(key: BundleKey) {
526    let mut cache = SHIM_BYTECODE_CACHE
527        .lock()
528        .unwrap_or_else(|poisoned| poisoned.into_inner());
529    if let Some(slot) = cache.iter_mut().find(|s| s.key == key) {
530        slot.run_failed = true;
531    }
532}
533
534/// Snapshot of the live bytecode cache: one `(key, blob)` per distinct
535/// bundle, `None` where compilation failed. Reads the real production cache
536/// — the tests use it to assert that DISTINCT bundles get DISTINCT bytecode,
537/// which is the property the old single `OnceLock` could not provide.
538#[cfg(test)]
539pub(crate) fn shim_bytecode_cache_snapshot() -> Vec<(BundleKey, Option<Arc<Vec<u8>>>)> {
540    let cache = SHIM_BYTECODE_CACHE
541        .lock()
542        .unwrap_or_else(|poisoned| poisoned.into_inner());
543    cache.iter().map(|s| (s.key, s.bytecode.clone())).collect()
544}
545
546/// Create a JS context for one VU, bootstrap the shim libraries `shim`
547/// carries, install the native modules and PM bridge functions, and wire a
548/// `sleep(seconds)` helper.
549///
550/// TR-501: `shim` is no longer always the full embedded set — the caller
551/// builds it from the input format (`ShimBundle::for_format`), so which
552/// libraries a VU gets depends on what that format's scripts can name. Do not
553/// assume `pm`/`chai`/`_`/`CryptoJS`/`bru` are all present in a context built
554/// here; check the bundle.
555///
556/// Returns `None` if context creation fails — context-creation failures log
557/// a warning, but a shim bootstrap failure is logged at ERROR level (the VU
558/// still runs, just without scripts).
559pub(crate) async fn create_vu_js_context(
560    vu_id: u32,
561    pm_state: &SharedPmState,
562    http_client: &Arc<dyn DriverHttpClient>,
563    shim: &ShimBundle,
564    config: &SandboxConfig,
565    force_stop: Arc<AtomicBool>,
566) -> Option<tropel_js::JsContext> {
567    let mut ctx = match tropel_js::JsContext::new_with_force_stop(
568        Some(js_heap_bytes()),
569        Some(js_deadline_secs()),
570        force_stop.clone(),
571    )
572    .await
573    {
574        Ok(ctx) => ctx,
575        Err(e) => {
576            tracing::warn!(
577                "VU {}: Failed to create JS context: {} (scripts will be skipped)",
578                vu_id,
579                e
580            );
581            return None;
582        }
583    };
584
585    // P4b: a NON-default sandbox config (custom canonical name / aliases)
586    // must be installed as `__tropel_sandbox_config` BEFORE the shim bundle
587    // evals, so pm.js's install tail exposes the configured names. The
588    // default config is skipped — pm.js's own fallback (`tropel` + `wire`)
589    // is byte-identical, and skipping keeps the default path untouched.
590    if config != &SandboxConfig::default() {
591        if let Err(e) = ctx.eval(&config.render_js_preamble()).await {
592            // Loud, like the shim-bootstrap failure: the embedder asked for a
593            // specific canonical name and silently getting `tropel.*` would
594            // make every `trp.*` script throw ReferenceError at runtime.
595            tracing::warn!(
596                "VU {}: Failed to set sandbox config preamble: {} — failing the VU context",
597                vu_id,
598                e
599            );
600            return None;
601        }
602    }
603
604    if let Err(e) = bootstrap_shims(&mut ctx, vu_id, shim).await {
605        // Backlog line 238: a shim-eval failure must be LOUD — warn-only left
606        // every script throwing `ReferenceError: pm is not defined`. Fail the
607        // VU's JS context (scripts are skipped) and log at error level so the
608        // run can't silently degrade into broken scripts.
609        tracing::error!(
610            "VU {}: JS shim bootstrap FAILED: {} — scripts will be skipped",
611            vu_id,
612            e
613        );
614        return None;
615    }
616
617    if let Err(e) = tropel_native::install_all(&mut ctx).await {
618        tracing::warn!("VU {}: Failed to install native modules: {}", vu_id, e);
619    }
620
621    let bridge = tropel_sandbox::bindings::trp::TrpBridge::with_http_client(
622        pm_state.clone(),
623        http_client.clone(),
624    );
625    if let Err(e) = bridge.install(&mut ctx) {
626        tracing::warn!("VU {}: Failed to install PM bridge functions: {}", vu_id, e);
627    }
628
629    // The sleep burns WALL time; the per-eval JS interrupt deadline must not
630    // count it against the JS execution budget, or a stock k6 pacing idiom
631    // like `sleep(Math.random()*10)` is interrupted on resume (backlog line
632    // 104). Re-arm the deadline after the blocking sleep, like the WS loop
633    // does per step.
634    let (deadline, max_exec) = ctx.interrupt_deadline_handle();
635    let force_stop_sleep = force_stop.clone();
636    ctx.with_ctx(|rq_ctx| {
637        let globals = rq_ctx.globals();
638        let deadline_sleep = deadline.clone();
639        // MUST be a SYNC host fn. `JsContext` builds a plain `rquickjs::Runtime`
640        // (tropel-js/src/context.rs), which has NO spawner — `Opaque::spawner()`
641        // is `.expect("tried to use async function in non async runtime")`. An
642        // `Async` host fn calls `ctx.spawn` on first invocation and panics
643        // there; rquickjs's ffi layer catches the panic, stashes it in the
644        // runtime's `Opaque`, and throws into JS, so the VU sees an opaque
645        // "Async script rejected" — and the stashed payload then `resume_unwind`s
646        // on whichever VU next raises an exception, which on a shared runtime is
647        // a DIFFERENT VU. A previous revision registered this as `Async` and the
648        // guard below only checked `typeof sleep`, so 1130 tests passed while
649        // `sleep()` was dead on every declarative format.
650        //
651        // Absolute deadline, not `remaining -= slice`: OS overshoot compounds
652        // in the subtractive form (TR-502). Mirrors the k6 driver's copy.
653        let _ = globals.set(
654            "__tropel_native_sleep",
655            rquickjs::function::Func::from(move |ms: f64| {
656                if ms > 0.0 {
657                    let total = Duration::from_secs_f64(ms / 1000.0);
658                    let deadline_inner = std::time::Instant::now() + total;
659                    let step = Duration::from_millis(10);
660                    loop {
661                        if force_stop_sleep.load(Ordering::Acquire) {
662                            deadline_sleep.store(0, Ordering::Relaxed);
663                            return;
664                        }
665                        let now = std::time::Instant::now();
666                        if now >= deadline_inner {
667                            break;
668                        }
669                        let remaining = deadline_inner - now;
670                        std::thread::sleep(remaining.min(step));
671                    }
672                }
673                tropel_js::rearm_deadline(&deadline_sleep, max_exec);
674            }),
675        );
676    });
677
678    // EXPLICIT globalThis assignment, not a declaration. The previous form —
679    // `if (typeof sleep === 'undefined') { async function sleep(…) {…} }` —
680    // was a no-op: a function declaration inside a block is BLOCK-SCOPED in
681    // ES2015+, and Annex B's sloppy-mode hoisting does not apply to async
682    // functions, so QuickJS (correctly) never put it on the global object.
683    // The wrapper evaluated, went out of scope, and `sleep` stayed undefined
684    // on the whole declarative path — a stock k6 pacing idiom
685    // (`http.get(u); sleep(1);` in a collection script) threw ReferenceError.
686    // The k6 Driver path was unaffected only because its own bundle carries
687    // js/k6-shim/sleep-shim.js.
688    let sleep_code = [
689        "if (typeof globalThis.sleep === 'undefined') {",
690        "  globalThis.sleep = async function sleep(seconds) {",
691        "    if (typeof __tropel_native_sleep === 'function') {",
692        "      await __tropel_native_sleep(seconds * 1000);",
693        "    }",
694        "  };",
695        "}",
696    ]
697    .join("\n");
698    let _ = ctx.eval(&sleep_code).await;
699
700    Some(ctx)
701}
702
703/// Bootstrap the shim libraries in `shim` into `ctx`.
704///
705/// Preferred path for EVERY bundle, not just the default one: fetch this
706/// bundle's bytecode from the keyed process-wide cache (compiled once by the
707/// first VU that asked for this bundle) and run it in this context — no
708/// per-VU parse/compile. Fallback: evaluate the rendered source directly.
709///
710/// TR-501: `bootstrap_shims` previously took the bytecode path only when
711/// `shim.is_default()`, because the cache was a single unkeyed `OnceLock`.
712/// That made shim gating a net loss — a gated bundle carries less source but
713/// paid a full per-VU parse+compile for it, and measured 557,824 B/VU against
714/// the default bundle's 497,584 B/VU (✅MEAS, release, Apple M2). With the
715/// cache keyed by [`ShimBundle::key`], every distinct bundle compiles once
716/// and gating is finally a saving.
717///
718/// Returns `Err` ONLY when the shim bundle could not be evaluated by ANY
719/// path (bytecode compile failed + source eval failed, or bytecode run
720/// failed + source eval failed) — a true `pm is not defined` condition that
721/// the caller must surface loudly.
722async fn bootstrap_shims(
723    ctx: &mut tropel_js::JsContext,
724    vu_id: u32,
725    shim: &ShimBundle,
726) -> Result<()> {
727    let key = shim.key();
728
729    if let Some(bytecode) = shim_bytecode_for(ctx, shim, key) {
730        match ctx.run_global_bytecode(&bytecode).await {
731            Ok(()) => return Ok(()),
732            Err(e) => {
733                mark_shim_bytecode_run_failed(key);
734                tracing::warn!(
735                    "VU {vu_id}: Failed to run JS shim bytecode: {e} \
736                     (disabling the bytecode path for this bundle; falling back to source eval)"
737                );
738                let rendered = shim.render();
739                return ctx.bootstrap_library(&rendered).await.map_err(|e2| {
740                    TropelError::Js(format!(
741                        "VU {vu_id}: shim source eval failed after bytecode run error: {e2}"
742                    ))
743                });
744            }
745        }
746    }
747
748    let rendered = shim.render();
749    ctx.bootstrap_library(&rendered)
750        .await
751        .map_err(|e| TropelError::Js(format!("VU {vu_id}: shim source eval failed: {e}")))
752}
753
754#[cfg(test)]
755mod tests {
756    use super::*;
757    use std::sync::Arc;
758    use tropel_core::config::HttpConfig;
759    use tropel_http::client::{HttpClient, VuCookieClient};
760    use tropel_sandbox::state::new_pm_state;
761    use tropel_sdk::traits::DriverHttpClient;
762
763    /// Replaces `shim_lists_stay_in_lockstep_with_bru`, which pinned the
764    /// design this commit removes.
765    ///
766    /// That test guarded a real defect (W2 line 182: a `JS_SHIM_BUNDLE`
767    /// concat carried 5 shims while `ShimBundle::default()` carried 6, so
768    /// bru.js was compiled into the binary but NEVER evaluated) by asserting
769    /// the two hand-maintained lists agreed. There is now ONE list —
770    /// [`Shim::ALL`] — and the bytecode path compiles `render()` like every
771    /// other path, so "the two lists drifted" is no longer expressible. What
772    /// still needs guarding is that `render()` really emits every entry, in
773    /// order: a `render()` that silently dropped an entry would reintroduce
774    /// exactly the `typeof bru === 'undefined'` symptom through a different
775    /// door.
776    #[test]
777    fn render_emits_every_shim_in_the_default_bundle() {
778        let d = ShimBundle::default();
779        assert_eq!(
780            d.0.iter().map(|e| e.0).collect::<Vec<_>>(),
781            vec![
782                "deep-equal-shim",
783                "k6-core-shim",
784                "pm-shim",
785                "chai-shim",
786                "lodash-shim",
787                "cryptojs-shim",
788                "exec-shim",
789                "bru-shim",
790                "fetch-shim"
791            ],
792            "the default bundle is Shim::ALL, in canonical order"
793        );
794
795        let rendered = d.render();
796        let mut cursor = 0usize;
797        for ShimEntry(name, src) in &d.0 {
798            let header = format!("// ==== shim: {name} ====\n");
799            let at = rendered[cursor..].find(&header).map(|i| i + cursor);
800            let at = at.unwrap_or_else(|| panic!("render() dropped the {name} section header"));
801            cursor = at + header.len();
802            assert!(
803                rendered[cursor..].starts_with(src.as_ref()),
804                "render() dropped or reordered the {name} source"
805            );
806            cursor += src.len();
807        }
808
809        // The specific byte that went missing last time.
810        let bru_src = Shim::Bru.source();
811        assert!(
812            rendered.contains(bru_src),
813            "render() must emit bru.js — the W2 line-182 symptom was `typeof bru === 'undefined'`"
814        );
815    }
816
817    /// TR-501: a Postman collection has no syntax that can produce Bruno's
818    /// `bru` / `req` / `res`, so a Postman run must not materialise bru.js.
819    /// It CAN produce chai-style `pm.expect`, `_` and `CryptoJS`, so those
820    /// must survive when the collection names them.
821    ///
822    /// Fails on pre-fix code: `ShimBundle::from_script` (the only selector
823    /// that existed) appended `bru-shim` unconditionally, for every input.
824    /// TR-475: EVERY format that runs user scripts can call `fetch`.
825    ///
826    /// The bundles are narrowed per format, and `fetch` was added only to the
827    /// default one. So `await fetch(...)` in a pre-script worked through the
828    /// API client's agent and under a k6 run (both take the default bundle),
829    /// and died on `fetch is not defined` under a Postman or Bruno LOAD run.
830    /// One script, two answers, decided by which bundle the format picked —
831    /// and nothing said so, because a narrowed bundle looks deliberate.
832    ///
833    /// Measured, not reasoned: a real `tropel run` over a Postman collection
834    /// whose pre-script fetches printed `FETCH_FAIL fetch is not defined`
835    /// before this, and `FETCH_OK status=200` after.
836    #[test]
837    fn every_scripted_format_bundle_carries_fetch() {
838        for format in ["postman", "bru"] {
839            let shims = format_shims(format)
840                .unwrap_or_else(|| panic!("{format} should have a narrowed bundle"));
841            assert!(
842                shims.contains(&Shim::Fetch),
843                "`{format}` scripts are arbitrary JS, and `fetch` is how arbitrary JS \
844                 makes a request — leaving it out makes the same script behave \
845                 differently here than under the default bundle: {shims:?}"
846            );
847        }
848        // `k6` returns None on purpose (it takes the FULL default bundle), so
849        // its coverage comes from Shim::ALL rather than from this table.
850        assert!(
851            format_shims("k6").is_none(),
852            "k6 takes the full default bundle; if that changes, it needs Fetch too"
853        );
854        assert!(
855            Shim::ALL.contains(&Shim::Fetch),
856            "the default bundle carries fetch"
857        );
858    }
859
860    #[test]
861    fn postman_bundle_excludes_bru_and_keeps_the_assertion_libraries() {
862        let collection = br#"{"info":{"schema":"getpostman.com/collection"},
863            "item":[{"event":[{"listen":"test","script":{"exec":[
864              "pm.expect(_.map([1],String)).to.eql(['1']);",
865              "pm.environment.set('h', CryptoJS.MD5('x').toString());"
866            ]}}]}]}"#;
867        let names = shim_names(&ShimBundle::for_format("postman", collection));
868
869        assert!(
870            !names.contains(&"bru-shim"),
871            "a Postman run must not materialise bru.js — got {names:?}"
872        );
873        for required in [
874            "deep-equal-shim",
875            "k6-core-shim",
876            "pm-shim",
877            "chai-shim",
878            "exec-shim",
879        ] {
880            assert!(
881                names.contains(&required),
882                "a Postman script can reach {required} — got {names:?}"
883            );
884        }
885        assert!(
886            names.contains(&"lodash-shim") && names.contains(&"cryptojs-shim"),
887            "this collection names both `_.` and `CryptoJS` — got {names:?}"
888        );
889    }
890
891    /// TR-501: the same collection WITHOUT the two optional libraries drops
892    /// them. This is the layer that existed before (content gating); the
893    /// assertion here is that the format layer did not disable it.
894    #[test]
895    fn postman_bundle_drops_unreferenced_optional_libraries() {
896        let collection = br#"{"info":{"schema":"getpostman.com/collection"},
897            "item":[{"event":[{"listen":"test","script":{"exec":[
898              "pm.test('ok', () => pm.response.to.have.status(200));"
899            ]}}]}]}"#;
900        let names = shim_names(&ShimBundle::for_format("postman", collection));
901        assert!(
902            !names.contains(&"lodash-shim") && !names.contains(&"cryptojs-shim"),
903            "nothing in this collection names `_` or `CryptoJS` — got {names:?}"
904        );
905        assert!(
906            names.contains(&"pm-shim"),
907            "pm.js is not optional for Postman — got {names:?}"
908        );
909    }
910
911    // `k6_bundle_excludes_bru_but_keeps_pm` was removed: it asserted the k6 row NARROWS.
912    // That row now returns the full default bundle on purpose — see
913    // `format_shims`. A k6 script is arbitrary JS, and narrowing it made a
914    // real test fire zero of its four requests.
915
916    /// TR-501: har / openapi / http / insomnia adapters cannot emit a
917    /// `prerequest` or `test` script (see
918    /// `assertion_libraries_are_unreachable_for_script_free_formats`), so no
919    /// script of those formats can name chai, lodash, CryptoJS or bru.
920    ///
921    /// Fails on pre-fix code: the only selector was a keyword scan of the
922    /// file bytes, which always kept chai and bru and — for a HAR whose
923    /// recorded URLs contain `crypto.` or `_.` — kept those too.
924    #[test]
925    fn script_free_formats_exclude_the_user_script_libraries() {
926        // Deliberately seeded with the exact tokens the content scan looks
927        // for: a recorded URL can contain anything. The FORMAT is what makes
928        // them unreachable, and the format layer must win.
929        let recorded = br#"{"log":{"entries":[{"request":{"url":"https://api.example.com/crypto.json?f=_.x&q=lodash"}}]}}"#;
930        for format in ["har", "openapi", "http", "insomnia"] {
931            let names = shim_names(&ShimBundle::for_format(format, recorded));
932            for excluded in ["chai-shim", "lodash-shim", "cryptojs-shim", "bru-shim"] {
933                assert!(
934                    !names.contains(&excluded),
935                    "'{format}' emits no scripts, so nothing can name {excluded} — got {names:?}"
936                );
937            }
938            assert_eq!(
939                names,
940                vec!["deep-equal-shim", "k6-core-shim", "pm-shim", "exec-shim"],
941                "'{format}' bundle"
942            );
943        }
944    }
945
946    /// TR-501: the table is opt-in. An adapter id it does not know — a
947    /// third-party input extension, a `subprocess:<cmd>` id, anything added
948    /// after this table was written — must get the FULL bundle. A missing
949    /// shim is a `ReferenceError` in a customer's script; an unnecessary one
950    /// is only memory.
951    #[test]
952    fn unknown_format_falls_back_to_the_full_bundle() {
953        for unknown in ["", "graphql", "subprocess:./gen.sh", "POSTMAN", "postman2"] {
954            let names = shim_names(&ShimBundle::for_format(unknown, b"{}"));
955            assert_eq!(
956                names,
957                shim_names(&ShimBundle::default()),
958                "unknown format '{unknown}' must get the full default bundle"
959            );
960        }
961    }
962
963    /// TR-501: the load-bearing premise of the `har` / `openapi` / `http` /
964    /// `insomnia` row in [`format_shims`] is that those adapters cannot
965    /// produce a script. This re-derives it from the REAL adapters and the
966    /// REAL parsed `Scenario`, not from reading their source — so an adapter
967    /// that starts emitting scripts fails this test instead of shipping a
968    /// `ReferenceError` to a customer whose script now has nothing to run
969    /// against.
970    ///
971    /// If this fails: add the libraries that format's scripts can reach back
972    /// into `format_shims`, in the same commit.
973    #[test]
974    fn assertion_libraries_are_unreachable_for_script_free_formats() {
975        use tropel_sdk::traits::InputAdapter;
976
977        fn count_scripts(items: &[tropel_sdk::scenario::ScenarioItem]) -> usize {
978            items
979                .iter()
980                .map(|i| i.prerequest.len() + i.test.len() + count_scripts(&i.items))
981                .sum()
982        }
983
984        let cases: Vec<(&str, Box<dyn InputAdapter>, &[u8])> = vec![
985            (
986                "har",
987                Box::new(tropel_input_har::HarInputAdapter),
988                br#"{"log":{"version":"1.2","creator":{"name":"t","version":"1"},"entries":[
989                    {"startedDateTime":"2020-01-01T00:00:00Z","time":1,
990                     "request":{"method":"GET","url":"https://example.com/a","httpVersion":"HTTP/1.1","headers":[],"queryString":[],"cookies":[],"headersSize":-1,"bodySize":-1},
991                     "response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","headers":[],"cookies":[],"content":{"size":0,"mimeType":"text/plain"},"redirectURL":"","headersSize":-1,"bodySize":0},
992                     "cache":{},"timings":{"send":0,"wait":1,"receive":0}}]}}"#,
993            ),
994            (
995                "openapi",
996                Box::new(tropel_input_openapi::OpenApiInputAdapter),
997                br#"{"openapi":"3.0.0","info":{"title":"t","version":"1"},
998                    "servers":[{"url":"https://example.com"}],
999                    "paths":{"/a":{"get":{"responses":{"200":{"description":"ok"}}}}}}"#,
1000            ),
1001            (
1002                "http",
1003                Box::new(tropel_input_http::HttpFileAdapter),
1004                b"GET https://example.com/a\nAccept: application/json\n",
1005            ),
1006            (
1007                "insomnia",
1008                Box::new(tropel_input_insomnia::InsomniaInputAdapter),
1009                br#"{"_type":"export","__export_format":4,"resources":[
1010                    {"_id":"req_1","_type":"request","parentId":"wrk_1","name":"a","method":"GET","url":"https://example.com/a"},
1011                    {"_id":"wrk_1","_type":"workspace","name":"w"}]}"#,
1012            ),
1013        ];
1014
1015        for (format, adapter, bytes) in cases {
1016            assert_eq!(
1017                adapter.id(),
1018                format,
1019                "the format_shims key must be the adapter's own id"
1020            );
1021            let scenario = adapter
1022                .parse(bytes)
1023                .unwrap_or_else(|e| panic!("{format} fixture must parse: {e}"));
1024            assert!(
1025                !scenario.items.is_empty(),
1026                "{format} fixture must produce at least one item, or it proves nothing"
1027            );
1028            assert_eq!(
1029                count_scripts(&scenario.items),
1030                0,
1031                "the '{format}' row of format_shims drops chai/lodash/cryptojs/bru on the \
1032                 grounds that this adapter cannot emit a script. It just did. Put the \
1033                 libraries its scripts can reach back into format_shims."
1034            );
1035        }
1036    }
1037
1038    /// Collect the shim section names of a bundle, in order.
1039    fn shim_names(bundle: &ShimBundle) -> Vec<&'static str> {
1040        bundle.0.iter().map(|e| e.0).collect()
1041    }
1042
1043    /// F1: `HttpClient` itself does not implement `DriverHttpClient` — the
1044    /// engine wraps it in `DriverHttpClientImpl` (vu_loop.rs). Reuse it here
1045    /// so the test builds the same trait object the VU loop passes.
1046    use crate::vu_loop::DriverHttpClientImpl;
1047
1048    /// P4b: the engine bootstrap must honor a NON-default SandboxConfig.
1049    /// The VU loop always passes the default (so the config branch would be
1050    /// provably inert without this test) — an embedder passing a custom
1051    /// namespace + aliases must get those names installed, and the default
1052    /// `trp` canonical must be absent (a namespace distinct from the default
1053    /// proves the config drives the name). This runs through the SAME path
1054    /// as production: preamble eval before bootstrap_shims, then the
1055    /// (default) ShimBundle — the bytecode cache path is exercised since
1056    /// this test runs after other VU contexts compiled it.
1057    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1058    async fn create_vu_js_context_honors_custom_sandbox_config() {
1059        let pm_state = new_pm_state();
1060        let client: Arc<dyn DriverHttpClient> = Arc::new(DriverHttpClientImpl {
1061            client: VuCookieClient::new(
1062                HttpClient::new(&HttpConfig::default()).expect("http client should construct"),
1063            ),
1064        });
1065        let config = SandboxConfig {
1066            namespace: "acme".into(),
1067            aliases: vec!["product".into(), "wire".into()],
1068        };
1069        let mut ctx = create_vu_js_context(
1070            7,
1071            &pm_state,
1072            &client,
1073            &ShimBundle::default(),
1074            &config,
1075            Arc::new(AtomicBool::new(false)),
1076        )
1077        .await
1078        .expect("context must be created");
1079
1080        let check = ctx
1081            .eval(
1082                "typeof acme === 'object' && typeof product === 'object' \
1083                 && product === acme && wire === acme && typeof pm === 'object' \
1084                 && typeof bru === 'object' && typeof trp === 'undefined' \
1085                 && typeof tropel === 'undefined'",
1086            )
1087            .await
1088            .expect("probe should eval");
1089        assert_eq!(
1090            check, "true",
1091            "custom namespace/aliases must be installed via the preamble; default trp absent; bru must be evaluated by the real bundle path — got: {check}"
1092        );
1093    }
1094
1095    /// TR-503: isolation — one script's globals must not be reachable from
1096    /// another's. Each VU owns a separate QuickJS Runtime, so a global set
1097    /// in one must be undefined in the other. This is the 34 leaking globals
1098    /// guard: if a shim leaks, this fails.
1099    /// `sleep` must be a GLOBAL function on the declarative path.
1100    ///
1101    /// The wrapper `create_vu_js_context` appends used to be a block-scoped
1102    /// `async function` inside an `if` — which ES2015 block-scopes and Annex B
1103    /// does not rescue for async functions — so it never reached globalThis
1104    /// and every collection script calling `sleep(1)` threw ReferenceError.
1105    /// The adjacent comment used to assert the opposite of reality; this
1106    /// pins the reality.
1107    ///
1108    /// Fails on the pre-fix code: `typeof sleep` evaluated to "undefined".
1109    #[tokio::test]
1110    async fn sleep_is_a_global_function_on_the_declarative_path() {
1111        let pm_state = new_pm_state();
1112        let client: Arc<dyn DriverHttpClient> = Arc::new(DriverHttpClientImpl {
1113            client: VuCookieClient::new(
1114                HttpClient::new(&HttpConfig::default()).expect("http client"),
1115            ),
1116        });
1117        let mut ctx = create_vu_js_context(
1118            1,
1119            &pm_state,
1120            &client,
1121            &ShimBundle::default(),
1122            &SandboxConfig::default(),
1123            Arc::new(AtomicBool::new(false)),
1124        )
1125        .await
1126        .expect("VU context");
1127        let ty = ctx.eval("typeof sleep").await.expect("eval");
1128        assert_eq!(
1129            ty, "function",
1130            "sleep must be installed on globalThis for the declarative path — \
1131             a block-scoped declaration silently leaves it undefined"
1132        );
1133
1134        // `typeof` alone is not evidence: it passed for the whole period in
1135        // which `sleep` was backed by an `Async` host fn on a runtime with no
1136        // spawner, so the first CALL panicked with "tried to use async function
1137        // in non async runtime". Call it, and assert it actually waits.
1138        let elapsed = ctx
1139            .eval_async(
1140                "(async () => { const t = Date.now(); await sleep(0.05); return Date.now() - t; })()",
1141            )
1142            .await
1143            .expect("sleep must be callable, not merely defined");
1144        let ms: f64 = elapsed.trim().parse().unwrap_or(-1.0);
1145        assert!(
1146            ms >= 40.0,
1147            "await sleep(0.05) must block ~50ms; got {elapsed:?} — the host fn \
1148             is registered but not actually sleeping"
1149        );
1150    }
1151
1152    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1153    async fn per_vu_globals_are_isolated() {
1154        let pm_state = new_pm_state();
1155        let client: Arc<dyn DriverHttpClient> = Arc::new(DriverHttpClientImpl {
1156            client: VuCookieClient::new(
1157                HttpClient::new(&HttpConfig::default()).expect("http client should construct"),
1158            ),
1159        });
1160        let mut ctx1 = create_vu_js_context(
1161            1,
1162            &pm_state,
1163            &client,
1164            &ShimBundle::default(),
1165            &SandboxConfig::default(),
1166            Arc::new(AtomicBool::new(false)),
1167        )
1168        .await
1169        .expect("ctx1");
1170        let mut ctx2 = create_vu_js_context(
1171            2,
1172            &pm_state,
1173            &client,
1174            &ShimBundle::default(),
1175            &SandboxConfig::default(),
1176            Arc::new(AtomicBool::new(false)),
1177        )
1178        .await
1179        .expect("ctx2");
1180
1181        // Set a global in ctx1
1182        let _ = ctx1.eval("var leak_test = 42; leak_test").await;
1183        // Must be undefined in ctx2
1184        let check = ctx2
1185            .eval("typeof leak_test === 'undefined'")
1186            .await
1187            .expect("probe");
1188        assert_eq!(
1189            check, "true",
1190            "per-VU globals must be isolated — leak_test leaked to ctx2: {check}"
1191        );
1192        // Also check that built-in shims are present in both but not shared
1193        let c1 = ctx1.eval("typeof pm === 'object'").await.expect("c1");
1194        let c2 = ctx2.eval("typeof pm === 'object'").await.expect("c2");
1195        assert_eq!(c1, "true");
1196        assert_eq!(c2, "true");
1197    }
1198
1199    /// TR-503: the per-VU heap number printed in `README.md` must track
1200    /// the code.
1201    ///
1202    /// This is the gate that the 57 KB "shared Runtime" claim needed and did
1203    /// not have. That figure sat in the README, the budget table, the W5
1204    /// verification footer and the W6 release gate for as long as it took to
1205    /// read `context.rs` — the `SHARED_RT` it cited shared nothing. Nothing
1206    /// compared the documented number against a running context, so nothing
1207    /// objected.
1208    ///
1209    /// A wide band on purpose: this catches an order-of-magnitude divergence
1210    /// (57 KB vs ~486 KB is 9x) and tolerates allocator and platform variance.
1211    /// It is a drift alarm, not a precision budget — `perf-regression` owns
1212    /// the budget.
1213    ///
1214    /// If this fails, re-run `measure_per_vu_quickjs_heap` and update BOTH
1215    /// documents. Do not widen the band to make it pass.
1216    #[tokio::test]
1217    async fn documented_per_vu_heap_matches_reality() {
1218        const DOCUMENTED_BYTES: u64 = 497_584;
1219        const TOLERANCE: f64 = 0.25;
1220
1221        let pm_state = new_pm_state();
1222        let client: Arc<dyn DriverHttpClient> = Arc::new(DriverHttpClientImpl {
1223            client: VuCookieClient::new(
1224                HttpClient::new(&HttpConfig::default()).expect("http client should construct"),
1225            ),
1226        });
1227        let ctx = create_vu_js_context(
1228            1,
1229            &pm_state,
1230            &client,
1231            &ShimBundle::default(),
1232            &SandboxConfig::default(),
1233            Arc::new(AtomicBool::new(false)),
1234        )
1235        .await
1236        .expect("full VU context");
1237
1238        let actual = ctx.quickjs_heap_bytes();
1239        let low = (DOCUMENTED_BYTES as f64 * (1.0 - TOLERANCE)) as u64;
1240        let high = (DOCUMENTED_BYTES as f64 * (1.0 + TOLERANCE)) as u64;
1241        assert!(
1242            (low..=high).contains(&actual),
1243            "per-VU QuickJS heap is {actual} B but README/CONVENTIONS document \
1244             {DOCUMENTED_BYTES} B (band {low}..={high}). Re-run \
1245             `cargo test -p tropel-engine --release measure_per_vu_quickjs_heap \
1246             -- --nocapture --ignored` and update both documents."
1247        );
1248    }
1249
1250    /// TR-503 / TR-501: print the ACTUAL per-VU QuickJS heap so the README
1251    /// number is derived, not asserted. Run with:
1252    /// `cargo test -p tropel-engine --release measure_per_vu_quickjs_heap -- --nocapture --ignored`
1253    #[tokio::test]
1254    #[ignore = "measurement, not an assertion — run explicitly with --nocapture"]
1255    async fn measure_per_vu_quickjs_heap() {
1256        let pm_state = new_pm_state();
1257        let client: Arc<dyn DriverHttpClient> = Arc::new(DriverHttpClientImpl {
1258            client: VuCookieClient::new(
1259                HttpClient::new(&HttpConfig::default()).expect("http client should construct"),
1260            ),
1261        });
1262        let bare = tropel_js::JsContext::new(None, None)
1263            .await
1264            .expect("bare context");
1265        println!(
1266            "bare JsContext (no shims)      = {} B",
1267            bare.quickjs_heap_bytes()
1268        );
1269
1270        let full = create_vu_js_context(
1271            1,
1272            &pm_state,
1273            &client,
1274            &ShimBundle::default(),
1275            &SandboxConfig::default(),
1276            Arc::new(AtomicBool::new(false)),
1277        )
1278        .await
1279        .expect("full VU context");
1280        println!(
1281            "full VU context (all shims)    = {} B",
1282            full.quickjs_heap_bytes()
1283        );
1284
1285        let gated = create_vu_js_context(
1286            2,
1287            &pm_state,
1288            &client,
1289            &ShimBundle::from_script(
1290                b"import http from 'k6/http'; export default () => http.get('http://x');",
1291            ),
1292            &SandboxConfig::default(),
1293            Arc::new(AtomicBool::new(false)),
1294        )
1295        .await
1296        .expect("gated VU context");
1297        println!(
1298            "http-only gated VU context     = {} B",
1299            gated.quickjs_heap_bytes()
1300        );
1301    }
1302
1303    /// TR-501: the exclusions must not break a real Postman script.
1304    ///
1305    /// Exercises the three surfaces the Postman row deliberately keeps —
1306    /// chai-style `pm.expect(...).to.eql(...)`, lodash `_.map`, and
1307    /// `CryptoJS.MD5` — through the production path
1308    /// (`create_vu_js_context` → `bootstrap_shims` → the keyed bytecode
1309    /// cache) with the bundle `ShimBundle::for_format("postman", …)`
1310    /// actually selects for this collection.
1311    ///
1312    /// This is the test that fails if someone "optimises" chai, lodash or
1313    /// cryptojs out of the Postman row: each assertion below becomes a
1314    /// `ReferenceError`, which is exactly the customer-visible symptom.
1315    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1316    async fn postman_script_runs_under_the_postman_bundle() {
1317        let collection = br#"{"info":{"schema":"https://schema.getpostman.com/json/collection/v2.1.0/collection.json"},
1318            "item":[{"name":"a","event":[{"listen":"test","script":{"exec":[
1319              "pm.expect(_.map([1,2],String)).to.eql(['1','2']);",
1320              "pm.environment.set('h', CryptoJS.MD5('abc').toString());"
1321            ]}}]}]}"#;
1322        let bundle = ShimBundle::for_format("postman", collection);
1323        assert!(
1324            !shim_names(&bundle).contains(&"bru-shim"),
1325            "precondition: this run is on the NARROWED Postman bundle"
1326        );
1327
1328        let mut ctx = new_vu_ctx(21, &bundle).await;
1329
1330        // chai-style deep equality through pm.expect — throws on failure.
1331        let eql = ctx
1332            .eval("(() => { try { pm.expect(_.map([1,2],String)).to.eql(['1','2']); return 'ok'; } catch (e) { return 'threw: ' + e; } })()")
1333            .await
1334            .expect("probe should eval");
1335        assert_eq!(eql, "ok", "pm.expect(...).to.eql(...) over _.map failed");
1336
1337        // CryptoJS.MD5 must produce the published vector for "abc".
1338        let md5 = ctx
1339            .eval("CryptoJS.MD5('abc').toString()")
1340            .await
1341            .expect("probe should eval");
1342        assert_eq!(
1343            md5, "900150983cd24fb0d6963f7d28e17f72",
1344            "CryptoJS.MD5('abc') must match the published vector"
1345        );
1346
1347        // …and the negative half: bru really is absent, so the exclusion is
1348        // doing something rather than being silently ignored.
1349        let no_bru = ctx
1350            .eval("typeof bru === 'undefined'")
1351            .await
1352            .expect("probe should eval");
1353        assert_eq!(no_bru, "true", "bru.js must not be materialised");
1354    }
1355
1356    /// TR-501: the exclusions must not break a real k6 script either.
1357    ///
1358    /// `check` is the idiom every k6 script uses, and it comes from pm.js
1359    /// (pm.js:1625) — which is why the k6 row keeps pm. `crypto` here is
1360    /// `CryptoJS`.
1361    ///
1362    /// NOT asserted, deliberately: `sleep` and `http`. Both are `undefined`
1363    /// in this path on master under the FULL default bundle as well — the
1364    /// `sleep` wrapper `create_vu_js_context` appended used to be a
1365    /// block-scoped `async function` that never reached the global object
1366    /// (fixed: it is now an explicit `globalThis.sleep = …` assignment), and
1367    /// `http.*` comes
1368    /// from the k6 DRIVER's own bundle in `tropel-input-k6`, which never goes
1369    /// through `ShimBundle`. Neither is something this change removed; see
1370    /// `narrowing_removes_only_the_excluded_globals`, which pins that
1371    /// difference directly instead of asserting a value nothing produces.
1372    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1373    async fn k6_script_runs_under_the_k6_bundle() {
1374        // Repointed from "k6" to "postman": the k6 row now returns the full
1375        // bundle on purpose (a k6 script is arbitrary JS), so it is no longer
1376        // a narrowed bundle and cannot serve as this test's subject.
1377        // Mentions CryptoJS and _. so content gating keeps them — otherwise
1378        // this probes content narrowing, not format narrowing.
1379        let script = b"// CryptoJS _.map\nexport default function () {\n  check(1, {'one': v => v === 1});\n}";
1380        let bundle = ShimBundle::for_format("postman", script);
1381        assert!(
1382            !shim_names(&bundle).contains(&"bru-shim"),
1383            "precondition: this run is on a NARROWED bundle"
1384        );
1385
1386        let mut ctx = new_vu_ctx(22, &bundle).await;
1387
1388        let checked = ctx
1389            .eval("typeof check === 'function' && check(1, {'one': v => v === 1})")
1390            .await
1391            .expect("probe should eval");
1392        assert_eq!(
1393            checked, "true",
1394            "k6's `check` is installed by pm.js — dropping pm from the k6 row breaks it"
1395        );
1396
1397        let metrics = ctx
1398            .eval("['Counter','Gauge','Rate','Trend','group'].every(n => typeof globalThis[n] === 'function')")
1399            .await
1400            .expect("probe should eval");
1401        assert_eq!(
1402            metrics, "true",
1403            "k6's metric constructors and `group` also come from pm.js"
1404        );
1405
1406        let sha = ctx
1407            .eval("CryptoJS.SHA256('abc').toString()")
1408            .await
1409            .expect("probe should eval");
1410        assert_eq!(
1411            sha, "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
1412            "CryptoJS.SHA256('abc') must match the published vector"
1413        );
1414
1415        let no_bru = ctx
1416            .eval("typeof bru === 'undefined'")
1417            .await
1418            .expect("probe should eval");
1419        assert_eq!(no_bru, "true", "bru.js must not be materialised");
1420    }
1421
1422    /// TR-501: narrowing a bundle must remove EXACTLY the globals of the
1423    /// shims it drops, and nothing else.
1424    ///
1425    /// The per-format tests above assert the bundle's contents; this asserts
1426    /// the consequence in the VU context, differentially against the full
1427    /// default bundle. That is the shape that catches collateral damage — a
1428    /// shim quietly depending on another one, an ordering assumption, a
1429    /// `var x = x || {}` that only worked because something earlier in the
1430    /// bundle had already run. Comparing against `ShimBundle::default()`
1431    /// rather than against a hardcoded list also means it keeps working when
1432    /// a new shim is added: both legs move together.
1433    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1434    async fn narrowing_removes_only_the_excluded_globals() {
1435        // Every global any shim in the bundle installs, plus the two the k6
1436        // surface is expected to want.
1437        const PROBED: &[&str] = &[
1438            "__tropelDeepEqual",
1439            "pm",
1440            "postman",
1441            "trp",
1442            "check",
1443            "group",
1444            "Counter",
1445            "Gauge",
1446            "Rate",
1447            "Trend",
1448            "chai",
1449            "expect",
1450            "_",
1451            "CryptoJS",
1452            "exec",
1453            "test",
1454            "bru",
1455            "req",
1456            "res",
1457            "sleep",
1458            "http",
1459        ];
1460        let probe = format!(
1461            "JSON.stringify({:?}.filter(n => typeof globalThis[n] !== 'undefined'))",
1462            PROBED
1463        );
1464
1465        async fn defined_globals(vu: u32, bundle: &ShimBundle, probe: &str) -> Vec<String> {
1466            let mut ctx = new_vu_ctx(vu, bundle).await;
1467            let json = ctx.eval(probe).await.expect("probe should eval");
1468            serde_json::from_str(&json).expect("probe returns a JSON array")
1469        }
1470
1471        let full = defined_globals(41, &ShimBundle::default(), &probe).await;
1472        assert!(
1473            full.contains(&"bru".to_string()) && full.contains(&"_".to_string()),
1474            "precondition: the default bundle really does install bru and lodash — got {full:?}"
1475        );
1476
1477        // (format, input, globals the narrowing is ALLOWED to remove)
1478        let cases: &[(&str, &[u8], &[&str])] = &[
1479            (
1480                "postman",
1481                br#"{"info":{"schema":"getpostman.com/collection"},"exec":"pm.expect(_.map([1],String)); CryptoJS.MD5('x')"}"#,
1482                &["bru", "req", "res"],
1483            ),
1484            // "k6" intentionally absent: that row returns the full bundle,
1485            // so it removes nothing and this table asserts removal.
1486            (
1487                "postman",
1488                b"export default () => check(1, {}); // _.map CryptoJS",
1489                &["bru", "req", "res"],
1490            ),
1491            (
1492                "har",
1493                br#"{"log":{"entries":[]}}"#,
1494                &["bru", "req", "res", "chai", "expect", "_", "CryptoJS"],
1495            ),
1496        ];
1497
1498        for (format, input, allowed_missing) in cases {
1499            let bundle = ShimBundle::for_format(format, input);
1500            let narrowed = defined_globals(42, &bundle, &probe).await;
1501
1502            let missing: Vec<&String> = full.iter().filter(|g| !narrowed.contains(g)).collect();
1503            let unexpected: Vec<&&String> = missing
1504                .iter()
1505                .filter(|g| !allowed_missing.contains(&g.as_str()))
1506                .collect();
1507            assert!(
1508                unexpected.is_empty(),
1509                "'{format}' narrowing removed globals it was not allowed to: {unexpected:?} \
1510                 (bundle {:?}; full had {full:?}, narrowed has {narrowed:?})",
1511                shim_names(&bundle)
1512            );
1513
1514            let extra: Vec<&String> = narrowed.iter().filter(|g| !full.contains(g)).collect();
1515            assert!(
1516                extra.is_empty(),
1517                "'{format}' narrowing INVENTED globals the default bundle does not have: {extra:?}"
1518            );
1519
1520            // The exclusion must actually bite, or the test proves nothing.
1521            assert!(
1522                !missing.is_empty(),
1523                "'{format}' bundle {:?} removed nothing at all — the format table is inert",
1524                shim_names(&bundle)
1525            );
1526        }
1527    }
1528
1529    /// TR-501, the core of the fix: the bytecode cache must hold a SEPARATE,
1530    /// DIFFERENT blob per distinct bundle.
1531    ///
1532    /// **Fails on pre-fix code.** The cache was one
1533    /// `static SHIM_BYTECODE: OnceLock<Option<Vec<u8>>>` keyed on nothing,
1534    /// and its own comment said reusing it for a second bundle "would
1535    /// silently serve the wrong bytecode" — so `bootstrap_shims` guarded the
1536    /// whole path behind `if shim.is_default()`. On that code a narrowed
1537    /// bundle never reaches the cache at all: only ONE entry appears, and
1538    /// every gated VU pays a full source parse+compile. That is why gating
1539    /// measured 557,824 B/VU against the default bundle's 497,584 B/VU.
1540    ///
1541    /// Asserted here: two distinct bundles produce two cache entries with
1542    /// two distinct non-empty blobs, AND each context ends up with exactly
1543    /// the globals of the bundle it asked for (so the right blob reached the
1544    /// right context — a cache that keyed correctly but served crosswise
1545    /// would pass the count assertion alone).
1546    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1547    async fn bytecode_cache_serves_distinct_bytecode_per_bundle() {
1548        let full = ShimBundle::default();
1549        // Narrower on purpose: no chai, no lodash, no cryptojs, no bru.
1550        let narrow = ShimBundle::for_format("har", b"{}");
1551        assert_ne!(
1552            full.key(),
1553            narrow.key(),
1554            "precondition: the two bundles must have distinct identities"
1555        );
1556
1557        let before: Vec<BundleKey> = shim_bytecode_cache_snapshot()
1558            .into_iter()
1559            .map(|(k, _)| k)
1560            .collect();
1561
1562        let mut ctx_full = new_vu_ctx(31, &full).await;
1563        let mut ctx_narrow = new_vu_ctx(32, &narrow).await;
1564
1565        let after = shim_bytecode_cache_snapshot();
1566        let full_slot = after
1567            .iter()
1568            .find(|(k, _)| *k == full.key())
1569            .expect("the default bundle must be in the bytecode cache");
1570        let narrow_slot = after.iter().find(|(k, _)| *k == narrow.key()).expect(
1571            "the NARROWED bundle must be in the bytecode cache — on the pre-fix single \
1572             OnceLock it never got there, which is what made gating cost more than it saved",
1573        );
1574
1575        let full_bc = full_slot.1.as_ref().expect("default bytecode compiled");
1576        let narrow_bc = narrow_slot.1.as_ref().expect("narrow bytecode compiled");
1577        assert!(!full_bc.is_empty() && !narrow_bc.is_empty());
1578        assert_ne!(
1579            full_bc.as_slice(),
1580            narrow_bc.as_slice(),
1581            "two different shim bundles must compile to different bytecode"
1582        );
1583        assert!(
1584            narrow_bc.len() < full_bc.len(),
1585            "the narrowed bundle carries 4 fewer shims, so its bytecode must be smaller \
1586             (full {} B, narrow {} B)",
1587            full_bc.len(),
1588            narrow_bc.len()
1589        );
1590        assert!(
1591            !before.contains(&narrow.key()),
1592            "precondition: the narrow bundle must not have been cached before this test"
1593        );
1594
1595        // The right blob reached the right context.
1596        let full_globals = ctx_full
1597            .eval("typeof _ === 'object' && typeof chai === 'object' && typeof bru === 'object'")
1598            .await
1599            .expect("probe");
1600        assert_eq!(
1601            full_globals, "true",
1602            "the default bundle's context must have lodash, chai and bru"
1603        );
1604        let narrow_globals = ctx_narrow
1605            .eval(
1606                "typeof _ === 'undefined' && typeof chai === 'undefined' \
1607                 && typeof bru === 'undefined' && typeof CryptoJS === 'undefined' \
1608                 && typeof pm === 'object'",
1609            )
1610            .await
1611            .expect("probe");
1612        assert_eq!(
1613            narrow_globals, "true",
1614            "the narrowed bundle's context must NOT have been served the default bundle's bytecode"
1615        );
1616    }
1617
1618    /// TR-501: per-VU heap by input format, amortised over N real VU
1619    /// contexts so the bytecode cache is warm — a single context pays the
1620    /// one-off compile and is not representative of VU number 2..N.
1621    ///
1622    /// Each context here owns a private `rquickjs::Runtime` (master; the
1623    /// shared-Runtime work is TR-503 / PR #481), so `quickjs_heap_bytes()`
1624    /// reports only its own runtime and summing is correct.
1625    ///
1626    /// `cargo test -p tropel-engine --release per_vu_heap_by_format -- --nocapture --ignored`
1627    #[tokio::test]
1628    #[ignore = "measurement, not an assertion — run explicitly with --nocapture"]
1629    async fn per_vu_heap_by_format() {
1630        const N: u32 = 25;
1631
1632        let postman = br#"{"info":{"schema":"https://schema.getpostman.com/json/collection/v2.1.0/collection.json"},
1633            "item":[{"name":"a","event":[{"listen":"test","script":{"exec":[
1634              "pm.expect(_.map([1],String)).to.eql(['1']);",
1635              "pm.environment.set('h', CryptoJS.MD5('x').toString());"]}}]}]}"#;
1636        let k6 = b"import http from 'k6/http';\nexport default function () { check(http.get('http://x'), {'ok': r => r.status === 200}); }";
1637        let har = br#"{"log":{"entries":[{"request":{"url":"https://example.com/a"}}]}}"#;
1638        let http_only = b"import http from 'k6/http'; export default () => http.get('http://x');";
1639
1640        let bare = tropel_js::JsContext::new(None, None)
1641            .await
1642            .expect("bare context");
1643        println!(
1644            "bare JsContext (no shims)                = {:>9} B",
1645            bare.quickjs_heap_bytes()
1646        );
1647
1648        let cases: Vec<(&str, ShimBundle)> = vec![
1649            ("default (all 7 shims)", ShimBundle::default()),
1650            (
1651                "content-gated http-only (no format)",
1652                ShimBundle::from_script(http_only),
1653            ),
1654            ("format=k6", ShimBundle::for_format("k6", k6)),
1655            ("format=postman", ShimBundle::for_format("postman", postman)),
1656            ("format=har", ShimBundle::for_format("har", har)),
1657            // NOT SHIPPED — this quantifies the headroom `format_shims`
1658            // deliberately leaves on the table by keeping pm.js in every
1659            // row (70,197 B of source, the largest single shim). See the
1660            // `format_shims` doc comment for why it is not taken.
1661            (
1662                "[not shipped] har minus pm.js",
1663                ShimBundle::from_shims(&[Shim::DeepEqual, Shim::Exec]),
1664            ),
1665        ];
1666
1667        for (label, bundle) in cases {
1668            let mut ctxs = Vec::with_capacity(N as usize);
1669            for i in 0..N {
1670                ctxs.push(new_vu_ctx(i, &bundle).await);
1671            }
1672            // `quickjs_heap_bytes()` reads the RUNTIME's heap, and since TR-503
1673            // every context on this thread shares one runtime — so all N reads
1674            // return the same figure and summing them is N x double-counting.
1675            // The previous `sum / N` therefore printed the whole N-context
1676            // runtime heap under a `B/VU` label, ~N x too large. Read once and
1677            // divide once.
1678            let whole = ctxs[0].quickjs_heap_bytes();
1679            debug_assert_eq!(
1680                whole,
1681                ctxs[N as usize - 1].quickjs_heap_bytes(),
1682                "contexts on one thread must share a runtime; if this fires, \
1683                 sharing regressed and the arithmetic below is wrong"
1684            );
1685            println!(
1686                "{label:<40} = {:>9} B/VU  (N={N}, runtime total {whole} B, shims: {})",
1687                whole / u64::from(N),
1688                shim_names(&bundle).join("+")
1689            );
1690            std::hint::black_box(ctxs);
1691        }
1692    }
1693
1694    /// A VU context wired exactly as production wires one, for the tests
1695    /// that need a real bootstrap rather than a bundle inspection.
1696    async fn new_vu_ctx(vu_id: u32, bundle: &ShimBundle) -> tropel_js::JsContext {
1697        let pm_state = new_pm_state();
1698        let client: Arc<dyn DriverHttpClient> = Arc::new(DriverHttpClientImpl {
1699            client: VuCookieClient::new(
1700                HttpClient::new(&HttpConfig::default()).expect("http client should construct"),
1701            ),
1702        });
1703        create_vu_js_context(
1704            vu_id,
1705            &pm_state,
1706            &client,
1707            bundle,
1708            &SandboxConfig::default(),
1709            Arc::new(AtomicBool::new(false)),
1710        )
1711        .await
1712        .expect("VU context must be created")
1713    }
1714}