sparrow-cli 0.4.0

A local-first Rust agent cockpit — route, run, replay, rewind
Documentation
use sparrow::event::{Block, RunId};
use sparrow::tools::browser_sandbox::{BrowserTool, ComputerTool};
use sparrow::tools::{Tool, ToolCtx, ToolResult};

fn ctx() -> ToolCtx {
    ToolCtx {
        workspace_root: std::env::current_dir().expect("workspace root"),
        run_id: RunId("browser-computer-e2e".into()),
    }
}

fn runtime_is_required() -> bool {
    std::env::var("SPARROW_REQUIRE_PLAYWRIGHT_E2E")
        .ok()
        .as_deref()
        == Some("1")
}

fn data_url(html: &str) -> String {
    let mut encoded = String::with_capacity(html.len());
    for byte in html.bytes() {
        match byte {
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
                encoded.push(byte as char);
            }
            b' ' => encoded.push_str("%20"),
            b'\n' => encoded.push_str("%0A"),
            _ => encoded.push_str(&format!("%{byte:02X}")),
        }
    }
    format!("data:text/html,{encoded}")
}

fn skip_or_panic(result: &ToolResult, action: &str) -> bool {
    if !result.is_error {
        return false;
    }
    let message = result
        .content
        .iter()
        .filter_map(|block| match block {
            Block::Text(text) => Some(text.as_str()),
            _ => None,
        })
        .collect::<Vec<_>>()
        .join("\n");
    let missing_runtime = message.contains("Playwright")
        || message.contains("Chromium")
        || message.contains("Node.js")
        || message.contains("playwright");
    if missing_runtime && !runtime_is_required() {
        eprintln!("skipping Playwright E2E `{action}` because runtime is unavailable: {message}");
        return true;
    }
    panic!("Playwright E2E `{action}` failed: {message}");
}

#[tokio::test]
async fn browser_screenshot_returns_real_png_block() {
    let result = BrowserTool
        .call(
            serde_json::json!({
                "action": "screenshot",
                "url": "about:blank",
                "viewport": { "width": 320, "height": 200 },
                "full_page": false
            }),
            &ctx(),
        )
        .await
        .expect("browser tool call");
    if skip_or_panic(&result, "browser.screenshot") {
        return;
    }

    assert!(!result.is_error);
    match result.content.as_slice() {
        [Block::Image { mime, data }] => {
            assert_eq!(mime, "image/png");
            assert!(
                data.starts_with(b"\x89PNG\r\n\x1a\n"),
                "screenshot must be PNG bytes"
            );
            assert!(data.len() > 500, "screenshot should not be an empty image");
        }
        other => panic!("expected one image block, got {other:?}"),
    }
}

#[tokio::test]
async fn computer_can_type_and_click_in_headless_page() {
    let url = data_url(
        r#"<!doctype html>
<html>
  <body>
    <input id="q" />
    <button id="go" onclick="document.body.dataset.clicked='yes'">Go</button>
  </body>
</html>"#,
    );
    let context = ctx();

    let typed = ComputerTool
        .call(
            serde_json::json!({
                "action": "type",
                "url": url,
                "selector": "#q",
                "text": "sparrow"
            }),
            &context,
        )
        .await
        .expect("computer type call");
    if skip_or_panic(&typed, "computer.type") {
        return;
    }
    assert!(!typed.is_error);
    assert!(
        matches!(typed.content.as_slice(), [Block::Text(text)] if text.contains("typed 7 chars into #q"))
    );

    let clicked = ComputerTool
        .call(
            serde_json::json!({
                "action": "click",
                "url": url,
                "selector": "#go"
            }),
            &context,
        )
        .await
        .expect("computer click call");
    if skip_or_panic(&clicked, "computer.click") {
        return;
    }
    assert!(!clicked.is_error);
    assert!(
        matches!(clicked.content.as_slice(), [Block::Text(text)] if text.contains("clicked #go"))
    );
}