use serde_json::Value;
use std::{path::Path, process::Command};
use tempfile::TempDir;
fn command(dir: &Path) -> Command {
let mut c = Command::new(env!("CARGO_BIN_EXE_qqq"));
c.current_dir(dir)
.env("HOME", dir)
.env_remove("QQQ_SESSION")
.env_remove("CODEX_THREAD_ID")
.env_remove("CODEX_SESSION_ID")
.env_remove("HERDR_PANE_ID")
.env_remove("HERDR_ENV");
c
}
fn text(dir: &Path, args: &[&str]) -> String {
let out = command(dir).args(args).output().unwrap();
assert!(
out.status.success(),
"{args:?}: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(out.stderr.is_empty());
String::from_utf8(out.stdout).unwrap()
}
fn project() -> TempDir {
let dir = TempDir::new().unwrap();
let init = text(dir.path(), &["init"]);
assert!(init.starts_with("Database: "), "{init}");
assert!(init.contains("qqq.db"));
dir
}
#[cfg(unix)]
#[test]
fn human_list_tty_width_shows_multiline_rows() {
let output = Command::new("python3")
.arg(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/list_tty_pty.py"
))
.arg(env!("CARGO_BIN_EXE_qqq"))
.output()
.unwrap();
assert!(
output.status.success(),
"{}\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
#[test]
fn next_dry_run_reports_stored_task_and_empty_queue() {
let dir = project();
let p = dir.path();
assert_eq!(
text(p, &["next", "--dry-run"]),
"No task available for pickup.\n"
);
assert_eq!(text(p, &["next", "--dry-run", "--json"]), "null\n");
text(p, &["add", "Preview"]);
let preview = text(p, &["next", "--dry-run"]);
assert!(preview.contains("#1"));
assert!(preview.contains("Status: New"));
assert!(!preview.contains("Harness session:"));
assert!(!preview.contains('\u{1b}'));
assert_eq!(text(p, &["next", "--dry-run"]), preview);
text(p, &["next", "--local", "--session", "owner"]);
assert_eq!(
text(p, &["next", "--dry-run", "--session", "owner"]),
"No task available for pickup.\n"
);
}
#[test]
fn human_next_omits_assignment_while_json_and_show_retain_it() {
let dir = project();
let p = dir.path();
text(
p,
&[
"add",
"Build API\nKeep full task context",
"--priority",
"8",
],
);
let claim = text(
p,
&[
"next",
"--local",
"--session",
"worker",
"--harness-name",
"codex",
"--harness-session",
"visible",
"--orchestrator-name",
"herdr",
"--orchestrator-session",
"work",
],
);
for part in [
"#1",
"Status: In progress",
"Priority: 8",
"Parent: -",
"Created:",
"Updated:",
"Build API\n Keep full task context",
] {
assert!(claim.contains(part), "{part}: {claim}");
}
let repeated = text(p, &["next", "--local", "--session", "worker"]);
for output in [&claim, &repeated] {
for label in [
"Harness name:",
"Harness session:",
"Orchestrator name:",
"Orchestrator session:",
] {
assert!(!output.contains(label), "{label}: {output}");
}
}
let json: Value = serde_json::from_str(&text(
p,
&["next", "--local", "--session", "worker", "--json"],
))
.unwrap();
let shown = text(p, &["show", "1"]);
for (key, expected) in [
("harness_name", "codex"),
("harness_session", "visible"),
("orchestrator_name", "herdr"),
("orchestrator_session", "work"),
] {
assert_eq!(json[key], expected);
}
for (label, expected) in [
("Harness", "visible (codex)"),
("Orchestrator", "work (herdr)"),
] {
assert!(
shown
.lines()
.any(|line| line.trim_start().starts_with(label)
&& line.trim_end().ends_with(expected)),
"{label}: {shown}"
);
}
}
#[test]
fn human_tasks_show_descriptions_dependencies_and_ownership() {
let d = project();
let p = d.path();
assert_eq!(text(p, &["list"]), "No tasks yet.\n");
assert_eq!(
text(p, &["next", "--session", "a"]),
"No task available for pickup.\n"
);
let added = text(p, &["add", "Build API\n\nFirst line\nSecond line"]);
assert!(added.contains("#1"), "{added}");
assert!(added.contains("Build API"), "{added}");
assert!(added.contains("Status: New"));
assert!(added.contains("First line\n Second line"));
for label in [
"Harness name:",
"Harness session:",
"Orchestrator name:",
"Orchestrator session:",
] {
assert!(!added.contains(label), "{label}: {added}");
}
for label in ["Parent:", "Created:", "Updated:"] {
assert!(added.contains(label), "{label}: {added}");
}
text(p, &["add", "Build client", "--parent", "1"]);
let list = text(p, &["list"]);
for part in [
"ID",
"STATUS",
"TASK",
"Build API",
"Build client",
"New",
"└── Build client",
] {
assert!(list.contains(part), "{part}: {list}");
}
assert!(!list.contains("PARENT"));
let next = text(p, &["next", "--session", "a"]);
assert!(next.contains("Status: In progress"));
assert!(!next.contains("Harness session:"));
let child = text(p, &["show", "2"]);
assert!(
child
.lines()
.any(|line| line.trim_start().starts_with("Parent:") && line.ends_with("#1"))
);
for name in ["Harness", "Orchestrator"] {
assert!(
child
.lines()
.any(|line| line.trim_start().starts_with(name) && line.trim_end().ends_with('-')),
"{name}: {child}"
);
}
let changed = text(p, &["edit", "2", "--description", "New details"]);
assert!(changed.contains("New details"));
assert!(
text(p, &["edit", "1", "--set-status", "new", "--session", "a"]).contains("Status: New")
);
text(p, &["next", "--session", "a"]);
assert!(text(p, &["complete", "1", "--session", "a"]).contains("Status: Completed"));
}
#[test]
fn human_show_includes_messages_images_history_and_export_result() {
let d = project();
let p = d.path();
text(p, &["add", "Task"]);
let message = text(p, &["message", "1", "Useful note", "--session", "author"]);
assert!(message.contains("Message #1"));
assert!(message.contains("Task: #1"));
assert!(message.contains("author"));
assert!(message.contains("Useful note"));
std::fs::write(p.join("test.png"), b"\x89PNG\r\n\x1a\nfixture").unwrap();
text(p, &["edit", "1", "--image", "test.png"]);
text(p, &["next", "--session", "a"]);
text(p, &["edit", "1", "--set-status", "new", "--session", "a"]);
let show = text(p, &["show", "1"]);
for part in [
"Messages:",
"Images:",
"History:",
"Useful note",
"test.png",
"Image #1",
"image/png",
"15 bytes",
"claim",
"release",
"Herdr: Not linked",
"Created:",
"Updated:",
] {
assert!(show.contains(part), "{part}: {show}");
}
let export = text(
p,
&["show", "1", "--export-image", "1", "--output", "out.png"],
);
assert!(export.contains("Exported image #1 to out.png"));
}
#[test]
fn global_json_flag_preserves_machine_output_and_errors() {
let d = project();
let p = d.path();
let task: Value = serde_json::from_str(&text(p, &["--json", "add", "Task"])).unwrap();
assert_eq!(task["id"], 1);
for key in [
"harness_name",
"harness_session",
"orchestrator_name",
"orchestrator_session",
] {
assert_eq!(task.get(key), Some(&Value::Null), "{key}: {task}");
}
let list: Value = serde_json::from_str(&text(p, &["list", "--json"])).unwrap();
assert_eq!(list[0], task);
text(p, &["next", "--session", "a"]);
assert_eq!(text(p, &["next", "--session", "b", "--json"]), "null\n");
for args in [vec!["show", "999"], vec!["--json", "show", "999"]] {
let json = args.contains(&"--json");
let out = command(p).args(args).output().unwrap();
assert_eq!(out.status.code(), Some(1));
assert!(out.stdout.is_empty());
if json {
let failure: Value = serde_json::from_slice(&out.stderr).unwrap();
assert_eq!(failure["code"], "TASK_NOT_FOUND");
assert_eq!(failure["details"]["task_id"], 999);
} else {
assert!(String::from_utf8_lossy(&out.stderr).contains("error: Task 999 not found"));
}
}
}
#[cfg(unix)]
#[test]
fn human_herdr_output_includes_saved_identity_and_live_location() {
use std::os::unix::fs::PermissionsExt;
let d = project();
let p = d.path();
text(p, &["add", "Task"]);
let bin = p.join("herdr");
std::fs::write(&bin, r#"#!/bin/sh
printf '%s\n' '{"result":{"agents":[{"pane_id":"p1","workspace_id":"w1","tab_id":"t1","agent_session":{"agent":"codex","kind":"id","value":"session-1"}}]}}'
"#).unwrap();
std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
for args in [
vec![
"herdr",
"link",
"1",
"--agent",
"codex",
"--agent-session",
"session-1",
],
vec!["herdr", "find", "1"],
vec!["show", "1"],
] {
let out = command(p)
.args(args)
.env(
"PATH",
format!("{}:{}", p.display(), std::env::var("PATH").unwrap()),
)
.output()
.unwrap();
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
let output = String::from_utf8(out.stdout).unwrap();
for part in ["codex", "session-1", "Workspace: w1", "Tab: t1", "Pane: p1"] {
assert!(output.contains(part), "{part}: {output}");
}
}
}
#[test]
fn human_rows_escape_control_characters_without_changing_json_data() {
let d = project();
let title = "Two\nlines\t\u{1b}[31m";
text(d.path(), &["add", title]);
let list = text(d.path(), &["list"]);
assert_eq!(list.lines().count(), 2);
assert!(!list.contains('\u{1b}'));
let json: Value = serde_json::from_str(&text(d.path(), &["list", "--json"])).unwrap();
assert_eq!(json[0]["description"], title);
}
#[test]
fn aliases_support_human_default_and_json_in_all_positions() {
let d = project();
let p = d.path();
text(p, &["add", "Alias task"]);
std::fs::create_dir_all(p.join(".config/qqq")).unwrap();
std::fs::write(
p.join(".config/qqq/config.toml"),
"[alias]\nls = 'list'\njs = '--json ls'\n",
)
.unwrap();
for args in [
vec!["ls"],
vec!["--json", "ls"],
vec!["ls", "--json"],
vec!["js"],
] {
let output = command(p).env("HOME", p).args(&args).output().unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
if args == ["ls"] {
let output = String::from_utf8(output.stdout).unwrap();
assert!(output.starts_with("ID"));
assert!(output.contains("Alias task"));
} else {
let tasks: Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(tasks[0]["description"], "Alias task");
}
}
}
#[test]
fn human_list_groups_nested_dependencies_with_correct_tree_branches() {
let d = project();
let p = d.path();
for args in [
vec!["add", "Project A"],
vec!["add", "Project B"],
vec!["add", "API", "--parent", "1"],
vec!["add", "UI", "--parent", "1"],
vec!["add", "Auth", "--parent", "3"],
vec!["add", "Storage", "--parent", "3"],
vec!["add", "App", "--parent", "2"],
vec!["add", "Cleanup"],
vec!["add", "Views", "--parent", "4"],
] {
text(p, &args);
}
text(p, &["next", "--session", "tree"]);
text(p, &["complete", "1", "--session", "tree"]);
text(p, &["next", "--session", "tree"]);
let list = text(p, &["list"]);
let rows: Vec<_> = list.lines().collect();
assert_eq!(rows.len(), 10);
assert_eq!(
rows[0],
format!("{:<6} {:<12} {:>4} TASK", "ID", "STATUS", "PRI")
);
assert!(!list.contains("PARENT"));
for (row, (id, title)) in rows[1..].iter().zip([
(1, "Project A"),
(3, "├── API"),
(5, "│ ├── Auth"),
(6, "│ └── Storage"),
(4, "└── UI"),
(9, " └── Views"),
(2, "Project B"),
(7, "└── App"),
(8, "Cleanup"),
]) {
assert_eq!(row.split_whitespace().next().unwrap(), id.to_string());
assert_eq!(&row[25..], title, "{list}");
}
assert!(rows[1].contains("Completed"));
assert!(rows[7].contains("In progress"));
let json: Value = serde_json::from_str(&text(p, &["--json", "list"])).unwrap();
let ids: Vec<_> = json
.as_array()
.unwrap()
.iter()
.map(|task| task["id"].as_i64().unwrap())
.collect();
assert_eq!(ids, (1..=9).collect::<Vec<_>>());
assert_eq!(json[4]["parent_id"], 3);
assert!(!list.contains('\u{1b}'));
}
#[test]
fn nested_previews_escape_terminal_controls_and_json_keeps_original_text() {
let d = project();
let p = d.path();
text(p, &["add", "Root"]);
let body = "Child\t\u{1b}[31m\nline";
text(p, &["add", body, "--parent", "1"]);
let list = text(p, &["list"]);
assert_eq!(list.lines().count(), 3);
assert!(list.contains("└── Child\\t\\u{1b}[31m"), "{list}");
assert!(!list.contains('\u{1b}'));
let json: Value = serde_json::from_str(&text(p, &["list", "--json"])).unwrap();
assert_eq!(json[1]["description"], body);
}
#[test]
fn human_list_renders_long_dependency_chains() {
let d = project();
let p = d.path();
let conn = rusqlite::Connection::open(p.join(".qqq/qqq.db")).unwrap();
conn.execute_batch(
"WITH RECURSIVE ids(id) AS (SELECT 1 UNION ALL SELECT id+1 FROM ids WHERE id<512)
INSERT INTO tasks(id,description,parent_id) SELECT id,'Task '||id,NULLIF(id-1,0) FROM ids;",
)
.unwrap();
let list = text(p, &["list"]);
let rows: Vec<_> = list.lines().collect();
assert_eq!(rows.len(), 513);
assert_eq!(
&rows[512][25..],
format!("{}└── Task 512", " ".repeat(510))
);
}