use std::borrow::Cow;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
pub(crate) fn js_heap_bytes() -> usize {
std::env::var("TROPEL_JS_HEAP_MB")
.ok()
.and_then(|s| s.parse::<usize>().ok())
.map(|mb| mb * 1024 * 1024)
.unwrap_or(10 * 1024 * 1024)
}
pub(crate) fn js_deadline_secs() -> Duration {
std::env::var("TROPEL_JS_DEADLINE_SECS")
.ok()
.and_then(|s| s.parse::<u64>().ok())
.map(Duration::from_secs)
.unwrap_or(Duration::from_secs(10))
}
use tropel_sandbox::config::SandboxConfig;
use tropel_sandbox::state::SharedPmState;
use tropel_sdk::error::TropelError;
use tropel_sdk::traits::DriverHttpClient;
use tropel_sdk::Result;
pub(crate) const SHIM_BUNDLE_VERSION: &str = "0.1.0";
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Shim {
DeepEqual,
K6Core,
Pm,
Chai,
Lodash,
CryptoJs,
Exec,
Bru,
Fetch,
}
impl Shim {
pub const ALL: [Shim; 9] = [
Shim::DeepEqual,
Shim::K6Core,
Shim::Pm,
Shim::Chai,
Shim::Lodash,
Shim::CryptoJs,
Shim::Exec,
Shim::Bru,
Shim::Fetch,
];
pub fn name(self) -> &'static str {
match self {
Shim::DeepEqual => "deep-equal-shim",
Shim::K6Core => "k6-core-shim",
Shim::Pm => "pm-shim",
Shim::Chai => "chai-shim",
Shim::Lodash => "lodash-shim",
Shim::CryptoJs => "cryptojs-shim",
Shim::Exec => "exec-shim",
Shim::Bru => "bru-shim",
Shim::Fetch => "fetch-shim",
}
}
pub fn source(self) -> &'static str {
match self {
Shim::DeepEqual => include_str!("../js/shared/deep-equal.js"),
Shim::K6Core => include_str!("../js/shared/k6-core.js"),
Shim::Pm => include_str!("../js/scripting-api/pm.js"),
Shim::Chai => include_str!("../js/chai/chai-shim.js"),
Shim::Lodash => include_str!("../js/lodash/lodash-shim.js"),
Shim::CryptoJs => include_str!("../js/cryptojs-shim/cryptojs.js"),
Shim::Exec => include_str!("../js/exec/exec.js"),
Shim::Bru => include_str!("../js/scripting-api/bru.js"),
Shim::Fetch => include_str!("../js/scripting-api/fetch.js"),
}
}
}
pub struct ShimEntry(pub &'static str, pub Cow<'static, str>);
pub struct ShimBundle(pub Vec<ShimEntry>);
impl ShimBundle {
pub fn from_shims(shims: &[Shim]) -> Self {
Self(
shims
.iter()
.map(|s| ShimEntry(s.name(), Cow::Borrowed(s.source())))
.collect(),
)
}
pub fn render(&self) -> String {
let mut out = String::new();
for ShimEntry(name, src) in &self.0 {
out.push_str(&format!("// ==== shim: {name} ====\n"));
out.push_str(src);
out.push('\n');
}
out
}
pub(crate) fn key(&self) -> BundleKey {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
self.0.len().hash(&mut h);
for ShimEntry(name, src) in &self.0 {
name.hash(&mut h);
match src {
Cow::Borrowed(s) => {
0u8.hash(&mut h);
(s.as_ptr() as usize).hash(&mut h);
s.len().hash(&mut h);
}
Cow::Owned(s) => {
1u8.hash(&mut h);
s.hash(&mut h);
}
}
}
BundleKey(h.finish())
}
}
impl Default for ShimBundle {
fn default() -> Self {
Self::from_shims(&Shim::ALL)
}
}
fn format_shims(format: &str) -> Option<&'static [Shim]> {
use Shim::*;
Some(match format {
"postman" => &[DeepEqual, K6Core, Pm, Chai, Lodash, CryptoJs, Exec, Fetch],
"bru" => &[
DeepEqual, K6Core, Pm, Chai, Lodash, CryptoJs, Exec, Bru, Fetch,
],
"k6" => return None,
"har" | "openapi" | "http" | "insomnia" => &[DeepEqual, K6Core, Pm, Exec],
_ => return None,
})
}
impl ShimBundle {
pub fn for_format(format: &str, input: &[u8]) -> Self {
let Some(allowed) = format_shims(format) else {
tracing::debug!(
"TR-501: no shim table for input format '{format}' — using the full default bundle"
);
return Self::default();
};
let src = String::from_utf8_lossy(input);
let needs_crypto =
src.contains("CryptoJS") || src.contains("crypto.") || src.contains("crypto ");
let needs_lodash = src.contains("_.") || src.contains("lodash");
let kept: Vec<Shim> = allowed
.iter()
.copied()
.filter(|s| match s {
Shim::Lodash => needs_lodash,
Shim::CryptoJs => needs_crypto,
_ => true,
})
.collect();
Self::from_shims(&kept)
}
pub fn for_format_path(format: &str, path: &std::path::Path) -> Self {
match std::fs::read(path) {
Ok(bytes) => Self::for_format(format, &bytes),
Err(e) => {
tracing::debug!(
"TR-501: could not read '{}' for shim gating ({e}) — using the full default bundle",
path.display()
);
Self::default()
}
}
}
pub fn from_script(script: &[u8]) -> Self {
let src = String::from_utf8_lossy(script);
let needs_crypto =
src.contains("CryptoJS") || src.contains("crypto.") || src.contains("crypto ");
let needs_lodash = src.contains("_.") || src.contains("lodash");
let kept: Vec<Shim> = Shim::ALL
.iter()
.copied()
.filter(|s| match s {
Shim::Lodash => needs_lodash,
Shim::CryptoJs => needs_crypto,
_ => true,
})
.collect();
Self::from_shims(&kept)
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) struct BundleKey(u64);
struct ShimBytecodeSlot {
key: BundleKey,
bytecode: Option<Arc<Vec<u8>>>,
run_failed: bool,
}
const SHIM_BYTECODE_CACHE_CAP: usize = 16;
static SHIM_BYTECODE_CACHE: Mutex<Vec<ShimBytecodeSlot>> = Mutex::new(Vec::new());
static SHIM_BYTECODE_CACHE_FULL_LOGGED: AtomicBool = AtomicBool::new(false);
fn shim_bytecode_for(
ctx: &mut tropel_js::JsContext,
bundle: &ShimBundle,
key: BundleKey,
) -> Option<Arc<Vec<u8>>> {
let mut cache = SHIM_BYTECODE_CACHE
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
if let Some(slot) = cache.iter().find(|s| s.key == key) {
if slot.run_failed {
return None;
}
return slot.bytecode.clone();
}
if cache.len() >= SHIM_BYTECODE_CACHE_CAP {
if !SHIM_BYTECODE_CACHE_FULL_LOGGED.swap(true, Ordering::Relaxed) {
tracing::warn!(
"Shim bytecode cache is full ({SHIM_BYTECODE_CACHE_CAP} distinct bundles); \
further bundles fall back to per-VU source eval"
);
}
return None;
}
let rendered = bundle.render();
let compiled = match ctx.compile_global_bytecode(&rendered) {
Ok(bc) => {
tracing::info!(
"Compiled shim bundle [{}] to bytecode once ({} B from {} B of source) — reusing across VUs",
bundle
.0
.iter()
.map(|e| e.0)
.collect::<Vec<_>>()
.join("+"),
bc.len(),
rendered.len()
);
Some(Arc::new(bc))
}
Err(e) => {
tracing::warn!(
"Shim bytecode compilation failed ({e}); falling back to per-VU source eval"
);
None
}
};
cache.push(ShimBytecodeSlot {
key,
bytecode: compiled.clone(),
run_failed: false,
});
compiled
}
fn mark_shim_bytecode_run_failed(key: BundleKey) {
let mut cache = SHIM_BYTECODE_CACHE
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
if let Some(slot) = cache.iter_mut().find(|s| s.key == key) {
slot.run_failed = true;
}
}
#[cfg(test)]
pub(crate) fn shim_bytecode_cache_snapshot() -> Vec<(BundleKey, Option<Arc<Vec<u8>>>)> {
let cache = SHIM_BYTECODE_CACHE
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
cache.iter().map(|s| (s.key, s.bytecode.clone())).collect()
}
pub(crate) async fn create_vu_js_context(
vu_id: u32,
pm_state: &SharedPmState,
http_client: &Arc<dyn DriverHttpClient>,
shim: &ShimBundle,
config: &SandboxConfig,
force_stop: Arc<AtomicBool>,
) -> Option<tropel_js::JsContext> {
let mut ctx = match tropel_js::JsContext::new_with_force_stop(
Some(js_heap_bytes()),
Some(js_deadline_secs()),
force_stop.clone(),
)
.await
{
Ok(ctx) => ctx,
Err(e) => {
tracing::warn!(
"VU {}: Failed to create JS context: {} (scripts will be skipped)",
vu_id,
e
);
return None;
}
};
if config != &SandboxConfig::default() {
if let Err(e) = ctx.eval(&config.render_js_preamble()).await {
tracing::warn!(
"VU {}: Failed to set sandbox config preamble: {} — failing the VU context",
vu_id,
e
);
return None;
}
}
if let Err(e) = bootstrap_shims(&mut ctx, vu_id, shim).await {
tracing::error!(
"VU {}: JS shim bootstrap FAILED: {} — scripts will be skipped",
vu_id,
e
);
return None;
}
if let Err(e) = tropel_native::install_all(&mut ctx).await {
tracing::warn!("VU {}: Failed to install native modules: {}", vu_id, e);
}
let bridge = tropel_sandbox::bindings::trp::TrpBridge::with_http_client(
pm_state.clone(),
http_client.clone(),
);
if let Err(e) = bridge.install(&mut ctx) {
tracing::warn!("VU {}: Failed to install PM bridge functions: {}", vu_id, e);
}
let (deadline, max_exec) = ctx.interrupt_deadline_handle();
let force_stop_sleep = force_stop.clone();
ctx.with_ctx(|rq_ctx| {
let globals = rq_ctx.globals();
let deadline_sleep = deadline.clone();
let _ = globals.set(
"__tropel_native_sleep",
rquickjs::function::Func::from(move |ms: f64| {
if ms > 0.0 {
let total = Duration::from_secs_f64(ms / 1000.0);
let deadline_inner = std::time::Instant::now() + total;
let step = Duration::from_millis(10);
loop {
if force_stop_sleep.load(Ordering::Acquire) {
deadline_sleep.store(0, Ordering::Relaxed);
return;
}
let now = std::time::Instant::now();
if now >= deadline_inner {
break;
}
let remaining = deadline_inner - now;
std::thread::sleep(remaining.min(step));
}
}
tropel_js::rearm_deadline(&deadline_sleep, max_exec);
}),
);
});
let sleep_code = [
"if (typeof globalThis.sleep === 'undefined') {",
" globalThis.sleep = async function sleep(seconds) {",
" if (typeof __tropel_native_sleep === 'function') {",
" await __tropel_native_sleep(seconds * 1000);",
" }",
" };",
"}",
]
.join("\n");
let _ = ctx.eval(&sleep_code).await;
Some(ctx)
}
async fn bootstrap_shims(
ctx: &mut tropel_js::JsContext,
vu_id: u32,
shim: &ShimBundle,
) -> Result<()> {
let key = shim.key();
if let Some(bytecode) = shim_bytecode_for(ctx, shim, key) {
match ctx.run_global_bytecode(&bytecode).await {
Ok(()) => return Ok(()),
Err(e) => {
mark_shim_bytecode_run_failed(key);
tracing::warn!(
"VU {vu_id}: Failed to run JS shim bytecode: {e} \
(disabling the bytecode path for this bundle; falling back to source eval)"
);
let rendered = shim.render();
return ctx.bootstrap_library(&rendered).await.map_err(|e2| {
TropelError::Js(format!(
"VU {vu_id}: shim source eval failed after bytecode run error: {e2}"
))
});
}
}
}
let rendered = shim.render();
ctx.bootstrap_library(&rendered)
.await
.map_err(|e| TropelError::Js(format!("VU {vu_id}: shim source eval failed: {e}")))
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
use tropel_core::config::HttpConfig;
use tropel_http::client::{HttpClient, VuCookieClient};
use tropel_sandbox::state::new_pm_state;
use tropel_sdk::traits::DriverHttpClient;
#[test]
fn render_emits_every_shim_in_the_default_bundle() {
let d = ShimBundle::default();
assert_eq!(
d.0.iter().map(|e| e.0).collect::<Vec<_>>(),
vec![
"deep-equal-shim",
"k6-core-shim",
"pm-shim",
"chai-shim",
"lodash-shim",
"cryptojs-shim",
"exec-shim",
"bru-shim",
"fetch-shim"
],
"the default bundle is Shim::ALL, in canonical order"
);
let rendered = d.render();
let mut cursor = 0usize;
for ShimEntry(name, src) in &d.0 {
let header = format!("// ==== shim: {name} ====\n");
let at = rendered[cursor..].find(&header).map(|i| i + cursor);
let at = at.unwrap_or_else(|| panic!("render() dropped the {name} section header"));
cursor = at + header.len();
assert!(
rendered[cursor..].starts_with(src.as_ref()),
"render() dropped or reordered the {name} source"
);
cursor += src.len();
}
let bru_src = Shim::Bru.source();
assert!(
rendered.contains(bru_src),
"render() must emit bru.js — the W2 line-182 symptom was `typeof bru === 'undefined'`"
);
}
#[test]
fn every_scripted_format_bundle_carries_fetch() {
for format in ["postman", "bru"] {
let shims = format_shims(format)
.unwrap_or_else(|| panic!("{format} should have a narrowed bundle"));
assert!(
shims.contains(&Shim::Fetch),
"`{format}` scripts are arbitrary JS, and `fetch` is how arbitrary JS \
makes a request — leaving it out makes the same script behave \
differently here than under the default bundle: {shims:?}"
);
}
assert!(
format_shims("k6").is_none(),
"k6 takes the full default bundle; if that changes, it needs Fetch too"
);
assert!(
Shim::ALL.contains(&Shim::Fetch),
"the default bundle carries fetch"
);
}
#[test]
fn postman_bundle_excludes_bru_and_keeps_the_assertion_libraries() {
let collection = br#"{"info":{"schema":"getpostman.com/collection"},
"item":[{"event":[{"listen":"test","script":{"exec":[
"pm.expect(_.map([1],String)).to.eql(['1']);",
"pm.environment.set('h', CryptoJS.MD5('x').toString());"
]}}]}]}"#;
let names = shim_names(&ShimBundle::for_format("postman", collection));
assert!(
!names.contains(&"bru-shim"),
"a Postman run must not materialise bru.js — got {names:?}"
);
for required in [
"deep-equal-shim",
"k6-core-shim",
"pm-shim",
"chai-shim",
"exec-shim",
] {
assert!(
names.contains(&required),
"a Postman script can reach {required} — got {names:?}"
);
}
assert!(
names.contains(&"lodash-shim") && names.contains(&"cryptojs-shim"),
"this collection names both `_.` and `CryptoJS` — got {names:?}"
);
}
#[test]
fn postman_bundle_drops_unreferenced_optional_libraries() {
let collection = br#"{"info":{"schema":"getpostman.com/collection"},
"item":[{"event":[{"listen":"test","script":{"exec":[
"pm.test('ok', () => pm.response.to.have.status(200));"
]}}]}]}"#;
let names = shim_names(&ShimBundle::for_format("postman", collection));
assert!(
!names.contains(&"lodash-shim") && !names.contains(&"cryptojs-shim"),
"nothing in this collection names `_` or `CryptoJS` — got {names:?}"
);
assert!(
names.contains(&"pm-shim"),
"pm.js is not optional for Postman — got {names:?}"
);
}
#[test]
fn script_free_formats_exclude_the_user_script_libraries() {
let recorded = br#"{"log":{"entries":[{"request":{"url":"https://api.example.com/crypto.json?f=_.x&q=lodash"}}]}}"#;
for format in ["har", "openapi", "http", "insomnia"] {
let names = shim_names(&ShimBundle::for_format(format, recorded));
for excluded in ["chai-shim", "lodash-shim", "cryptojs-shim", "bru-shim"] {
assert!(
!names.contains(&excluded),
"'{format}' emits no scripts, so nothing can name {excluded} — got {names:?}"
);
}
assert_eq!(
names,
vec!["deep-equal-shim", "k6-core-shim", "pm-shim", "exec-shim"],
"'{format}' bundle"
);
}
}
#[test]
fn unknown_format_falls_back_to_the_full_bundle() {
for unknown in ["", "graphql", "subprocess:./gen.sh", "POSTMAN", "postman2"] {
let names = shim_names(&ShimBundle::for_format(unknown, b"{}"));
assert_eq!(
names,
shim_names(&ShimBundle::default()),
"unknown format '{unknown}' must get the full default bundle"
);
}
}
#[test]
fn assertion_libraries_are_unreachable_for_script_free_formats() {
use tropel_sdk::traits::InputAdapter;
fn count_scripts(items: &[tropel_sdk::scenario::ScenarioItem]) -> usize {
items
.iter()
.map(|i| i.prerequest.len() + i.test.len() + count_scripts(&i.items))
.sum()
}
let cases: Vec<(&str, Box<dyn InputAdapter>, &[u8])> = vec![
(
"har",
Box::new(tropel_input_har::HarInputAdapter),
br#"{"log":{"version":"1.2","creator":{"name":"t","version":"1"},"entries":[
{"startedDateTime":"2020-01-01T00:00:00Z","time":1,
"request":{"method":"GET","url":"https://example.com/a","httpVersion":"HTTP/1.1","headers":[],"queryString":[],"cookies":[],"headersSize":-1,"bodySize":-1},
"response":{"status":200,"statusText":"OK","httpVersion":"HTTP/1.1","headers":[],"cookies":[],"content":{"size":0,"mimeType":"text/plain"},"redirectURL":"","headersSize":-1,"bodySize":0},
"cache":{},"timings":{"send":0,"wait":1,"receive":0}}]}}"#,
),
(
"openapi",
Box::new(tropel_input_openapi::OpenApiInputAdapter),
br#"{"openapi":"3.0.0","info":{"title":"t","version":"1"},
"servers":[{"url":"https://example.com"}],
"paths":{"/a":{"get":{"responses":{"200":{"description":"ok"}}}}}}"#,
),
(
"http",
Box::new(tropel_input_http::HttpFileAdapter),
b"GET https://example.com/a\nAccept: application/json\n",
),
(
"insomnia",
Box::new(tropel_input_insomnia::InsomniaInputAdapter),
br#"{"_type":"export","__export_format":4,"resources":[
{"_id":"req_1","_type":"request","parentId":"wrk_1","name":"a","method":"GET","url":"https://example.com/a"},
{"_id":"wrk_1","_type":"workspace","name":"w"}]}"#,
),
];
for (format, adapter, bytes) in cases {
assert_eq!(
adapter.id(),
format,
"the format_shims key must be the adapter's own id"
);
let scenario = adapter
.parse(bytes)
.unwrap_or_else(|e| panic!("{format} fixture must parse: {e}"));
assert!(
!scenario.items.is_empty(),
"{format} fixture must produce at least one item, or it proves nothing"
);
assert_eq!(
count_scripts(&scenario.items),
0,
"the '{format}' row of format_shims drops chai/lodash/cryptojs/bru on the \
grounds that this adapter cannot emit a script. It just did. Put the \
libraries its scripts can reach back into format_shims."
);
}
}
fn shim_names(bundle: &ShimBundle) -> Vec<&'static str> {
bundle.0.iter().map(|e| e.0).collect()
}
use crate::vu_loop::DriverHttpClientImpl;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn create_vu_js_context_honors_custom_sandbox_config() {
let pm_state = new_pm_state();
let client: Arc<dyn DriverHttpClient> = Arc::new(DriverHttpClientImpl {
client: VuCookieClient::new(
HttpClient::new(&HttpConfig::default()).expect("http client should construct"),
),
});
let config = SandboxConfig {
namespace: "acme".into(),
aliases: vec!["product".into(), "wire".into()],
};
let mut ctx = create_vu_js_context(
7,
&pm_state,
&client,
&ShimBundle::default(),
&config,
Arc::new(AtomicBool::new(false)),
)
.await
.expect("context must be created");
let check = ctx
.eval(
"typeof acme === 'object' && typeof product === 'object' \
&& product === acme && wire === acme && typeof pm === 'object' \
&& typeof bru === 'object' && typeof trp === 'undefined' \
&& typeof tropel === 'undefined'",
)
.await
.expect("probe should eval");
assert_eq!(
check, "true",
"custom namespace/aliases must be installed via the preamble; default trp absent; bru must be evaluated by the real bundle path — got: {check}"
);
}
#[tokio::test]
async fn sleep_is_a_global_function_on_the_declarative_path() {
let pm_state = new_pm_state();
let client: Arc<dyn DriverHttpClient> = Arc::new(DriverHttpClientImpl {
client: VuCookieClient::new(
HttpClient::new(&HttpConfig::default()).expect("http client"),
),
});
let mut ctx = create_vu_js_context(
1,
&pm_state,
&client,
&ShimBundle::default(),
&SandboxConfig::default(),
Arc::new(AtomicBool::new(false)),
)
.await
.expect("VU context");
let ty = ctx.eval("typeof sleep").await.expect("eval");
assert_eq!(
ty, "function",
"sleep must be installed on globalThis for the declarative path — \
a block-scoped declaration silently leaves it undefined"
);
let elapsed = ctx
.eval_async(
"(async () => { const t = Date.now(); await sleep(0.05); return Date.now() - t; })()",
)
.await
.expect("sleep must be callable, not merely defined");
let ms: f64 = elapsed.trim().parse().unwrap_or(-1.0);
assert!(
ms >= 40.0,
"await sleep(0.05) must block ~50ms; got {elapsed:?} — the host fn \
is registered but not actually sleeping"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn per_vu_globals_are_isolated() {
let pm_state = new_pm_state();
let client: Arc<dyn DriverHttpClient> = Arc::new(DriverHttpClientImpl {
client: VuCookieClient::new(
HttpClient::new(&HttpConfig::default()).expect("http client should construct"),
),
});
let mut ctx1 = create_vu_js_context(
1,
&pm_state,
&client,
&ShimBundle::default(),
&SandboxConfig::default(),
Arc::new(AtomicBool::new(false)),
)
.await
.expect("ctx1");
let mut ctx2 = create_vu_js_context(
2,
&pm_state,
&client,
&ShimBundle::default(),
&SandboxConfig::default(),
Arc::new(AtomicBool::new(false)),
)
.await
.expect("ctx2");
let _ = ctx1.eval("var leak_test = 42; leak_test").await;
let check = ctx2
.eval("typeof leak_test === 'undefined'")
.await
.expect("probe");
assert_eq!(
check, "true",
"per-VU globals must be isolated — leak_test leaked to ctx2: {check}"
);
let c1 = ctx1.eval("typeof pm === 'object'").await.expect("c1");
let c2 = ctx2.eval("typeof pm === 'object'").await.expect("c2");
assert_eq!(c1, "true");
assert_eq!(c2, "true");
}
#[tokio::test]
async fn documented_per_vu_heap_matches_reality() {
const DOCUMENTED_BYTES: u64 = 497_584;
const TOLERANCE: f64 = 0.25;
let pm_state = new_pm_state();
let client: Arc<dyn DriverHttpClient> = Arc::new(DriverHttpClientImpl {
client: VuCookieClient::new(
HttpClient::new(&HttpConfig::default()).expect("http client should construct"),
),
});
let ctx = create_vu_js_context(
1,
&pm_state,
&client,
&ShimBundle::default(),
&SandboxConfig::default(),
Arc::new(AtomicBool::new(false)),
)
.await
.expect("full VU context");
let actual = ctx.quickjs_heap_bytes();
let low = (DOCUMENTED_BYTES as f64 * (1.0 - TOLERANCE)) as u64;
let high = (DOCUMENTED_BYTES as f64 * (1.0 + TOLERANCE)) as u64;
assert!(
(low..=high).contains(&actual),
"per-VU QuickJS heap is {actual} B but README/CONVENTIONS document \
{DOCUMENTED_BYTES} B (band {low}..={high}). Re-run \
`cargo test -p tropel-engine --release measure_per_vu_quickjs_heap \
-- --nocapture --ignored` and update both documents."
);
}
#[tokio::test]
#[ignore = "measurement, not an assertion — run explicitly with --nocapture"]
async fn measure_per_vu_quickjs_heap() {
let pm_state = new_pm_state();
let client: Arc<dyn DriverHttpClient> = Arc::new(DriverHttpClientImpl {
client: VuCookieClient::new(
HttpClient::new(&HttpConfig::default()).expect("http client should construct"),
),
});
let bare = tropel_js::JsContext::new(None, None)
.await
.expect("bare context");
println!(
"bare JsContext (no shims) = {} B",
bare.quickjs_heap_bytes()
);
let full = create_vu_js_context(
1,
&pm_state,
&client,
&ShimBundle::default(),
&SandboxConfig::default(),
Arc::new(AtomicBool::new(false)),
)
.await
.expect("full VU context");
println!(
"full VU context (all shims) = {} B",
full.quickjs_heap_bytes()
);
let gated = create_vu_js_context(
2,
&pm_state,
&client,
&ShimBundle::from_script(
b"import http from 'k6/http'; export default () => http.get('http://x');",
),
&SandboxConfig::default(),
Arc::new(AtomicBool::new(false)),
)
.await
.expect("gated VU context");
println!(
"http-only gated VU context = {} B",
gated.quickjs_heap_bytes()
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn postman_script_runs_under_the_postman_bundle() {
let collection = br#"{"info":{"schema":"https://schema.getpostman.com/json/collection/v2.1.0/collection.json"},
"item":[{"name":"a","event":[{"listen":"test","script":{"exec":[
"pm.expect(_.map([1,2],String)).to.eql(['1','2']);",
"pm.environment.set('h', CryptoJS.MD5('abc').toString());"
]}}]}]}"#;
let bundle = ShimBundle::for_format("postman", collection);
assert!(
!shim_names(&bundle).contains(&"bru-shim"),
"precondition: this run is on the NARROWED Postman bundle"
);
let mut ctx = new_vu_ctx(21, &bundle).await;
let eql = ctx
.eval("(() => { try { pm.expect(_.map([1,2],String)).to.eql(['1','2']); return 'ok'; } catch (e) { return 'threw: ' + e; } })()")
.await
.expect("probe should eval");
assert_eq!(eql, "ok", "pm.expect(...).to.eql(...) over _.map failed");
let md5 = ctx
.eval("CryptoJS.MD5('abc').toString()")
.await
.expect("probe should eval");
assert_eq!(
md5, "900150983cd24fb0d6963f7d28e17f72",
"CryptoJS.MD5('abc') must match the published vector"
);
let no_bru = ctx
.eval("typeof bru === 'undefined'")
.await
.expect("probe should eval");
assert_eq!(no_bru, "true", "bru.js must not be materialised");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn k6_script_runs_under_the_k6_bundle() {
let script = b"// CryptoJS _.map\nexport default function () {\n check(1, {'one': v => v === 1});\n}";
let bundle = ShimBundle::for_format("postman", script);
assert!(
!shim_names(&bundle).contains(&"bru-shim"),
"precondition: this run is on a NARROWED bundle"
);
let mut ctx = new_vu_ctx(22, &bundle).await;
let checked = ctx
.eval("typeof check === 'function' && check(1, {'one': v => v === 1})")
.await
.expect("probe should eval");
assert_eq!(
checked, "true",
"k6's `check` is installed by pm.js — dropping pm from the k6 row breaks it"
);
let metrics = ctx
.eval("['Counter','Gauge','Rate','Trend','group'].every(n => typeof globalThis[n] === 'function')")
.await
.expect("probe should eval");
assert_eq!(
metrics, "true",
"k6's metric constructors and `group` also come from pm.js"
);
let sha = ctx
.eval("CryptoJS.SHA256('abc').toString()")
.await
.expect("probe should eval");
assert_eq!(
sha, "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
"CryptoJS.SHA256('abc') must match the published vector"
);
let no_bru = ctx
.eval("typeof bru === 'undefined'")
.await
.expect("probe should eval");
assert_eq!(no_bru, "true", "bru.js must not be materialised");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn narrowing_removes_only_the_excluded_globals() {
const PROBED: &[&str] = &[
"__tropelDeepEqual",
"pm",
"postman",
"trp",
"check",
"group",
"Counter",
"Gauge",
"Rate",
"Trend",
"chai",
"expect",
"_",
"CryptoJS",
"exec",
"test",
"bru",
"req",
"res",
"sleep",
"http",
];
let probe = format!(
"JSON.stringify({:?}.filter(n => typeof globalThis[n] !== 'undefined'))",
PROBED
);
async fn defined_globals(vu: u32, bundle: &ShimBundle, probe: &str) -> Vec<String> {
let mut ctx = new_vu_ctx(vu, bundle).await;
let json = ctx.eval(probe).await.expect("probe should eval");
serde_json::from_str(&json).expect("probe returns a JSON array")
}
let full = defined_globals(41, &ShimBundle::default(), &probe).await;
assert!(
full.contains(&"bru".to_string()) && full.contains(&"_".to_string()),
"precondition: the default bundle really does install bru and lodash — got {full:?}"
);
let cases: &[(&str, &[u8], &[&str])] = &[
(
"postman",
br#"{"info":{"schema":"getpostman.com/collection"},"exec":"pm.expect(_.map([1],String)); CryptoJS.MD5('x')"}"#,
&["bru", "req", "res"],
),
(
"postman",
b"export default () => check(1, {}); // _.map CryptoJS",
&["bru", "req", "res"],
),
(
"har",
br#"{"log":{"entries":[]}}"#,
&["bru", "req", "res", "chai", "expect", "_", "CryptoJS"],
),
];
for (format, input, allowed_missing) in cases {
let bundle = ShimBundle::for_format(format, input);
let narrowed = defined_globals(42, &bundle, &probe).await;
let missing: Vec<&String> = full.iter().filter(|g| !narrowed.contains(g)).collect();
let unexpected: Vec<&&String> = missing
.iter()
.filter(|g| !allowed_missing.contains(&g.as_str()))
.collect();
assert!(
unexpected.is_empty(),
"'{format}' narrowing removed globals it was not allowed to: {unexpected:?} \
(bundle {:?}; full had {full:?}, narrowed has {narrowed:?})",
shim_names(&bundle)
);
let extra: Vec<&String> = narrowed.iter().filter(|g| !full.contains(g)).collect();
assert!(
extra.is_empty(),
"'{format}' narrowing INVENTED globals the default bundle does not have: {extra:?}"
);
assert!(
!missing.is_empty(),
"'{format}' bundle {:?} removed nothing at all — the format table is inert",
shim_names(&bundle)
);
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn bytecode_cache_serves_distinct_bytecode_per_bundle() {
let full = ShimBundle::default();
let narrow = ShimBundle::for_format("har", b"{}");
assert_ne!(
full.key(),
narrow.key(),
"precondition: the two bundles must have distinct identities"
);
let before: Vec<BundleKey> = shim_bytecode_cache_snapshot()
.into_iter()
.map(|(k, _)| k)
.collect();
let mut ctx_full = new_vu_ctx(31, &full).await;
let mut ctx_narrow = new_vu_ctx(32, &narrow).await;
let after = shim_bytecode_cache_snapshot();
let full_slot = after
.iter()
.find(|(k, _)| *k == full.key())
.expect("the default bundle must be in the bytecode cache");
let narrow_slot = after.iter().find(|(k, _)| *k == narrow.key()).expect(
"the NARROWED bundle must be in the bytecode cache — on the pre-fix single \
OnceLock it never got there, which is what made gating cost more than it saved",
);
let full_bc = full_slot.1.as_ref().expect("default bytecode compiled");
let narrow_bc = narrow_slot.1.as_ref().expect("narrow bytecode compiled");
assert!(!full_bc.is_empty() && !narrow_bc.is_empty());
assert_ne!(
full_bc.as_slice(),
narrow_bc.as_slice(),
"two different shim bundles must compile to different bytecode"
);
assert!(
narrow_bc.len() < full_bc.len(),
"the narrowed bundle carries 4 fewer shims, so its bytecode must be smaller \
(full {} B, narrow {} B)",
full_bc.len(),
narrow_bc.len()
);
assert!(
!before.contains(&narrow.key()),
"precondition: the narrow bundle must not have been cached before this test"
);
let full_globals = ctx_full
.eval("typeof _ === 'object' && typeof chai === 'object' && typeof bru === 'object'")
.await
.expect("probe");
assert_eq!(
full_globals, "true",
"the default bundle's context must have lodash, chai and bru"
);
let narrow_globals = ctx_narrow
.eval(
"typeof _ === 'undefined' && typeof chai === 'undefined' \
&& typeof bru === 'undefined' && typeof CryptoJS === 'undefined' \
&& typeof pm === 'object'",
)
.await
.expect("probe");
assert_eq!(
narrow_globals, "true",
"the narrowed bundle's context must NOT have been served the default bundle's bytecode"
);
}
#[tokio::test]
#[ignore = "measurement, not an assertion — run explicitly with --nocapture"]
async fn per_vu_heap_by_format() {
const N: u32 = 25;
let postman = br#"{"info":{"schema":"https://schema.getpostman.com/json/collection/v2.1.0/collection.json"},
"item":[{"name":"a","event":[{"listen":"test","script":{"exec":[
"pm.expect(_.map([1],String)).to.eql(['1']);",
"pm.environment.set('h', CryptoJS.MD5('x').toString());"]}}]}]}"#;
let k6 = b"import http from 'k6/http';\nexport default function () { check(http.get('http://x'), {'ok': r => r.status === 200}); }";
let har = br#"{"log":{"entries":[{"request":{"url":"https://example.com/a"}}]}}"#;
let http_only = b"import http from 'k6/http'; export default () => http.get('http://x');";
let bare = tropel_js::JsContext::new(None, None)
.await
.expect("bare context");
println!(
"bare JsContext (no shims) = {:>9} B",
bare.quickjs_heap_bytes()
);
let cases: Vec<(&str, ShimBundle)> = vec![
("default (all 7 shims)", ShimBundle::default()),
(
"content-gated http-only (no format)",
ShimBundle::from_script(http_only),
),
("format=k6", ShimBundle::for_format("k6", k6)),
("format=postman", ShimBundle::for_format("postman", postman)),
("format=har", ShimBundle::for_format("har", har)),
(
"[not shipped] har minus pm.js",
ShimBundle::from_shims(&[Shim::DeepEqual, Shim::Exec]),
),
];
for (label, bundle) in cases {
let mut ctxs = Vec::with_capacity(N as usize);
for i in 0..N {
ctxs.push(new_vu_ctx(i, &bundle).await);
}
let whole = ctxs[0].quickjs_heap_bytes();
debug_assert_eq!(
whole,
ctxs[N as usize - 1].quickjs_heap_bytes(),
"contexts on one thread must share a runtime; if this fires, \
sharing regressed and the arithmetic below is wrong"
);
println!(
"{label:<40} = {:>9} B/VU (N={N}, runtime total {whole} B, shims: {})",
whole / u64::from(N),
shim_names(&bundle).join("+")
);
std::hint::black_box(ctxs);
}
}
async fn new_vu_ctx(vu_id: u32, bundle: &ShimBundle) -> tropel_js::JsContext {
let pm_state = new_pm_state();
let client: Arc<dyn DriverHttpClient> = Arc::new(DriverHttpClientImpl {
client: VuCookieClient::new(
HttpClient::new(&HttpConfig::default()).expect("http client should construct"),
),
});
create_vu_js_context(
vu_id,
&pm_state,
&client,
bundle,
&SandboxConfig::default(),
Arc::new(AtomicBool::new(false)),
)
.await
.expect("VU context must be created")
}
}