ferridriver-cli 0.4.0

ferridriver CLI -- MCP server for browser automation
#![allow(clippy::expect_used, clippy::unwrap_used)]
//! Smoke tests for the `ferridriver run` subcommand: a standalone
//! script runner where the script launches its own browser via the
//! Playwright-style `chromium()` / `firefox()` / `webkit()` factories.
//!
//! Requires a built `ferridriver` binary (`FERRIDRIVER_BIN` or
//! `target/{debug,release}/ferridriver`) plus Chrome + Firefox,
//! exactly like the `backends` suite.

use std::io::Write as _;
use std::process::{Command, Stdio};

fn bin() -> String {
  std::env::var("FERRIDRIVER_BIN").unwrap_or_else(|_| {
    let base = format!("{}/../../target", env!("CARGO_MANIFEST_DIR"));
    let debug = format!("{base}/debug/ferridriver");
    if std::path::Path::new(&debug).exists() {
      debug
    } else {
      format!("{base}/release/ferridriver")
    }
  })
}

/// Run `ferridriver run <extra…>` with `stdin` piped; returns
/// (success, stdout, stderr).
fn run(extra: &[&str], stdin: Option<&str>) -> (bool, String, String) {
  let mut cmd = Command::new(bin());
  cmd
    .arg("run")
    .args(extra)
    .stdin(Stdio::piped())
    .stdout(Stdio::piped())
    .stderr(Stdio::piped());
  let mut child = cmd.spawn().expect("spawn ferridriver run");
  if let Some(s) = stdin {
    child.stdin.take().unwrap().write_all(s.as_bytes()).unwrap();
  } else {
    drop(child.stdin.take());
  }
  let out = child.wait_with_output().expect("wait");
  (
    out.status.success(),
    String::from_utf8_lossy(&out.stdout).into_owned(),
    String::from_utf8_lossy(&out.stderr).into_owned(),
  )
}

#[test]
fn inline_eval_launches_browser_and_returns_value() {
  let (ok, stdout, stderr) = run(
    &[
      "-e",
      "const b = await chromium().launch({ headless: true }); \
       const p = await (await b.newContext()).newPage(); \
       await p.goto('data:text/html,<title>RunCmd</title>'); \
       const t = await p.title(); await b.close(); return t;",
    ],
    None,
  );
  assert!(ok, "exit ok; stderr={stderr}");
  let v: serde_json::Value = serde_json::from_str(&stdout).expect("json stdout");
  assert_eq!(v["status"], "ok", "{v}");
  assert_eq!(v["value"], "RunCmd", "script launched its own browser: {v}");
}

#[test]
fn file_mode_with_positional_args() {
  let dir = tempfile::tempdir().unwrap();
  let path = dir.path().join("s.js");
  std::fs::write(&path, "return { argc: args.length, first: args[0], sum: 1 + 2 };").unwrap();
  let (ok, stdout, _) = run(&[path.to_str().unwrap(), "--", "alpha", "beta"], None);
  assert!(ok);
  let v: serde_json::Value = serde_json::from_str(&stdout).unwrap();
  assert_eq!(v["value"]["argc"], 2);
  assert_eq!(v["value"]["first"], "alpha");
  assert_eq!(v["value"]["sum"], 3);
}

#[test]
fn stdin_dash_reads_source() {
  let (ok, stdout, _) = run(&["-"], Some("return 6 * 7;"));
  assert!(ok);
  let v: serde_json::Value = serde_json::from_str(&stdout).unwrap();
  assert_eq!(v["value"], 42);
}

#[test]
fn script_error_exits_nonzero() {
  let (ok, stdout, stderr) = run(&["-e", "throw new Error('boom-run')"], None);
  assert!(!ok, "a thrown error must exit nonzero");
  let v: serde_json::Value = serde_json::from_str(&stdout).unwrap();
  assert_eq!(v["status"], "error");
  assert!(stderr.contains("boom-run"), "stderr summary: {stderr}");
}

