256 lines
8.4 KiB
Rust
256 lines
8.4 KiB
Rust
//! Behavioral test: a completed yoke run persists per-iteration and per-run
|
|
//! metrics rows under the user's home directory (`~/.yoke/metrics/`) and the
|
|
//! rows survive `yoke clean`.
|
|
//!
|
|
//! Uses a mock `claude` script so no real agent invocation happens. The
|
|
//! subprocess gets HOME pointed at the test tempdir so writes don't escape
|
|
//! the test sandbox.
|
|
|
|
use std::fs;
|
|
use std::os::unix::fs::PermissionsExt;
|
|
use std::process::Command;
|
|
|
|
fn yoke_bin() -> std::path::PathBuf {
|
|
let mut path = std::env::current_exe()
|
|
.expect("current_exe")
|
|
.parent()
|
|
.expect("parent of test binary")
|
|
.parent()
|
|
.expect("parent of deps dir")
|
|
.to_path_buf();
|
|
path.push("yoke");
|
|
path
|
|
}
|
|
|
|
const PROTOCOL: &str = "\
|
|
# Protocol
|
|
|
|
Single-iteration test. Write STATUS: DONE and exit.
|
|
";
|
|
|
|
const PLAN: &str = "\
|
|
## Stage 1
|
|
|
|
Be done.
|
|
";
|
|
|
|
const CONF: &str = "\
|
|
allow .
|
|
";
|
|
|
|
const MOCK_CLAUDE: &str = r#"#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
# Always: write STATUS: DONE so the loop exits after one iteration.
|
|
printf 'STATUS: DONE\n\n## Stage 1\nDone.\n' > .loop/notes.md
|
|
exit 0
|
|
"#;
|
|
|
|
#[test]
|
|
fn metrics_rows_persist_under_home_and_survive_clean() {
|
|
// Build yoke
|
|
let status = Command::new("cargo")
|
|
.args(["build", "--quiet"])
|
|
.status()
|
|
.expect("cargo build");
|
|
assert!(status.success(), "cargo build failed");
|
|
|
|
let yoke = yoke_bin();
|
|
let tmp = tempfile::tempdir().expect("tempdir");
|
|
let project = tmp.path().join("proj");
|
|
fs::create_dir(&project).expect("create proj");
|
|
|
|
// Isolated HOME so metrics land under tmp/.yoke/metrics/...
|
|
let fake_home = tmp.path().join("home");
|
|
fs::create_dir(&fake_home).expect("create home");
|
|
|
|
// Project files
|
|
let loop_dir = project.join(".loop");
|
|
fs::create_dir(&loop_dir).expect("create .loop");
|
|
fs::write(loop_dir.join("protocol.md"), PROTOCOL).unwrap();
|
|
fs::write(loop_dir.join("plan.md"), PLAN).unwrap();
|
|
fs::write(loop_dir.join("yoke.conf"), CONF).unwrap();
|
|
fs::write(loop_dir.join("notes.md"), "").unwrap();
|
|
fs::write(loop_dir.join("guard-results.md"), "").unwrap();
|
|
|
|
// Mock claude on PATH
|
|
let mock_bin_dir = project.join("mock-bin");
|
|
fs::create_dir(&mock_bin_dir).expect("create mock-bin");
|
|
let mock_claude_path = mock_bin_dir.join("claude");
|
|
fs::write(&mock_claude_path, MOCK_CLAUDE).unwrap();
|
|
fs::set_permissions(&mock_claude_path, fs::Permissions::from_mode(0o755)).unwrap();
|
|
|
|
// Boundary check needs a git repo
|
|
let git = |args: &[&str]| {
|
|
let out = Command::new("git")
|
|
.args(args)
|
|
.current_dir(&project)
|
|
.env("GIT_CONFIG_NOSYSTEM", "1")
|
|
.env("GIT_AUTHOR_NAME", "test")
|
|
.env("GIT_AUTHOR_EMAIL", "test@test")
|
|
.env("GIT_COMMITTER_NAME", "test")
|
|
.env("GIT_COMMITTER_EMAIL", "test@test")
|
|
.output()
|
|
.unwrap_or_else(|e| panic!("git {:?} failed: {}", args, e));
|
|
assert!(
|
|
out.status.success(),
|
|
"git {:?} failed: {}",
|
|
args,
|
|
String::from_utf8_lossy(&out.stderr)
|
|
);
|
|
};
|
|
git(&["init"]);
|
|
fs::write(project.join("seed.txt"), "x\n").unwrap();
|
|
git(&["add", "seed.txt"]);
|
|
git(&["-c", "user.name=t", "-c", "user.email=t@t", "commit", "-m", "init"]);
|
|
|
|
let original_path = std::env::var("PATH").unwrap_or_default();
|
|
let test_path = format!("{}:{}", mock_bin_dir.display(), original_path);
|
|
|
|
let output = Command::new(&yoke)
|
|
.args(["run", "--no-sandbox"])
|
|
.current_dir(&project)
|
|
.env("PATH", &test_path)
|
|
.env("HOME", &fake_home)
|
|
.output()
|
|
.expect("failed to run yoke");
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
assert!(
|
|
output.status.success(),
|
|
"yoke should exit 0 on STATUS: DONE.\nstderr:\n{}",
|
|
stderr
|
|
);
|
|
|
|
// Find the metrics directory: ~/.yoke/metrics/<project-slug>/
|
|
let metrics_dir = fake_home.join(".yoke").join("metrics");
|
|
assert!(
|
|
metrics_dir.exists(),
|
|
"metrics dir should be created at {}",
|
|
metrics_dir.display()
|
|
);
|
|
let project_dirs: Vec<_> = fs::read_dir(&metrics_dir)
|
|
.expect("read metrics dir")
|
|
.filter_map(|e| e.ok())
|
|
.map(|e| e.path())
|
|
.collect();
|
|
assert_eq!(
|
|
project_dirs.len(),
|
|
1,
|
|
"expected exactly one project slug dir, got {:?}",
|
|
project_dirs
|
|
);
|
|
let slug_dir = &project_dirs[0];
|
|
|
|
// The slug dir must contain runs.ndjson + at least one <run-id>.ndjson
|
|
let mut runs_path = None;
|
|
let mut iter_path = None;
|
|
for entry in fs::read_dir(slug_dir).expect("read slug dir").flatten() {
|
|
let name = entry.file_name().to_string_lossy().into_owned();
|
|
if name == "runs.ndjson" {
|
|
runs_path = Some(entry.path());
|
|
} else if name.ends_with(".ndjson") {
|
|
iter_path = Some(entry.path());
|
|
}
|
|
}
|
|
let runs_path = runs_path.expect("runs.ndjson should exist");
|
|
let iter_path = iter_path.expect("iteration ndjson file should exist");
|
|
|
|
// Run row: contains outcome=done and iterations >= 1
|
|
let runs_content = fs::read_to_string(&runs_path).expect("read runs.ndjson");
|
|
assert!(
|
|
runs_content.contains("\"outcome\":\"done\""),
|
|
"runs.ndjson should record outcome=done.\ncontent: {}",
|
|
runs_content
|
|
);
|
|
assert!(
|
|
runs_content.contains("\"iterations\":1"),
|
|
"runs.ndjson should report 1 iteration.\ncontent: {}",
|
|
runs_content
|
|
);
|
|
|
|
// Iteration row: has mode=loop, status_done=true, restore_ms field
|
|
let iter_content = fs::read_to_string(&iter_path).expect("read iter ndjson");
|
|
assert!(
|
|
iter_content.contains("\"mode\":\"loop\""),
|
|
"iter row should carry mode=loop.\ncontent: {}",
|
|
iter_content
|
|
);
|
|
assert!(
|
|
iter_content.contains("\"status_done\":true"),
|
|
"iter row should record status_done=true.\ncontent: {}",
|
|
iter_content
|
|
);
|
|
assert!(
|
|
iter_content.contains("\"restore_ms\":"),
|
|
"iter row should record restore_ms.\ncontent: {}",
|
|
iter_content
|
|
);
|
|
|
|
// Survive `yoke clean`
|
|
let clean_output = Command::new(&yoke)
|
|
.args(["clean"])
|
|
.current_dir(&project)
|
|
.env("HOME", &fake_home)
|
|
.output()
|
|
.expect("yoke clean");
|
|
assert!(clean_output.status.success(), "yoke clean failed");
|
|
assert!(
|
|
runs_path.exists(),
|
|
"runs.ndjson must survive yoke clean (lives outside the project)"
|
|
);
|
|
assert!(
|
|
iter_path.exists(),
|
|
"iteration ndjson must survive yoke clean"
|
|
);
|
|
|
|
// `yoke stats` lists the run we just recorded
|
|
let stats_output = Command::new(&yoke)
|
|
.args(["stats"])
|
|
.current_dir(&project)
|
|
.env("HOME", &fake_home)
|
|
.output()
|
|
.expect("yoke stats");
|
|
assert!(stats_output.status.success(), "yoke stats failed");
|
|
let stats_stdout = String::from_utf8_lossy(&stats_output.stdout);
|
|
let stats_stderr = String::from_utf8_lossy(&stats_output.stderr);
|
|
let combined = format!("{}{}", stats_stdout, stats_stderr);
|
|
// Derive run-id from the file name we found earlier
|
|
let run_id = iter_path
|
|
.file_stem()
|
|
.and_then(|s| s.to_str())
|
|
.expect("run id from file stem");
|
|
assert!(
|
|
combined.contains(run_id),
|
|
"yoke stats should list the run-id {} in its output.\ncombined:\n{}",
|
|
run_id,
|
|
combined
|
|
);
|
|
assert!(
|
|
combined.contains("done"),
|
|
"yoke stats should show the run outcome 'done'.\ncombined:\n{}",
|
|
combined
|
|
);
|
|
|
|
// `yoke stats --run <id>` shows the iteration table
|
|
let run_output = Command::new(&yoke)
|
|
.args(["stats", "--run", run_id])
|
|
.current_dir(&project)
|
|
.env("HOME", &fake_home)
|
|
.output()
|
|
.expect("yoke stats --run");
|
|
assert!(run_output.status.success(), "yoke stats --run failed");
|
|
let run_stdout = String::from_utf8_lossy(&run_output.stdout);
|
|
let run_stderr = String::from_utf8_lossy(&run_output.stderr);
|
|
let run_combined = format!("{}{}", run_stdout, run_stderr);
|
|
assert!(
|
|
run_combined.contains("ITER"),
|
|
"yoke stats --run should print an iteration table header.\noutput:\n{}",
|
|
run_combined
|
|
);
|
|
assert!(
|
|
// We produced one iteration; the row should show iteration "1"
|
|
// and one of the "pass" / "yes" status badges.
|
|
run_combined.contains("pass"),
|
|
"yoke stats --run should report guard status.\noutput:\n{}",
|
|
run_combined
|
|
);
|
|
}
|