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}