use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant};
use vs_store::{ActionFilter, AnnotationTarget, Store};
const VS_BIN: &str = env!("CARGO_BIN_EXE_vs");
struct DaemonGuard {
child: Child,
}
impl Drop for DaemonGuard {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
fn spawn_daemon(home: &Path) -> DaemonGuard {
let child = Command::new(VS_BIN)
.arg(format!("--home={}", home.display()))
.arg("serve")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("spawn vs serve");
let mut guard = DaemonGuard { child };
let socket = home.join("daemon.sock");
let deadline = Instant::now() + Duration::from_secs(5);
while Instant::now() < deadline {
if socket.exists() {
return guard;
}
std::thread::sleep(Duration::from_millis(50));
}
let _ = guard.child.kill();
let _ = guard.child.wait();
panic!(
"daemon socket {} did not appear within 5s",
socket.display()
);
}
struct CliOut {
code: i32,
stdout: String,
stderr: String,
}
fn vs(home: &Path, args: &[&str]) -> CliOut {
let out = Command::new(VS_BIN)
.arg(format!("--home={}", home.display()))
.arg("--no-spawn")
.args(args)
.output()
.expect("run vs");
CliOut {
code: out.status.code().unwrap_or(-1),
stdout: String::from_utf8_lossy(&out.stdout).into_owned(),
stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
}
}
fn token_of(out: &CliOut) -> String {
for line in out.stdout.lines() {
if let Some(rest) = line.strip_prefix('@') {
return rest
.split_whitespace()
.next()
.expect("@-line without token")
.to_string();
}
}
panic!(
"no success envelope in stdout (code={}):\nstdout:\n{}\nstderr:\n{}",
out.code, out.stdout, out.stderr
);
}
fn body_first(out: &CliOut) -> String {
let mut lines = out.stdout.lines();
let _envelope = lines.next();
match lines.next() {
Some(line) => line.to_string(),
None => panic!("empty body in:\n{}", out.stdout),
}
}
fn assert_ok(label: &str, out: &CliOut) {
assert!(
out.code == 0 || out.code == 2,
"{label}: exit={} stdout={:?} stderr={:?}",
out.code,
out.stdout,
out.stderr
);
}
#[test]
fn e2e_all_primitives_via_binary() {
let dir = tempfile::tempdir().unwrap();
let home: PathBuf = dir.path().to_path_buf();
let daemon = spawn_daemon(&home);
let r = vs(&home, &["session-open", "--policy=default"]);
assert_ok("session-open", &r);
let session_id = body_first(&r);
assert!(session_id.starts_with("s_"), "session_id={session_id:?}");
let r = vs(&home, &["open", "https://example.com/login"]);
assert_ok("open", &r);
let page_id = body_first(&r);
assert!(page_id.starts_with("p_"), "page_id={page_id:?}");
let r = vs(&home, &["view", &page_id]);
assert_ok("view", &r);
let view_token = token_of(&r);
let r = vs(&home, &["read", &page_id, "1"]);
assert_ok("read", &r);
assert!(
r.stdout.contains("[1]"),
"read body should reference [1]: {}",
r.stdout
);
let r = vs(
&home,
&[
"act",
&page_id,
"4",
"click",
&format!("--token={view_token}"),
"--group=login-flow",
],
);
assert_ok("act", &r);
let act_token = token_of(&r);
let r = vs(&home, &["find", "stub"]);
assert_ok("find", &r);
let r = vs(&home, &["wait", &page_id, "stable", "--timeout=200"]);
assert_ok("wait", &r);
let r = vs(
&home,
&["extract", &page_id, "list", &format!("--token={act_token}")],
);
assert_ok("extract", &r);
let extract_token = token_of(&r);
let r = vs(
&home,
&[
"mark",
&page_id,
"4",
"submit",
&format!("--token={extract_token}"),
],
);
assert_ok("mark", &r);
let r = vs(
&home,
&["annotate", "mark:submit", "purpose", "primary-cta"],
);
assert_ok("annotate", &r);
let r = vs(&home, &["status"]);
assert_ok("status", &r);
assert!(
r.stdout.contains(&session_id),
"status should include session: {}",
r.stdout
);
let r = vs(&home, &["log", "--limit=50"]);
assert_ok("log", &r);
let r = vs(&home, &["skill", "list"]);
assert_ok("skill list", &r);
let r = vs(&home, &["capture", &page_id]);
assert_ok("capture", &r);
let r = vs(&home, &["viewport", &page_id, "mobile"]);
assert_ok("viewport", &r);
let r = vs(&home, &["layout", &page_id, "1", "2", "4"]);
assert_ok("layout", &r);
let r = vs(&home, &["auth", "list"]);
assert_ok("auth list", &r);
let r = vs(&home, &["close", &page_id]);
assert_ok("close", &r);
let r = vs(&home, &["session-close"]);
assert_ok("session-close", &r);
drop(daemon);
std::thread::sleep(Duration::from_millis(50));
assert_store_state(&home, &session_id, &page_id);
}
fn assert_store_state(home: &Path, session_id: &str, page_id: &str) {
let store = Store::open_read_only(home.join("state.db")).expect("open store ro");
let session = store
.get_session(session_id)
.expect("get_session")
.expect("session row missing");
assert_eq!(session.id, session_id);
assert!(
session.closed_at.is_some(),
"session should be closed: {session:?}"
);
let page = store
.get_page(page_id)
.expect("get_page")
.expect("page row missing");
assert_eq!(page.id, page_id);
assert!(page.closed_at.is_some(), "page should be closed: {page:?}");
let mark = store
.get_mark(session_id, "submit")
.expect("get_mark")
.expect("mark missing");
assert_eq!(mark.name, "submit");
assert_eq!(mark.page_id, page_id);
let target = AnnotationTarget::Mark("submit".into());
let annotations = store.list_annotations(&target).expect("list_annotations");
assert!(
annotations.iter().any(|a| a.key == "purpose"),
"annotation missing: {annotations:?}"
);
let actions = store
.list_actions(&ActionFilter {
session_id: Some(session_id.to_string()),
..Default::default()
})
.expect("list_actions");
let primitives: std::collections::HashSet<&str> =
actions.iter().map(|a| a.primitive.as_str()).collect();
for p in [
"vs_session_open",
"vs_open",
"vs_view",
"vs_read",
"vs_act",
"vs_find",
"vs_wait",
"vs_extract",
"vs_mark",
"vs_annotate",
"vs_status",
"vs_log",
"vs_skill",
"vs_capture",
"vs_viewport",
"vs_layout",
"vs_auth",
"vs_close",
"vs_session_close",
] {
assert!(
primitives.contains(p),
"missing audit row for {p}: have {primitives:?}"
);
}
let grouped = store
.list_actions(&ActionFilter {
session_id: Some(session_id.to_string()),
group_label: Some("login-flow".into()),
..Default::default()
})
.expect("list_actions grouped");
assert!(
grouped.iter().any(|a| a.primitive == "vs_act"),
"expected vs_act with group=login-flow: {grouped:?}"
);
let captures = home.join("captures");
assert!(
captures.is_dir(),
"captures dir missing: {}",
captures.display()
);
let png_count = std::fs::read_dir(&captures)
.expect("read captures")
.filter_map(Result::ok)
.filter(|e| {
e.path()
.extension()
.and_then(|x| x.to_str())
.is_some_and(|s| s.eq_ignore_ascii_case("png"))
})
.count();
assert!(png_count >= 1, "expected ≥1 capture PNG, got {png_count}");
}