visual-rubric 0.2.0

AI-assisted screenshot rubric runner for local visual UX review
Documentation
mod common;

use std::ffi::OsString;
use std::time::Duration;

use visual_rubric::{PoolConfig, PoolError, RubricOptions, RubricPool};

#[test]
fn rate_limit_records_retry_after_and_exhausts_retries() {
    let Some(fake) = common::fake_codex_acp_binary() else {
        eprintln!("skipping: fake-codex-acp feature is not enabled");
        return;
    };
    let temp = tempfile::TempDir::new().expect("tempdir");
    let image = common::write_fixture_png(&temp);
    let pool = RubricPool::new(PoolConfig {
        workers: 1,
        max_retries: 1,
        backoff_base: Duration::ZERO,
        backoff_cap: Duration::ZERO,
        codex_acp_binary: fake,
        extra_env: vec![(
            OsString::from("FAKE_CODEX_ACP_MODE"),
            OsString::from("rate_limit"),
        )],
        ..PoolConfig::default()
    })
    .expect("pool");

    let err = pool
        .submit(&image, "rate limit", RubricOptions::default())
        .expect_err("rate limit");
    assert_eq!(
        err,
        PoolError::RateLimited {
            retry_after: Some(Duration::from_secs(3))
        }
    );
    let stats = pool.stats();
    assert_eq!(stats.completed, 0);
    assert_eq!(stats.failures, 1);
    assert_eq!(stats.rate_limit_events.len(), 2);
    assert_eq!(
        stats.rate_limit_events[0].retry_after,
        Some(Duration::from_secs(3))
    );
    let _ = pool.shutdown();
}

#[test]
fn validates_worker_count_bounds() {
    let err = match RubricPool::new(PoolConfig {
        workers: 0,
        ..PoolConfig::default()
    }) {
        Ok(_) => panic!("zero workers should fail"),
        Err(error) => error,
    };
    assert!(matches!(err, PoolError::Spawn(message) if message.contains("greater than zero")));

    let err = match RubricPool::new(PoolConfig {
        workers: 65,
        ..PoolConfig::default()
    }) {
        Ok(_) => panic!("too many workers should fail"),
        Err(error) => error,
    };
    assert!(matches!(err, PoolError::Spawn(message) if message.contains("alive bitmask")));
}

#[test]
fn spawn_failure_is_reported() {
    let err = match RubricPool::new(PoolConfig {
        workers: 1,
        codex_acp_binary: "/definitely/missing/codex-acp".into(),
        ..PoolConfig::default()
    }) {
        Ok(_) => panic!("spawn should fail"),
        Err(error) => error,
    };
    assert!(matches!(err, PoolError::Spawn(message) if message.contains("definitely/missing")));
}

#[cfg(unix)]
#[test]
fn worker_crash_can_leave_no_live_workers() {
    let Some(fake) = common::fake_codex_acp_binary() else {
        eprintln!("skipping: fake-codex-acp feature is not enabled");
        return;
    };
    let temp = tempfile::TempDir::new().expect("tempdir");
    let image = common::write_fixture_png(&temp);
    let wrapper = temp.path().join("stateful-fake-acp");
    let counter = temp.path().join("spawn-count");
    write_stateful_crash_wrapper(
        &wrapper,
        fake.to_str().expect("fake codex path"),
        counter.to_str().unwrap(),
    );
    let pool = RubricPool::new(PoolConfig {
        workers: 1,
        max_retries: 0,
        codex_acp_binary: wrapper,
        ..PoolConfig::default()
    })
    .expect("pool");

    let first = pool
        .submit(&image, "crash", RubricOptions::default())
        .expect_err("worker crash");
    assert!(matches!(first, PoolError::WorkerCrashed { .. }));

    let second = pool
        .submit(&image, "no workers", RubricOptions::default())
        .expect_err("no live workers");
    assert_eq!(second, PoolError::NoLiveWorkers);
    let _ = pool.shutdown();
}

#[cfg(unix)]
fn write_stateful_crash_wrapper(path: &std::path::Path, fake: &str, counter: &str) {
    use std::io::Write as _;
    use std::os::unix::fs::PermissionsExt as _;

    let mut file = std::fs::File::create(path).expect("wrapper");
    writeln!(
        file,
        r#"#!{}
set -eu
counter={counter:?}
fake={fake:?}
n=0
if [ -f "$counter" ]; then
  n="$(cat "$counter")"
fi
n=$((n + 1))
printf '%s' "$n" > "$counter"
if [ "$n" = 1 ]; then
  export FAKE_CODEX_ACP_MODE=prompt_crash
else
  export FAKE_CODEX_ACP_MODE=crash
fi
exec "$fake" "$@"
"#,
        std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string())
    )
    .expect("write wrapper");
    drop(file);
    let mut permissions = std::fs::metadata(path).unwrap().permissions();
    permissions.set_mode(0o755);
    std::fs::set_permissions(path, permissions).unwrap();
}

#[test]
fn missing_png_path_is_reported() {
    let Some(fake) = common::fake_codex_acp_binary() else {
        eprintln!("skipping: fake-codex-acp feature is not enabled");
        return;
    };
    let temp = tempfile::TempDir::new().expect("tempdir");
    let missing = temp.path().join("missing.png");
    let pool = RubricPool::new(PoolConfig {
        workers: 1,
        max_retries: 0,
        codex_acp_binary: fake,
        extra_env: vec![(
            OsString::from("FAKE_CODEX_ACP_MODE"),
            OsString::from("pass"),
        )],
        ..PoolConfig::default()
    })
    .expect("pool");

    let err = pool
        .submit(&missing, "missing", RubricOptions::default())
        .expect_err("missing image");
    assert!(matches!(err, PoolError::Rpc(message) if message.contains("read png")));
    let _ = pool.shutdown();
}

#[test]
fn worker_codex_home_is_seeded_from_source_home() {
    let Some(fake) = common::fake_codex_acp_binary() else {
        eprintln!("skipping: fake-codex-acp feature is not enabled");
        return;
    };
    let temp = tempfile::TempDir::new().expect("tempdir");
    let source_home = temp.path().join("source-codex");
    std::fs::create_dir(&source_home).expect("source home");
    std::fs::write(source_home.join("config.toml"), "seeded = true\n").expect("seed config");
    let env_log = temp.path().join("codex-home.log");
    let image = common::write_fixture_png(&temp);
    let pool = RubricPool::new(PoolConfig {
        workers: 1,
        codex_acp_binary: fake,
        source_codex_home: Some(source_home),
        extra_env: vec![
            (
                OsString::from("FAKE_CODEX_ACP_MODE"),
                OsString::from("pass"),
            ),
            (
                OsString::from("FAKE_CODEX_ACP_ENV_LOG"),
                env_log.as_os_str().to_os_string(),
            ),
            (
                OsString::from("FAKE_CODEX_ACP_LOG_ENV_KEY"),
                OsString::from("CODEX_HOME"),
            ),
        ],
        ..PoolConfig::default()
    })
    .expect("pool");

    let verdict = pool
        .submit(&image, "pass", RubricOptions::default())
        .expect("verdict");
    assert_eq!(verdict.verdict, "pass");
    let worker_home = std::fs::read_to_string(env_log).expect("env log");
    let seeded_config =
        std::fs::read_to_string(std::path::Path::new(worker_home.trim()).join("config.toml"))
            .expect("seeded worker config");
    assert!(seeded_config.contains("seeded = true"));
    let _ = pool.shutdown();
}