#![allow(clippy::expect_used, clippy::unwrap_used)]
use std::sync::Arc;
use std::time::Duration;
use ferridriver_script::{
InMemoryVars, Outcome, PathSandbox, PluginBinding, RunContext, RunOptions, ScriptEngineConfig, ScriptErrorKind,
Session, compile_and_extract_plugins,
};
const DEMO_PLUGIN: &str = "defineTool({ name: 'demo', handler: async ({ args }) => { \
globalThis.__n = (globalThis.__n || 0) + 1; return { n: globalThis.__n, got: args }; } });";
async fn demo_binding() -> (tempfile::TempDir, PluginBinding) {
let tmp = tempfile::tempdir().expect("tempdir");
let path = tmp.path().join("demo.js");
std::fs::write(&path, DEMO_PLUGIN).expect("write plugin");
let (compiled, failures) = compile_and_extract_plugins(&[path]).await;
assert!(failures.is_empty(), "compile failures: {failures:?}");
let cp = compiled.into_iter().next().expect("one compiled plugin");
assert!(!cp.bytecode.is_empty(), "compiled bytecode must be non-empty");
(tmp, PluginBinding { bytecode: cp.bytecode })
}
async fn run_demo_plugin_twice() {
let (_plugin_tmp, binding) = demo_binding().await;
let tmp = tempfile::tempdir().expect("tempdir");
let sandbox = PathSandbox::new(tmp.path()).expect("sandbox");
let ctx = RunContext {
vars: Arc::new(InMemoryVars::new()),
sandbox: Arc::new(sandbox),
artifacts: None,
page: None,
browser_context: None,
request: None,
browser: None,
plugins: vec![binding],
trusted_modules: false,
host: ferridriver_script::ExtensionHost::Script,
caps: ferridriver_script::ScriptCaps::default(),
};
let session = Session::create(ScriptEngineConfig::default(), &ctx)
.await
.expect("session create");
let r1 = session
.execute(
"return await plugins['demo']({ x: 1 });",
&[],
RunOptions::default(),
&ctx,
)
.await;
match r1.result.outcome {
Outcome::Ok { success } => assert_eq!(success.value, serde_json::json!({ "n": 1, "got": { "x": 1 } })),
Outcome::Error { error } => panic!("plugin call 1 failed: {error:?}"),
}
let r2 = session
.execute(
"return await plugins['demo']({ x: 2 });",
&[],
RunOptions::default(),
&ctx,
)
.await;
match r2.result.outcome {
Outcome::Ok { success } => assert_eq!(success.value, serde_json::json!({ "n": 2, "got": { "x": 2 } })),
Outcome::Error { error } => panic!("plugin call 2 failed: {error:?}"),
}
}
#[tokio::test(flavor = "multi_thread")]
async fn typescript_plugin_with_local_import_bundles_and_runs() {
let tmp = tempfile::tempdir().expect("tempdir");
std::fs::write(
tmp.path().join("helper.ts"),
"export const tag = (n: number): string => `t${n}`;\n",
)
.expect("write helper");
std::fs::write(
tmp.path().join("plug.ts"),
"import { tag } from './helper';\n\
interface In { n: number }\n\
defineTool({ name: 'ts', exposeAsTool: true, \
async handler({ args }: { args: In }) { return { tag: tag(args.n) }; } });\n",
)
.expect("write plugin");
let (compiled, failures) = compile_and_extract_plugins(&[tmp.path().join("plug.ts")]).await;
assert!(failures.is_empty(), "compile failures: {failures:?}");
let cp = compiled.into_iter().next().expect("one compiled plugin");
let sb_tmp = tempfile::tempdir().expect("tempdir");
let ctx = RunContext {
vars: Arc::new(InMemoryVars::new()),
sandbox: Arc::new(PathSandbox::new(sb_tmp.path()).expect("sandbox")),
artifacts: None,
page: None,
browser_context: None,
request: None,
browser: None,
plugins: vec![PluginBinding { bytecode: cp.bytecode }],
trusted_modules: false,
host: ferridriver_script::ExtensionHost::Script,
caps: ferridriver_script::ScriptCaps::default(),
};
let session = Session::create(ScriptEngineConfig::default(), &ctx)
.await
.expect("session create");
let r = session
.execute(
"return await plugins['ts']({ n: 7 });",
&[],
RunOptions::default(),
&ctx,
)
.await;
match r.result.outcome {
Outcome::Ok { success } => assert_eq!(success.value, serde_json::json!({ "tag": "t7" })),
Outcome::Error { error } => panic!("ts plugin failed: {error:?}"),
}
}
#[tokio::test(flavor = "multi_thread")]
async fn allow_net_capability_is_enforced_on_the_request_binding() {
const NET_PLUGIN: &str = "defineTool({ name: 'net', \
allow: { net: ['127.0.0.1'] }, \
handler: async ({ args, request }) => { await request.get(args.url); return 'ok'; } });";
let tmp = tempfile::tempdir().expect("tempdir");
let path = tmp.path().join("net.js");
std::fs::write(&path, NET_PLUGIN).expect("write plugin");
let (compiled, failures) = compile_and_extract_plugins(&[path]).await;
assert!(failures.is_empty(), "compile failures: {failures:?}");
let cp = compiled.into_iter().next().expect("one compiled plugin");
let sb_tmp = tempfile::tempdir().expect("tempdir");
let request = Arc::new(ferridriver::http_client::HttpClient::new(
ferridriver::http_client::HttpClientOptions::default(),
));
let ctx = RunContext {
vars: Arc::new(InMemoryVars::new()),
sandbox: Arc::new(PathSandbox::new(sb_tmp.path()).expect("sandbox")),
artifacts: None,
page: None,
browser_context: None,
request: Some(request),
browser: None,
plugins: vec![PluginBinding { bytecode: cp.bytecode }],
trusted_modules: false,
host: ferridriver_script::ExtensionHost::Script,
caps: ferridriver_script::ScriptCaps::default(),
};
let session = Session::create(ScriptEngineConfig::default(), &ctx)
.await
.expect("session create");
let blocked = session
.execute(
"return await plugins['net']({ url: 'http://blocked.test/' });",
&[],
RunOptions::default(),
&ctx,
)
.await;
match blocked.result.outcome {
Outcome::Error { error } => {
assert!(
error.message.contains("not in allow.net") && error.message.contains("blocked.test"),
"expected an allow.net denial naming the host, got: {}",
error.message
);
},
Outcome::Ok { .. } => panic!("disallowed host must be rejected by the net capability"),
}
let allowed = session
.execute(
"return await plugins['net']({ url: 'http://127.0.0.1:1/' });",
&[],
RunOptions::default(),
&ctx,
)
.await;
match allowed.result.outcome {
Outcome::Error { error } => assert!(
!error.message.contains("allow.net"),
"allowed host must pass the guard; got an allow.net error instead: {}",
error.message
),
Outcome::Ok { .. } => {},
}
}
#[tokio::test(flavor = "multi_thread")]
async fn allow_net_capability_is_enforced_on_the_global_fetch() {
const NET_PLUGIN: &str = "defineTool({ name: 'netf', \
allow: { net: ['127.0.0.1'] }, \
handler: async ({ args }) => { const r = await fetch(args.url); return r.status; } });";
let tmp = tempfile::tempdir().expect("tempdir");
let path = tmp.path().join("netf.js");
std::fs::write(&path, NET_PLUGIN).expect("write plugin");
let (compiled, failures) = compile_and_extract_plugins(&[path]).await;
assert!(failures.is_empty(), "compile failures: {failures:?}");
let cp = compiled.into_iter().next().expect("one compiled plugin");
let sb_tmp = tempfile::tempdir().expect("tempdir");
let request = Arc::new(ferridriver::http_client::HttpClient::new(
ferridriver::http_client::HttpClientOptions::default(),
));
let ctx = RunContext {
vars: Arc::new(InMemoryVars::new()),
sandbox: Arc::new(PathSandbox::new(sb_tmp.path()).expect("sandbox")),
artifacts: None,
page: None,
browser_context: None,
request: Some(request),
browser: None,
plugins: vec![PluginBinding { bytecode: cp.bytecode }],
trusted_modules: false,
host: ferridriver_script::ExtensionHost::Script,
caps: ferridriver_script::ScriptCaps::default(),
};
let session = Session::create(ScriptEngineConfig::default(), &ctx)
.await
.expect("session create");
let blocked = session
.execute(
"return await plugins['netf']({ url: 'http://blocked.test/' });",
&[],
RunOptions::default(),
&ctx,
)
.await;
match blocked.result.outcome {
Outcome::Error { error } => assert!(
error.message.contains("not in allow.net") && error.message.contains("blocked.test"),
"fetch to a disallowed host must be rejected by allow.net, got: {}",
error.message
),
Outcome::Ok { .. } => panic!("disallowed fetch host must be rejected by the net capability"),
}
let allowed = session
.execute(
"return await plugins['netf']({ url: 'http://127.0.0.1:1/' });",
&[],
RunOptions::default(),
&ctx,
)
.await;
match allowed.result.outcome {
Outcome::Error { error } => assert!(
!error.message.contains("allow.net"),
"allowed fetch host must pass the guard; got an allow.net error: {}",
error.message
),
Outcome::Ok { .. } => {},
}
}
#[tokio::test(flavor = "multi_thread")]
async fn fetch_net_policy_does_not_leak_between_concurrent_tools() {
const PLUGIN: &str = "defineTool({ name: 'restricted', allow: { net: ['127.0.0.1'] }, \
handler: async ({ args }) => { try { await fetch(args.url); return 'reached'; } \
catch (e) { return 'denied:' + String(e.message || e); } } }); \
defineTool({ name: 'open', \
handler: async ({ args }) => { try { await fetch(args.url); return 'reached'; } \
catch (e) { return 'err:' + String(e.message || e); } } });";
let tmp = tempfile::tempdir().expect("tempdir");
let path = tmp.path().join("leak.js");
std::fs::write(&path, PLUGIN).expect("write plugin");
let (compiled, failures) = compile_and_extract_plugins(&[path]).await;
assert!(failures.is_empty(), "compile failures: {failures:?}");
let cp = compiled.into_iter().next().expect("one compiled plugin");
let sb_tmp = tempfile::tempdir().expect("tempdir");
let ctx = RunContext {
vars: Arc::new(InMemoryVars::new()),
sandbox: Arc::new(PathSandbox::new(sb_tmp.path()).expect("sandbox")),
artifacts: None,
page: None,
browser_context: None,
request: Some(Arc::new(ferridriver::http_client::HttpClient::new(
ferridriver::http_client::HttpClientOptions::default(),
))),
browser: None,
plugins: vec![PluginBinding { bytecode: cp.bytecode }],
trusted_modules: false,
host: ferridriver_script::ExtensionHost::Script,
caps: ferridriver_script::ScriptCaps::default(),
};
let session = Session::create(ScriptEngineConfig::default(), &ctx)
.await
.expect("session create");
let r = session
.execute(
"const [a, b] = await Promise.all([ \
plugins['restricted']({ url: 'http://blocked.test/' }), \
plugins['open']({ url: 'http://127.0.0.1:1/' }) ]); \
return { restricted: a, open: b };",
&[],
RunOptions::default(),
&ctx,
)
.await;
match r.result.outcome {
Outcome::Ok { success } => {
let restricted = success.value["restricted"].as_str().unwrap_or_default();
let open = success.value["open"].as_str().unwrap_or_default();
assert!(
restricted.contains("denied:") && restricted.contains("not in allow.net"),
"restricted tool's fetch must be denied by allow.net even under concurrency, got: {restricted}"
);
assert!(
!open.contains("not in allow.net"),
"the unrestricted tool's fetch must not inherit another tool's allow.net, got: {open}"
);
},
Outcome::Error { error } => panic!("concurrent tool run failed: {error:?}"),
}
}
#[tokio::test(flavor = "multi_thread")]
async fn extension_branches_on_ferridriver_host_flag() {
const EXT: &str = "if (ferridriver.host === 'mcp') { \
defineTool({ name: 'mcpOnly', handler: async () => 'tool-ran' }); \
} \
if (ferridriver.host === 'bdd') { Given('a step', () => {}); }";
let tmp = tempfile::tempdir().expect("tempdir");
let path = tmp.path().join("ext.js");
std::fs::write(&path, EXT).expect("write ext");
let (compiled, failures) = compile_and_extract_plugins(&[path]).await;
assert!(failures.is_empty(), "compile failures: {failures:?}");
let cp = compiled.into_iter().next().expect("one compiled");
let mk = |host| {
let sb = tempfile::tempdir().expect("tempdir");
let ctx = RunContext {
vars: Arc::new(InMemoryVars::new()),
sandbox: Arc::new(PathSandbox::new(sb.path()).expect("sandbox")),
artifacts: None,
page: None,
browser_context: None,
request: None,
browser: None,
plugins: vec![PluginBinding {
bytecode: cp.bytecode.clone(),
}],
trusted_modules: false,
host,
caps: ferridriver_script::ScriptCaps::default(),
};
(sb, ctx)
};
let (_sb1, ctx) = mk(ferridriver_script::ExtensionHost::Mcp);
let session = Session::create(ScriptEngineConfig::default(), &ctx)
.await
.expect("session create");
let r = session
.execute("return await plugins['mcpOnly']({});", &[], RunOptions::default(), &ctx)
.await;
match r.result.outcome {
Outcome::Ok { success } => assert_eq!(success.value, serde_json::json!("tool-ran")),
Outcome::Error { error } => panic!("mcp host should expose the tool: {error:?}"),
}
let (_sb2, ctx) = mk(ferridriver_script::ExtensionHost::Bdd);
let session = Session::create(ScriptEngineConfig::default(), &ctx)
.await
.expect("session create");
let r = session
.execute("return typeof plugins['mcpOnly'];", &[], RunOptions::default(), &ctx)
.await;
match r.result.outcome {
Outcome::Ok { success } => assert_eq!(success.value, serde_json::json!("undefined")),
Outcome::Error { error } => panic!("bdd host lookup should be undefined, not error: {error:?}"),
}
}
#[tokio::test(flavor = "multi_thread")]
async fn plugin_bytecode_path_installs_and_persists() {
run_demo_plugin_twice().await;
}
fn make_ctx() -> (tempfile::TempDir, RunContext) {
let tmp = tempfile::tempdir().expect("tempdir");
let sandbox = PathSandbox::new(tmp.path()).expect("sandbox");
let ctx = RunContext {
vars: Arc::new(InMemoryVars::new()),
sandbox: Arc::new(sandbox),
artifacts: None,
page: None,
browser_context: None,
request: None,
browser: None,
plugins: Vec::new(),
trusted_modules: false,
host: ferridriver_script::ExtensionHost::Script,
caps: ferridriver_script::ScriptCaps::default(),
};
(tmp, ctx)
}
#[tokio::test(flavor = "multi_thread")]
async fn globals_persist_across_executions() {
let (_tmp, ctx) = make_ctx();
let session = Session::create(ScriptEngineConfig::default(), &ctx)
.await
.expect("session create");
let r1 = session
.execute(
"globalThis.h = () => 42; return null;",
&[],
RunOptions::default(),
&ctx,
)
.await;
assert!(r1.result.is_ok(), "{:?}", r1.result);
assert!(!r1.poisoned);
let r2 = session.execute("return h();", &[], RunOptions::default(), &ctx).await;
match r2.result.outcome {
Outcome::Ok { success } => assert_eq!(success.value, serde_json::json!(42)),
Outcome::Error { error } => panic!("expected 42, got error: {error:?}"),
}
assert!(!r2.poisoned);
}
#[tokio::test(flavor = "multi_thread")]
async fn let_const_inside_call_do_not_persist() {
let (_tmp, ctx) = make_ctx();
let session = Session::create(ScriptEngineConfig::default(), &ctx)
.await
.expect("session create");
let r1 = session
.execute("let x = 5; return x;", &[], RunOptions::default(), &ctx)
.await;
assert!(r1.result.is_ok(), "{:?}", r1.result);
let r2 = session
.execute("return typeof x;", &[], RunOptions::default(), &ctx)
.await;
match r2.result.outcome {
Outcome::Ok { success } => assert_eq!(success.value, serde_json::json!("undefined")),
Outcome::Error { error } => panic!("expected undefined, got error: {error:?}"),
}
}
#[tokio::test(flavor = "multi_thread")]
async fn plain_throw_does_not_poison_and_state_survives() {
let (_tmp, ctx) = make_ctx();
let session = Session::create(ScriptEngineConfig::default(), &ctx)
.await
.expect("session create");
session
.execute("globalThis.keep = 'alive'; return 1;", &[], RunOptions::default(), &ctx)
.await;
let thrown = session
.execute("throw new Error('boom');", &[], RunOptions::default(), &ctx)
.await;
match thrown.result.outcome {
Outcome::Error { error } => {
assert_eq!(error.kind, ScriptErrorKind::Runtime);
assert!(error.message.contains("boom"), "got: {}", error.message);
},
Outcome::Ok { .. } => panic!("expected the throw to surface as an error"),
}
assert!(!thrown.poisoned, "plain throw must not poison the session");
let after = session
.execute("return globalThis.keep;", &[], RunOptions::default(), &ctx)
.await;
match after.result.outcome {
Outcome::Ok { success } => assert_eq!(success.value, serde_json::json!("alive")),
Outcome::Error { error } => panic!("state lost after throw: {error:?}"),
}
}
#[tokio::test(flavor = "multi_thread")]
async fn timeout_poisons_the_session() {
let (_tmp, ctx) = make_ctx();
let session = Session::create(ScriptEngineConfig::default(), &ctx)
.await
.expect("session create");
let timed = session
.execute(
"while (true) { /* spin */ }",
&[],
RunOptions {
timeout: Some(Duration::from_millis(150)),
..RunOptions::default()
},
&ctx,
)
.await;
match timed.result.outcome {
Outcome::Error { error } => assert_eq!(error.kind, ScriptErrorKind::Timeout),
Outcome::Ok { .. } => panic!("expected timeout"),
}
assert!(timed.poisoned, "a timeout must poison the session");
}
#[tokio::test(flavor = "multi_thread")]
async fn framework_globals_refresh_each_call() {
let (_tmp, ctx) = make_ctx();
let session = Session::create(ScriptEngineConfig::default(), &ctx)
.await
.expect("session create");
session
.execute("vars.set('k', 'v'); return null;", &[], RunOptions::default(), &ctx)
.await;
let r = session
.execute("return vars.get('k');", &[], RunOptions::default(), &ctx)
.await;
match r.result.outcome {
Outcome::Ok { success } => assert_eq!(success.value, serde_json::json!("v")),
Outcome::Error { error } => panic!("expected 'v', got error: {error:?}"),
}
}
#[tokio::test(flavor = "multi_thread")]
async fn set_timeout_resolves_inside_execute() {
let (_tmp, ctx) = make_ctx();
let session = Session::create(ScriptEngineConfig::default(), &ctx)
.await
.expect("session create");
let r = session
.execute(
"return await new Promise((resolve) => setTimeout(() => resolve(7), 20));",
&[],
RunOptions::default(),
&ctx,
)
.await;
match r.result.outcome {
Outcome::Ok { success } => assert_eq!(success.value, serde_json::json!(7)),
Outcome::Error { error } => panic!("setTimeout did not resolve: {error:?}"),
}
assert!(!r.poisoned);
}
#[tokio::test(flavor = "multi_thread")]
async fn timer_handle_persists_and_clears_across_calls() {
let (_tmp, ctx) = make_ctx();
let session = Session::create(ScriptEngineConfig::default(), &ctx)
.await
.expect("session create");
let r1 = session
.execute(
"globalThis.__t = setTimeout(() => { globalThis.__fired = true; }, 10000); \
return typeof globalThis.__t;",
&[],
RunOptions::default(),
&ctx,
)
.await;
assert!(r1.result.is_ok(), "{:?}", r1.result);
let r2 = session
.execute(
"clearTimeout(globalThis.__t); return globalThis.__fired === true;",
&[],
RunOptions::default(),
&ctx,
)
.await;
match r2.result.outcome {
Outcome::Ok { success } => assert_eq!(success.value, serde_json::json!(false), "timer must not have fired"),
Outcome::Error { error } => panic!("clearTimeout across calls failed: {error:?}"),
}
}
#[tokio::test(flavor = "multi_thread")]
async fn url_and_search_params_work() {
let (_tmp, ctx) = make_ctx();
let session = Session::create(ScriptEngineConfig::default(), &ctx)
.await
.expect("session create");
let r = session
.execute(
"const p = new URLSearchParams('a=1&b=2'); p.append('b', '3'); \
return [p.get('a'), p.getAll('b').join(',')];",
&[],
RunOptions::default(),
&ctx,
)
.await;
match r.result.outcome {
Outcome::Ok { success } => assert_eq!(success.value, serde_json::json!(["1", "2,3"])),
Outcome::Error { error } => panic!("URLSearchParams failed: {error:?}"),
}
}
#[tokio::test(flavor = "multi_thread")]
async fn web_polyfills_text_codec_base64_microtask() {
let (_tmp, ctx) = make_ctx();
let session = Session::create(ScriptEngineConfig::default(), &ctx)
.await
.expect("session create");
let r = session
.execute(
"const enc = new TextEncoder().encode('hi€'); \
const dec = new TextDecoder().decode(enc); \
let mt = 0; queueMicrotask(() => { mt = 1; }); \
await Promise.resolve(); \
return { len: enc.length, dec, b64: btoa('hi'), round: atob(btoa('xy')), mt };",
&[],
RunOptions::default(),
&ctx,
)
.await;
match r.result.outcome {
Outcome::Ok { success } => {
assert_eq!(
success.value["len"],
serde_json::json!(5),
"hi€ = 5 UTF-8 bytes: {success:?}"
);
assert_eq!(success.value["dec"], serde_json::json!("hi€"));
assert_eq!(success.value["b64"], serde_json::json!("aGk="));
assert_eq!(success.value["round"], serde_json::json!("xy"));
assert_eq!(
success.value["mt"],
serde_json::json!(1),
"queueMicrotask must have run"
);
},
Outcome::Error { error } => panic!("web polyfills failed: {error:?}"),
}
}
#[tokio::test(flavor = "multi_thread")]
async fn console_uses_node_style_formatter_and_is_captured() {
let (_tmp, ctx) = make_ctx();
let session = Session::create(ScriptEngineConfig::default(), &ctx)
.await
.expect("session create");
let r = session
.execute(
"console.log('n =', 42, { a: 1 }); console.warn(['x', 'y']); return null;",
&[],
RunOptions::default(),
&ctx,
)
.await;
assert!(r.result.is_ok(), "{:?}", r.result);
let console = &r.result.console;
assert_eq!(console.len(), 2, "two console entries: {console:?}");
let line0 = &console[0].message;
assert!(line0.starts_with("n = 42 "), "got: {line0}");
assert!(line0.contains("a: 1"), "object Node-style, got: {line0}");
assert!(
console[1].message.contains("[ x, y ]"),
"array rendered structurally, got: {}",
console[1].message
);
}
#[tokio::test(flavor = "multi_thread")]
async fn native_url_class_parses_and_exposes_search_params() {
let (_tmp, ctx) = make_ctx();
let session = Session::create(ScriptEngineConfig::default(), &ctx)
.await
.expect("session create");
let r = session
.execute(
"const u = new URL('https://ex.com:8443/a/b?x=1&y=2#frag'); \
return { href: u.href, host: u.host, hostname: u.hostname, port: u.port, \
proto: u.protocol, path: u.pathname, search: u.search, hash: u.hash, \
origin: u.origin, sp: u.searchParams.get('y'), str: String(u) };",
&[],
RunOptions::default(),
&ctx,
)
.await;
match r.result.outcome {
Outcome::Ok { success } => {
let v = &success.value;
assert_eq!(v["host"], serde_json::json!("ex.com:8443"), "{v}");
assert_eq!(v["hostname"], serde_json::json!("ex.com"));
assert_eq!(v["port"], serde_json::json!("8443"));
assert_eq!(v["proto"], serde_json::json!("https:"));
assert_eq!(v["path"], serde_json::json!("/a/b"));
assert_eq!(v["search"], serde_json::json!("?x=1&y=2"));
assert_eq!(v["hash"], serde_json::json!("#frag"));
assert_eq!(v["origin"], serde_json::json!("https://ex.com:8443"));
assert_eq!(v["sp"], serde_json::json!("2"), "searchParams via native URL: {v}");
assert_eq!(v["str"], serde_json::json!("https://ex.com:8443/a/b?x=1&y=2#frag"));
},
Outcome::Error { error } => panic!("native URL failed: {error:?}"),
}
}
async fn binding_from(src: &str) -> (tempfile::TempDir, Result<PluginBinding, String>) {
let tmp = tempfile::tempdir().expect("tempdir");
let path = tmp.path().join("ext.ts");
std::fs::write(&path, src).expect("write plugin");
let (compiled, failures) = compile_and_extract_plugins(&[path]).await;
if let Some((_, e)) = failures.into_iter().next() {
return (tmp, Err(e.message));
}
let cp = compiled.into_iter().next().expect("one compiled plugin");
(tmp, Ok(PluginBinding { bytecode: cp.bytecode }))
}
#[tokio::test(flavor = "multi_thread")]
async fn duplicate_tool_name_is_rejected_at_load() {
let (_tmp, res) = binding_from(
"defineTool({ name: 'dup', handler: async () => 1 });\n\
defineTool({ name: 'dup', handler: async () => 2 });\n",
)
.await;
let err = res.expect_err("duplicate tool name must fail compilation");
assert!(err.contains("duplicate tool name `dup`"), "unexpected error: {err}");
let (_tmp2, res2) = binding_from("defineTool({ name: ' ', handler: async () => 1 });\n").await;
let err2 = res2.expect_err("empty tool name must fail compilation");
assert!(err2.contains("non-empty string"), "unexpected error: {err2}");
}
#[tokio::test(flavor = "multi_thread")]
async fn per_tool_timeout_ms_is_enforced_for_every_caller() {
let (_tmp, binding) = binding_from(
"defineTool({ name: 'slow', timeoutMs: 50, handler: async () => { \
await new Promise(r => setTimeout(r, 400)); return 'late'; } });\n\
defineTool({ name: 'fast', timeoutMs: 5000, handler: async () => 'quick' });\n",
)
.await;
let binding = binding.expect("compiles");
let tmp = tempfile::tempdir().expect("tempdir");
let sandbox = PathSandbox::new(tmp.path()).expect("sandbox");
let ctx = RunContext {
vars: Arc::new(InMemoryVars::new()),
sandbox: Arc::new(sandbox),
artifacts: None,
page: None,
browser_context: None,
request: None,
browser: None,
plugins: vec![binding],
trusted_modules: false,
host: ferridriver_script::ExtensionHost::Script,
caps: ferridriver_script::ScriptCaps::default(),
};
let session = Session::create(ScriptEngineConfig::default(), &ctx)
.await
.expect("session create");
let slow = session
.execute(
"try { await plugins['slow'](); return 'resolved'; } catch (e) { return String(e); }",
&[],
RunOptions::default(),
&ctx,
)
.await;
match slow.result.outcome {
Outcome::Ok { success } => {
let s = success.value.as_str().unwrap_or_default();
assert!(
s.contains("timed out after 50ms"),
"slow tool should have timed out, got: {s}"
);
},
Outcome::Error { error } => panic!("expected caught rejection, not engine error: {error:?}"),
}
let fast = session
.execute("return await plugins['fast']();", &[], RunOptions::default(), &ctx)
.await;
match fast.result.outcome {
Outcome::Ok { success } => assert_eq!(success.value, serde_json::json!("quick")),
Outcome::Error { error } => panic!("fast tool within its timeout must resolve: {error:?}"),
}
}