#[test]
fn factories_match_playwright_chromium_is_chromium_firefox_is_firefox() {
  // The Playwright contract: `chromium()` ALWAYS launches Chromium,
  // `firefox()` ALWAYS Firefox. No flag turns one into the other.
  let mk = |factory: &str| {
    format!(
      "const b = await {factory}().launch({{ headless: true }}); const v = await b.version(); await b.close(); return v;"
    )
  };

  let (ok, stdout, stderr) = run(&["-e", &mk("chromium")], None);
  assert!(ok, "chromium exit ok; stderr={stderr}");
  let v: serde_json::Value = serde_json::from_str(&stdout).unwrap();
  let got = v["value"].as_str().unwrap_or_default();
  assert!(
    got.starts_with("Chrome/") || got.starts_with("Chromium/") || got.starts_with("HeadlessChrome/"),
    "chromium() must launch Chromium, got version `{got}`"
  );

  let (ok, stdout, stderr) = run(&["-e", &mk("firefox")], None);
  assert!(ok, "firefox exit ok; stderr={stderr}");
  let v: serde_json::Value = serde_json::from_str(&stdout).unwrap();
  let got = v["value"].as_str().unwrap_or_default();
  assert!(
    got.to_ascii_lowercase().contains("firefox"),
    "firefox() must launch Firefox, got version `{got}`"
  );
}

// ── ES-module path: TypeScript + imports via the shared bundle infra ──

#[test]
fn ts_file_transpiles_and_returns_default_export() {
  let dir = tempfile::tempdir().unwrap();
  let path = dir.path().join("s.ts");
  // TypeScript syntax (type annotation) + `export default` result.
  std::fs::write(&path, "const n: number = 19 + 23;\nexport default n;").unwrap();
  let (ok, stdout, stderr) = run(&[path.to_str().unwrap()], None);
  assert!(ok, "exit ok; stderr={stderr}");
  let v: serde_json::Value = serde_json::from_str(&stdout).unwrap();
  assert_eq!(v["status"], "ok", "{v}");
  assert_eq!(v["value"], 42, "default export is the run result: {v}");
}

#[test]
fn ts_module_with_relative_import_is_bundled() {
  let dir = tempfile::tempdir().unwrap();
  std::fs::write(
    dir.path().join("helper.ts"),
    "export const triple = (n: number): number => n * 3;",
  )
  .unwrap();
  let entry = dir.path().join("main.ts");
  std::fs::write(&entry, "import { triple } from './helper';\nexport default triple(14);").unwrap();
  let (ok, stdout, stderr) = run(&[entry.to_str().unwrap()], None);
  assert!(ok, "exit ok; stderr={stderr}");
  let v: serde_json::Value = serde_json::from_str(&stdout).unwrap();
  assert_eq!(v["value"], 42, "imported helper must be bundled + run: {v}");
}

#[test]
fn module_without_default_export_yields_null() {
  let dir = tempfile::tempdir().unwrap();
  let path = dir.path().join("s.ts");
  std::fs::write(&path, "export const x = 1;\nconst _y = x + 1;").unwrap();
  let (ok, stdout, _) = run(&[path.to_str().unwrap()], None);
  assert!(ok);
  let v: serde_json::Value = serde_json::from_str(&stdout).unwrap();
  assert_eq!(v["status"], "ok", "{v}");
  assert!(v["value"].is_null(), "no default export -> null result: {v}");
}

#[test]
fn inline_eval_with_static_import_runs_as_module() {
  // `--eval` containing a static import is detected and bundled. Uses a
  // top-level await with no default export -> null result, but must not
  // error on the `import`/`export` syntax (which raw eval would reject).
  let (ok, stdout, stderr) = run(&["-e", "export default Math.max(1, 41) + 1;"], None);
  assert!(ok, "exit ok; stderr={stderr}");
  let v: serde_json::Value = serde_json::from_str(&stdout).unwrap();
  assert_eq!(v["value"], 42, "{v}");
}