#![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, ScriptResult,
SessionTable, compile_and_extract_plugins,
};
async fn binding(src: &str) -> (tempfile::TempDir, PluginBinding) {
let tmp = tempfile::tempdir().expect("tempdir");
let path = tmp.path().join("ext.ts");
std::fs::write(&path, src).expect("write");
let (compiled, failures) = compile_and_extract_plugins(&[path]).await;
assert!(failures.is_empty(), "compile failures: {failures:?}");
(
tmp,
PluginBinding {
bytecode: compiled.into_iter().next().expect("one").bytecode,
},
)
}
fn ctx(sandbox_tmp: &std::path::Path, b: PluginBinding) -> RunContext {
RunContext {
vars: Arc::new(InMemoryVars::new()),
sandbox: Arc::new(PathSandbox::new(sandbox_tmp).expect("sandbox")),
artifacts: None,
page: None,
browser_context: None,
request: None,
browser: None,
plugins: vec![b],
trusted_modules: false,
host: ferridriver_script::ExtensionHost::Mcp,
caps: ferridriver_script::ScriptCaps::default(),
}
}
async fn run_with(plugin: &str, js: &str) -> ScriptResult {
let (_p, b) = binding(plugin).await;
let sb = tempfile::tempdir().expect("tempdir");
let context = ctx(sb.path(), b);
let table = SessionTable::new(8, None);
let slot = table.acquire("s");
let mut bs = slot.lock().await;
bs.run(
ScriptEngineConfig::default(),
js,
&[],
RunOptions::default(),
context,
None,
)
.await
}
#[track_caller]
fn ok(r: &ScriptResult) -> &serde_json::Value {
match &r.outcome {
Outcome::Ok { success } => &success.value,
Outcome::Error { error } => panic!("expected ok, got error: {error:?}"),
}
}
#[track_caller]
fn err_msg(r: &ScriptResult) -> String {
match &r.outcome {
Outcome::Error { error } => error.message.clone(),
Outcome::Ok { success } => panic!("expected error, got ok: {success:?}"),
}
}
#[tokio::test(flavor = "multi_thread")]
async fn argv_form_runs_without_a_shell_so_metachars_are_inert() {
let plugin = r#"
defineTool({ name: 't', allow: { commands: { e: { run: ["echo", "${m}"] } } },
handler: async ({ args, commands }) => commands.run('e', { m: args.m }) });
"#;
let r = run_with(
plugin,
"return await plugins['t']({ m: '$(touch /tmp/ferri_pwned); a && b' });",
)
.await;
assert_eq!(ok(&r), &serde_json::json!("$(touch /tmp/ferri_pwned); a && b"));
}
#[tokio::test(flavor = "multi_thread")]
async fn output_modes_text_json_lines() {
let plugin = r#"
defineTool({ name: 'j', allow: { commands: { c: { run: "printf '{\"a\":1}'", output: "json" } } },
handler: async ({ commands }) => commands.run('c') });
defineTool({ name: 'l', allow: { commands: { c: { run: "printf 'a\nb\n\nc\n'", output: "lines" } } },
handler: async ({ commands }) => commands.run('c') });
defineTool({ name: 'x', allow: { commands: { c: "echo hi" } },
handler: async ({ commands }) => commands.run('c') });
"#;
let j = run_with(plugin, "return await plugins['j']();").await;
assert_eq!(ok(&j), &serde_json::json!({ "a": 1 }));
let l = run_with(plugin, "return await plugins['l']();").await;
assert_eq!(ok(&l), &serde_json::json!(["a", "b", "c"]));
let x = run_with(plugin, "return await plugins['x']();").await;
assert_eq!(ok(&x), &serde_json::json!("hi"));
}
#[tokio::test(flavor = "multi_thread")]
async fn strict_unknown_placeholder_errors() {
let plugin = r#"
defineTool({ name: 't', allow: { commands: { c: "echo ${name}" } },
handler: async ({ commands }) => commands.run('c', {}) });
"#;
let r = run_with(plugin, "return await plugins['t']();").await;
assert!(err_msg(&r).contains("${name}"), "{}", err_msg(&r));
}
#[tokio::test(flavor = "multi_thread")]
async fn undeclared_command_is_denied() {
let plugin = r#"
defineTool({ name: 't', allow: { commands: { allowed: "echo ok" } },
handler: async ({ commands }) => commands.run('other') });
"#;
let r = run_with(plugin, "return await plugins['t']();").await;
assert!(
err_msg(&r).contains("not in the commands allow-list"),
"{}",
err_msg(&r)
);
}
#[tokio::test(flavor = "multi_thread")]
async fn timeout_kills_a_slow_command() {
let plugin = r#"
defineTool({ name: 't', allow: { commands: { slow: { run: "sleep 5", timeoutMs: 150 } } },
handler: async ({ commands }) => commands.run('slow') });
"#;
let started = std::time::Instant::now();
let r = run_with(plugin, "return await plugins['t']();").await;
assert!(err_msg(&r).contains("timed out after 150ms"), "{}", err_msg(&r));
assert!(
started.elapsed() < Duration::from_secs(2),
"should not have waited the full 5s"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn run_rejects_a_persistent_spec_and_vice_versa() {
let plugin = r#"
defineTool({ name: 'p', allow: { commands: { srv: { run: "sleep 1", persistent: true } } },
handler: async ({ commands }) => commands.run('srv') });
defineTool({ name: 'o', allow: { commands: { one: "echo hi" } },
handler: async ({ commands }) => commands.start('one') });
"#;
let r = run_with(plugin, "return await plugins['p']();").await;
assert!(err_msg(&r).contains("persistent"), "{}", err_msg(&r));
let r = run_with(plugin, "return await plugins['o']();").await;
assert!(err_msg(&r).contains("not declared `persistent`"), "{}", err_msg(&r));
}
#[tokio::test(flavor = "multi_thread")]
async fn persistent_start_status_stop_lifecycle() {
let plugin = r#"
const SPEC = { run: "echo up; sleep 30", persistent: true };
defineTool({ name: 'srv', allow: { commands: { s: SPEC } }, handler: async ({ args, commands }) => {
if (args.op === 'start') return await commands.start('s');
if (args.op === 'status') return await commands.status('s');
if (args.op === 'stop') { await commands.stop('s'); return 'stopped'; }
}});
"#;
let (_p, b) = binding(plugin).await;
let sb = tempfile::tempdir().expect("tempdir");
let context = ctx(sb.path(), b);
let table = SessionTable::new(8, None);
let slot = table.acquire("s");
{
let mut bs = slot.lock().await;
let r = bs
.run(
ScriptEngineConfig::default(),
"return await plugins['srv']({ op: 'start' });",
&[],
RunOptions::default(),
context.clone(),
None,
)
.await;
let v = ok(&r);
assert!(v["pid"].as_i64().unwrap_or(0) > 0, "got pid: {v}");
}
tokio::time::sleep(Duration::from_millis(250)).await;
{
let mut bs = slot.lock().await;
let r = bs
.run(
ScriptEngineConfig::default(),
"return await plugins['srv']({ op: 'status' });",
&[],
RunOptions::default(),
context.clone(),
None,
)
.await;
let v = ok(&r);
assert_eq!(v["running"], serde_json::json!(true), "status: {v}");
assert!(
v["stdout"].as_str().unwrap_or("").contains("up"),
"captured stdout: {v}"
);
}
{
let mut bs = slot.lock().await;
let _ = bs
.run(
ScriptEngineConfig::default(),
"return await plugins['srv']({ op: 'stop' });",
&[],
RunOptions::default(),
context.clone(),
None,
)
.await;
tokio::time::sleep(Duration::from_millis(150)).await;
let r = bs
.run(
ScriptEngineConfig::default(),
"return await plugins['srv']({ op: 'status' });",
&[],
RunOptions::default(),
context.clone(),
None,
)
.await;
assert!(
err_msg(&r).contains("no persistent process"),
"post-stop status: {}",
err_msg(&r)
);
}
}