#![allow(
clippy::expect_used,
clippy::unwrap_used,
clippy::too_many_lines,
clippy::uninlined_format_args
)]
use std::sync::Arc;
use std::time::Duration;
use ferridriver::chromium;
use ferridriver::options::LaunchOptions;
use ferridriver_script::{
InMemoryVars, Outcome, PathSandbox, RunContext, RunOptions, ScriptEngineConfig, SessionTable,
};
fn data_url(html: &str) -> String {
format!(
"data:text/html,{}",
html
.bytes()
.map(|b| match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => (b as char).to_string(),
_ => format!("%{:02X}", b),
})
.collect::<String>()
)
}
fn ctx_for(vars: Arc<InMemoryVars>, sandbox: Arc<PathSandbox>, page: Option<Arc<ferridriver::Page>>) -> RunContext {
RunContext {
vars,
sandbox,
artifacts: None,
page,
browser_context: None,
request: None,
browser: None,
plugins: Vec::new(),
trusted_modules: false,
host: ferridriver_script::ExtensionHost::Mcp,
caps: ferridriver_script::ScriptCaps::default(),
}
}
fn ok_value(r: ferridriver_script::ScriptResult, label: &str) -> serde_json::Value {
match r.outcome {
Outcome::Ok { success } => success.value,
Outcome::Error { error } => panic!("[{label}] expected ok, got error: {error:?}"),
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn long_live_session_keeps_state_and_stays_healthy() {
let browser = chromium()
.launch(LaunchOptions::default())
.await
.expect("launch browser");
let page = browser.page().await.expect("page");
let tmp = tempfile::tempdir().expect("tmp");
let sandbox = Arc::new(PathSandbox::new(tmp.path()).expect("sandbox"));
let table = SessionTable::new(64, Some(Duration::from_secs(1800)));
let slot = table.acquire("mcp-1");
let cfg = ScriptEngineConfig::default();
{
let mut s = slot.lock().await;
let vars = s.vars();
let ctx = ctx_for(vars, sandbox.clone(), Some(page.clone()));
let r = s
.run(
cfg.clone(),
&format!(
"await page.goto({url:?}); globalThis.c = 0; vars.set('persisted', 'yes'); return true;",
url = data_url("<title>Long</title><body><h1 id='h'>hi</h1></body>")
),
&[],
RunOptions::default(),
ctx,
Some(1),
)
.await;
assert_eq!(ok_value(r, "seed"), serde_json::json!(true));
}
for i in 1..=40u32 {
let mut s = slot.lock().await;
let vars = s.vars();
let ctx = ctx_for(vars, sandbox.clone(), Some(page.clone()));
let r = s
.run(
cfg.clone(),
"globalThis.c += 1; \
const t = await page.locator('#h').textContent(); \
vars.set('lastIter', String(globalThis.c)); \
return { c: globalThis.c, t };",
&[],
RunOptions::default(),
ctx,
Some(1),
)
.await;
let v = ok_value(r, "iter");
assert_eq!(
v["c"],
serde_json::json!(i),
"REPL counter must advance with no rebuild"
);
assert_eq!(v["t"], serde_json::json!("hi"), "live page still reachable at iter {i}");
}
{
let mut s = slot.lock().await;
let vars = s.vars();
let ctx = ctx_for(vars, sandbox.clone(), Some(page.clone()));
let r = s
.run(
cfg.clone(),
"return { c: globalThis.c, persisted: vars.get('persisted'), last: vars.get('lastIter') };",
&[],
RunOptions::default(),
ctx,
Some(1),
)
.await;
let v = ok_value(r, "final");
assert_eq!(v["c"], serde_json::json!(40), "globalThis survived all 41 executes");
assert_eq!(v["persisted"], serde_json::json!("yes"));
assert_eq!(v["last"], serde_json::json!("40"));
}
for i in 0..250u32 {
let mut s = slot.lock().await;
let vars = s.vars();
let ctx = ctx_for(vars, sandbox.clone(), None);
let r = s
.run(
cfg.clone(),
"const a = Array.from({length: 5000}, (_, i) => ({ i, s: 'x'.repeat(32), nested: [i, i*2] })); \
globalThis.churn = (globalThis.churn || 0) + 1; \
return a.length + globalThis.churn;",
&[],
RunOptions::default(),
ctx,
Some(1),
)
.await;
match r.outcome {
Outcome::Ok { .. } => {},
Outcome::Error { error } => panic!("churn iter {i} failed (possible leak/OOM): {error:?}"),
}
}
{
let mut s = slot.lock().await;
let vars = s.vars();
let ctx = ctx_for(vars, sandbox.clone(), None);
let r = s
.run(
cfg.clone(),
"return globalThis.churn;",
&[],
RunOptions::default(),
ctx,
Some(1),
)
.await;
assert_eq!(
ok_value(r, "churn-final"),
serde_json::json!(250),
"all 250 churn executes ran in one VM"
);
}
{
let mut s = slot.lock().await;
let vars = s.vars();
let ctx = ctx_for(vars, sandbox.clone(), None);
let r = s
.run(
cfg.clone(),
"while (true) {}",
&[],
RunOptions {
timeout: Some(Duration::from_millis(200)),
..Default::default()
},
ctx,
Some(1),
)
.await;
assert!(
matches!(r.outcome, Outcome::Error { .. }),
"infinite loop must time out"
);
}
{
let mut s = slot.lock().await;
let vars = s.vars();
let ctx = ctx_for(vars, sandbox.clone(), None);
let r = s
.run(
cfg.clone(),
"return { rebuilt: globalThis.c === undefined, persisted: vars.get('persisted') };",
&[],
RunOptions::default(),
ctx,
Some(1),
)
.await;
let v = ok_value(r, "post-poison");
assert_eq!(
v["rebuilt"],
serde_json::json!(true),
"poisoned VM was rebuilt (globalThis cleared)"
);
assert_eq!(
v["persisted"],
serde_json::json!("yes"),
"durable vars survived the poison rebuild"
);
}
{
let mut s = slot.lock().await;
let vars = s.vars();
let ctx = ctx_for(vars, sandbox.clone(), None);
let r = s
.run(
cfg.clone(),
"globalThis.afterEpoch = (globalThis.afterEpoch || 0) + 1; \
return { fresh: globalThis.afterEpoch === 1, persisted: vars.get('persisted') };",
&[],
RunOptions::default(),
ctx,
Some(2), )
.await;
let v = ok_value(r, "epoch");
assert_eq!(v["fresh"], serde_json::json!(true), "epoch change rebuilt the VM");
assert_eq!(v["persisted"], serde_json::json!("yes"), "vars survived epoch rebuild");
}
}
#[tokio::test(flavor = "multi_thread")]
async fn session_table_cap_and_idle_ttl_end_to_end() {
let table = SessionTable::new(1, Some(Duration::from_millis(150)));
let cfg = ScriptEngineConfig::default();
let tmp = tempfile::tempdir().expect("tmp");
let sandbox = Arc::new(PathSandbox::new(tmp.path()).expect("sandbox"));
{
let slot = table.acquire("a");
let mut s = slot.lock().await;
let vars = s.vars();
let ctx = ctx_for(vars, sandbox.clone(), None);
let r = s
.run(
cfg.clone(),
"globalThis.tag = 'A'; vars.set('owner', 'a'); return true;",
&[],
RunOptions::default(),
ctx,
None,
)
.await;
assert_eq!(ok_value(r, "a-build"), serde_json::json!(true));
}
assert_eq!(table.live_vm_count(), 1, "one warm VM after building 'a'");
{
let slot = table.acquire("b");
let mut s = slot.lock().await;
let vars = s.vars();
let ctx = ctx_for(vars, sandbox.clone(), None);
let r = s
.run(
cfg.clone(),
"globalThis.tag = 'B'; return true;",
&[],
RunOptions::default(),
ctx,
None,
)
.await;
assert_eq!(ok_value(r, "b-build"), serde_json::json!(true));
}
assert!(table.live_vm_count() <= 1, "cap holds the warm-VM count at <= 1");
{
let slot = table.acquire("a");
let mut s = slot.lock().await;
let vars = s.vars();
let ctx = ctx_for(vars, sandbox.clone(), None);
let r = s
.run(
cfg.clone(),
"return { vmGone: globalThis.tag === undefined, owner: vars.get('owner') };",
&[],
RunOptions::default(),
ctx,
None,
)
.await;
let v = ok_value(r, "a-reacquire");
assert_eq!(v["vmGone"], serde_json::json!(true), "evicted VM rebuilt fresh");
assert_eq!(v["owner"], serde_json::json!("a"), "vars survived cap eviction");
}
tokio::time::sleep(Duration::from_millis(250)).await;
let slot = table.acquire("c"); {
let mut s = slot.lock().await;
let vars = s.vars();
let ctx = ctx_for(vars, sandbox.clone(), None);
let _ = s
.run(cfg.clone(), "return 1;", &[], RunOptions::default(), ctx, None)
.await;
}
{
let slot = table.acquire("a");
let mut s = slot.lock().await;
let vars = s.vars();
let ctx = ctx_for(vars, sandbox.clone(), None);
let r = s
.run(
cfg.clone(),
"return vars.get('owner');",
&[],
RunOptions::default(),
ctx,
None,
)
.await;
let v = ok_value(r, "a-after-reap");
assert!(
v.is_null() || v == serde_json::Value::Null,
"idle-reaped session starts fresh (no leaked durable vars), got {v}"
);
}
}