diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5b4ed4a..70ee161 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,11 +18,13 @@ jobs: name: Rust Quality runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 - - - uses: dtolnay/rust-toolchain@stable + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: - components: rustfmt, clippy + persist-credentials: false + + - run: rustup component add rustfmt clippy + + - uses: Swatinem/rust-cache@42dc69e1aa15d09112580998cf2ef0119e2e91ae # v2 - run: cargo fmt --check - run: cargo clippy --all-targets --all-features -- -D warnings @@ -32,9 +34,11 @@ jobs: name: JS and npm checks runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + persist-credentials: false - - uses: oven-sh/setup-bun@v2 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 - run: bun install --frozen-lockfile working-directory: tui-opentui @@ -42,7 +46,16 @@ jobs: - run: bun run typecheck working-directory: tui-opentui - - uses: actions/setup-node@v4 + - uses: Swatinem/rust-cache@42dc69e1aa15d09112580998cf2ef0119e2e91ae # v2 + + - run: cargo build --quiet --bin tsq + + - run: bun test + working-directory: tui-opentui + env: + TSQ_CONTRACT_BIN: ${{ github.workspace }}/target/debug/tsq + + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: "24" diff --git a/src/app/service.rs b/src/app/service.rs index 4468cc0..b7e5457 100644 --- a/src/app/service.rs +++ b/src/app/service.rs @@ -321,7 +321,12 @@ impl TasqueService { } pub fn migrate(&self, branch: &str) -> Result { - crate::app::sync::migrate_to_sync_branch(&self.ctx.repo_root, branch, &self.ctx.actor) + crate::app::sync::migrate_to_sync_branch( + &self.ctx.repo_root, + branch, + &self.ctx.actor, + crate::app::sync::PushMode::Required, + ) } pub fn sync(&self, push: bool) -> Result { diff --git a/src/app/sync.rs b/src/app/sync.rs index 10f3b64..aefd461 100644 --- a/src/app/sync.rs +++ b/src/app/sync.rs @@ -35,7 +35,12 @@ pub fn resolve_effective_root(repo_root: &str) -> Result { None => { let repo_path = Path::new(repo_root); if git::is_git_repo(repo_path) && !git::is_sync_worktree_path(repo_path) { - let migrated = migrate_to_sync_branch(repo_root, DEFAULT_SYNC_BRANCH, "tsq")?; + let migrated = migrate_to_sync_branch( + repo_root, + DEFAULT_SYNC_BRANCH, + "tsq", + PushMode::BestEffort, + )?; return Ok(migrated.worktree_path); } return Ok(repo_root.to_string()); @@ -57,7 +62,11 @@ pub fn resolve_effective_root(repo_root: &str) -> Result { )); } - let worktree = with_setup_lock(repo_root, || git::ensure_worktree(repo_path, &branch))?; + let worktree = with_setup_lock(repo_root, || { + let wt = git::ensure_worktree(repo_path, &branch)?; + git::setup_merge_driver_config(repo_path)?; + Ok(wt) + })?; Ok(worktree.to_string_lossy().to_string()) } @@ -126,6 +135,15 @@ fn setup_sync_branch_locked(repo_root: &str, branch: &str) -> Result Result { let existing = read_events(repo_root)?; @@ -166,7 +185,23 @@ pub fn migrate_to_sync_branch( let wt_path = Path::new(&setup.worktree_path); let _ = git::commit_worktree(wt_path, "chore: migrate tasque events to sync branch")?; if let Some(remote) = git::current_upstream_remote(Path::new(repo_root))? { - git::push_current_set_upstream(wt_path, &remote, branch)?; + match push { + PushMode::Required => { + git::push_current_set_upstream(wt_path, &remote, branch)?; + } + PushMode::BestEffort => { + if let Err(error) = git::push_current_set_upstream(wt_path, &remote, branch) { + // `remote` is git-derived and `error.message` may carry + // untrusted data; sanitize both before writing to stderr to + // avoid emitting raw terminal control sequences. + eprintln!( + "tsq: warning: migrated events to sync branch but push to '{}' failed: {}; run 'tsq sync' to push later", + crate::cli::render::sanitize_inline(&remote), + crate::cli::render::sanitize_inline(&error.message) + ); + } + } + } } clear_repo_events(repo_root)?; @@ -196,6 +231,7 @@ pub fn sync_worktree(repo_root: &str, push: bool) -> Result String { + sanitize(value, false) +} + +/// Same escaping as `sanitize_inline`, but preserves real newlines for +/// multi-line human-output contexts (spec content, note bodies). +pub(crate) fn sanitize_block(value: &str) -> String { + sanitize(value, true) +} + +fn sanitize(value: &str, keep_newlines: bool) -> String { + let mut out = String::with_capacity(value.len()); + for ch in value.chars() { + match ch { + '\\' => out.push_str("\\\\"), + '\t' => out.push_str("\\t"), + '\r' => out.push_str("\\r"), + '\n' if keep_newlines => out.push('\n'), + '\n' => out.push_str("\\n"), + c if is_bidi_control(c) => { + out.push_str(&format!("\\u{{{:x}}}", c as u32)); + } + c if is_unsafe_control(c) => { + out.push_str(&format!("\\x{:02x}", c as u32)); + } + c => out.push(c), + } + } + out +} + +fn is_unsafe_control(ch: char) -> bool { + let code = ch as u32; + code < 0x20 || code == 0x7f || (0x80..=0x9f).contains(&code) +} + +fn is_bidi_control(ch: char) -> bool { + matches!(ch as u32, 0x061c | 0x200e | 0x200f | 0x202a..=0x202e | 0x2066..=0x2069) +} + fn plain_cell(value: &str) -> String { - value - .replace('\\', "\\\\") - .replace('\t', "\\t") - .replace('\r', "\\r") - .replace('\n', "\\n") + sanitize_inline(value) } pub fn print_task_list(tasks: &[Task]) { @@ -32,12 +74,15 @@ pub fn print_task_list(tasks: &[Task]) { .map(|task| { vec![ task.id.clone(), - task.alias.clone(), + sanitize_inline(&task.alias), task.priority.to_string(), task_kind_to_string(task.kind).to_string(), status_to_string(task.status).to_string(), - task.assignee.clone().unwrap_or_else(|| "-".to_string()), - task.title.clone(), + task.assignee + .as_deref() + .map(sanitize_inline) + .unwrap_or_else(|| "-".to_string()), + sanitize_inline(&task.title), ] }) .collect(); @@ -102,7 +147,12 @@ pub fn print_task_list_plain(tasks: &[Task]) { } pub fn print_task(task: &Task) { - println!("{} {} {}", style::task_id(&task.id), task.alias, task.title); + println!( + "{} {} {}", + style::task_id(&task.id), + sanitize_inline(&task.alias), + sanitize_inline(&task.title) + ); println!( "{}={} {}={} {}={}", style::key("kind"), @@ -120,13 +170,21 @@ pub fn print_task(task: &Task) { ); } if let Some(assignee) = &task.assignee { - println!("{}={}", style::key("assignee"), assignee); + println!("{}={}", style::key("assignee"), sanitize_inline(assignee)); } if let Some(external_ref) = &task.external_ref { - println!("{}={}", style::key("external_ref"), external_ref); + println!( + "{}={}", + style::key("external_ref"), + sanitize_inline(external_ref) + ); } if let Some(discovered_from) = &task.discovered_from { - println!("{}={}", style::key("discovered_from"), discovered_from); + println!( + "{}={}", + style::key("discovered_from"), + sanitize_inline(discovered_from) + ); } if let Some(parent) = &task.parent_id { println!("{}={}", style::key("parent"), parent); @@ -138,7 +196,11 @@ pub fn print_task(task: &Task) { println!("{}={}", style::key("duplicate_of"), duplicate_of); } if let Some(description) = &task.description { - println!("{}={}", style::key("description"), description); + println!( + "{}={}", + style::key("description"), + sanitize_inline(description) + ); } println!("{}={}", style::key("notes"), task.notes.len()); if let (Some(spec_path), Some(spec_fingerprint)) = (&task.spec_path, &task.spec_fingerprint) { @@ -271,8 +333,9 @@ pub fn print_show_result_plain(data: &ShowResult) { pub fn print_spec_content(data: &SpecContentResult) { println!("--- spec: {} ---", data.spec_path); - print!("{}", data.content); - if !data.content.ends_with('\n') { + let sanitized = sanitize_block(&data.content); + print!("{}", sanitized); + if !sanitized.ends_with('\n') { println!(); } println!("--- end spec ---"); @@ -389,11 +452,14 @@ fn render_tree_node( meta_width, ); if max_title_width > 0 { - primary_parts.push(truncate_with_ellipsis(&node.task.title, max_title_width)); + primary_parts.push(truncate_with_ellipsis( + &sanitize_inline(&node.task.title), + max_title_width, + )); } } Density::Medium | Density::Wide => { - primary_parts.push(node.task.title.clone()); + primary_parts.push(sanitize_inline(&node.task.title)); primary_parts.push(meta); if density == Density::Wide && let Some(flow) = &flow @@ -495,7 +561,7 @@ pub fn print_merge_result(result: &MergeResult) { "{}={} \"{}\" [{}]", style::key("target"), style::task_id(&result.target.id), - result.target.title, + sanitize_inline(&result.target.title), result.target.status ); if let Some(summary) = &result.plan_summary { @@ -544,7 +610,7 @@ fn format_meta_badge_text(task: &Task) -> String { let assignee = task .assignee .as_ref() - .map(|value| format!(" @{}", value)) + .map(|value| format!(" @{}", sanitize_inline(value))) .unwrap_or_default(); format!("[p{}{}]", task.priority, assignee) } @@ -786,7 +852,7 @@ pub fn print_task_note(task_id: &str, note: &TaskNote) { note.actor, style::muted(¬e.event_id) ); - println!("{}", note.text); + println!("{}", sanitize_block(¬e.text)); } pub fn print_task_notes(task_id: &str, notes: &[TaskNote]) { @@ -808,7 +874,7 @@ pub fn print_task_notes(task_id: &str, notes: &[TaskNote]) { note.actor, style::muted(¬e.event_id) ); - println!("{}", note.text); + println!("{}", sanitize_block(¬e.text)); } } @@ -877,7 +943,7 @@ fn print_dep_node(node: &DepTreeNode, prefix: &str, is_last: bool, is_root: bool style::tree_prefix(connector), format_status(node.task.status), style::task_id(&node.task.id), - node.task.title, + sanitize_inline(&node.task.title), dir_tag, type_tag ); @@ -947,6 +1013,62 @@ mod tests { use super::*; use crate::types::{PlanningState, TaskKind}; + #[test] + fn sanitize_inline_escapes_esc() { + assert_eq!(sanitize_inline("a\u{1b}b"), "a\\x1bb"); + } + + #[test] + fn sanitize_inline_escapes_osc_sequence_including_bel() { + // ESC ] 0 ; x BEL — an OSC window-title sequence. + let input = "evil\u{1b}]0;pwned\u{7}title"; + let expected = "evil\\x1b]0;pwned\\x07title"; + assert_eq!(sanitize_inline(input), expected); + } + + #[test] + fn sanitize_inline_escapes_c1_csi() { + // U+009B is the single-byte CSI introducer in the C1 control range. + assert_eq!(sanitize_inline("a\u{9b}b"), "a\\x9bb"); + } + + #[test] + fn sanitize_inline_escapes_bidi_controls() { + assert_eq!(sanitize_inline("safe\u{061c}txt"), "safe\\u{61c}txt"); + assert_eq!(sanitize_inline("safe\u{200e}txt"), "safe\\u{200e}txt"); + assert_eq!(sanitize_inline("safe\u{200f}txt"), "safe\\u{200f}txt"); + assert_eq!(sanitize_inline("safe\u{202e}txt"), "safe\\u{202e}txt"); + assert_eq!(sanitize_inline("safe\u{2066}txt"), "safe\\u{2066}txt"); + } + + #[test] + fn sanitize_inline_escapes_del() { + assert_eq!(sanitize_inline("a\u{7f}b"), "a\\x7fb"); + } + + #[test] + fn sanitize_inline_escapes_backslash_tab_cr_lf() { + assert_eq!(sanitize_inline("a\\b\tc\rd\ne"), "a\\\\b\\tc\\rd\\ne"); + } + + #[test] + fn sanitize_inline_passes_through_plain_ascii_and_emoji() { + let input = "Fix auth redirect 🎉 — done!"; + assert_eq!(sanitize_inline(input), input); + } + + #[test] + fn sanitize_block_preserves_real_newlines_but_escapes_control_chars() { + let input = "line one\nline\u{1b}two\nline three"; + let expected = "line one\nline\\x1btwo\nline three"; + assert_eq!(sanitize_block(input), expected); + } + + #[test] + fn sanitize_block_still_escapes_backslash_tab_cr() { + assert_eq!(sanitize_block("a\\b\tc\rd"), "a\\\\b\\tc\\rd"); + } + #[test] fn narrow_tree_lines_fit_terminal_width_for_deep_hierarchies() { let width = 80; diff --git a/src/cli/tui_render.rs b/src/cli/tui_render.rs index 6b71200..d7a90fe 100644 --- a/src/cli/tui_render.rs +++ b/src/cli/tui_render.rs @@ -1,4 +1,4 @@ -use crate::cli::render::truncate_with_ellipsis; +use crate::cli::render::{sanitize_inline, truncate_with_ellipsis}; use crate::cli::style; use crate::cli::terminal::resolve_width; use crate::output::{err_envelope, ok_envelope}; @@ -249,7 +249,7 @@ fn render_board_view(data: &TuiFrameData, width: usize) -> Vec { } fn render_board_card(task: &Task) -> String { - let title = truncate_with_ellipsis(&task.title, 18); + let title = truncate_with_ellipsis(&sanitize_inline(&task.title), 18); format!( "{} {} {} {}", style::task_id(&task.id), @@ -283,7 +283,10 @@ fn render_inspector(data: &TuiFrameData, width: usize) -> Vec { lines.push(format!("id={}", style::task_id(&task.id))); lines.push(format!( "title={}", - truncate_with_ellipsis(&task.title, width.saturating_sub(8).max(12)) + truncate_with_ellipsis( + &sanitize_inline(&task.title), + width.saturating_sub(8).max(12) + ) )); lines.push(format!( "status={} kind={} priority={} planning={}", @@ -294,7 +297,10 @@ fn render_inspector(data: &TuiFrameData, width: usize) -> Vec { )); lines.push(format!( "assignee={} parent={} labels={}", - task.assignee.as_deref().unwrap_or("unassigned"), + task.assignee + .as_deref() + .map(sanitize_inline) + .unwrap_or_else(|| "unassigned".to_string()), task.parent_id.as_deref().unwrap_or("-"), labels )); @@ -406,9 +412,9 @@ fn render_table_row(task: &Task, selected: bool, title_width: usize) -> String { marker, task.id, type_pill(task.kind), - truncate_with_ellipsis(&task.title, title_width), + truncate_with_ellipsis(&sanitize_inline(&task.title), title_width), status_pill(task.status), - truncate_with_ellipsis(assignee, 12), + truncate_with_ellipsis(&sanitize_inline(assignee), 12), priority_pill(task.priority), spec_pill(task), ) diff --git a/src/cli/watch.rs b/src/cli/watch.rs index 936a719..df139df 100644 --- a/src/cli/watch.rs +++ b/src/cli/watch.rs @@ -2,7 +2,7 @@ use crate::app::service::TasqueService; use crate::app::service_types::ListFilter; use crate::cli::render::{ TreeRenderOptions, format_meta_badge, format_status, format_status_text, render_task_tree, - status_to_string, truncate_with_ellipsis, + sanitize_inline, status_to_string, truncate_with_ellipsis, }; use crate::cli::style; use crate::cli::terminal::{Density, resolve_density, resolve_width}; @@ -467,7 +467,7 @@ fn render_flat_tasks(tasks: &[Task], width: usize) -> Vec { "{} {} {}", status, style::task_id(&task.id), - truncate_with_ellipsis(&task.title, title_width) + truncate_with_ellipsis(&sanitize_inline(&task.title), title_width) )); lines.push(format!(" {}", meta)); } else { @@ -475,7 +475,7 @@ fn render_flat_tasks(tasks: &[Task], width: usize) -> Vec { "{} {} {} {}", status, style::task_id(&task.id), - task.title, + sanitize_inline(&task.title), meta )); } diff --git a/src/skills/mod.rs b/src/skills/mod.rs index 598e796..4558874 100644 --- a/src/skills/mod.rs +++ b/src/skills/mod.rs @@ -430,9 +430,6 @@ fn resolve_managed_skill_source_directory( if let Ok(dir) = env::var("TSQ_SKILLS_DIR") { roots.push(normalize_directory(PathBuf::from(dir), &default_home)?); } - if let Ok(cwd) = env::current_dir() { - roots.push(cwd.join("SKILLS")); - } if let Ok(exe_path) = env::current_exe() && let Some(exe_dir) = exe_path.parent() { diff --git a/tests/render_sanitize.rs b/tests/render_sanitize.rs new file mode 100644 index 0000000..910207d --- /dev/null +++ b/tests/render_sanitize.rs @@ -0,0 +1,200 @@ +mod common; + +use common::{create_task_with_args, init_repo, run_cli, run_json}; +use serde_json::Value; + +// ESC ] 0 ; pwned BEL — an OSC window-title rewrite sequence, the canonical +// terminal-injection payload. `\u{1b}` is ESC (0x1b), `\u{7}` is BEL (0x07). +const EVIL_TITLE: &str = "evil\u{1b}]0;pwned\u{7}title"; +const ESC_BYTE: u8 = 0x1b; +const BEL_BYTE: u8 = 0x07; + +fn assert_no_raw_esc(output: &str) { + // Piped stdout from these commands must never contain a literal, + // unescaped ESC byte — style.rs disables ANSI color when stdout is not + // a TTY (see `style::use_color`), so any ESC byte here must have come + // from user-controlled task data, not from styling. + assert!( + !output.as_bytes().contains(&ESC_BYTE), + "human output contained a raw ESC byte: {:?}", + output + ); + assert!( + !output.as_bytes().contains(&BEL_BYTE), + "human output contained a raw BEL byte: {:?}", + output + ); + assert!( + output.contains("\\x1b"), + "expected the escaped form \\x1b to be present: {:?}", + output + ); +} + +#[test] +fn human_show_output_escapes_control_sequences_in_title() { + let repo = common::make_repo(); + init_repo(repo.path()); + let id = create_task_with_args(repo.path(), EVIL_TITLE, &["--force"]); + + let result = run_cli(repo.path(), ["show", &id]); + + assert_eq!(result.code, 0, "stderr:\n{}", result.stderr); + assert_no_raw_esc(&result.stdout); +} + +#[test] +fn json_show_output_preserves_raw_control_sequences_in_title() { + let repo = common::make_repo(); + init_repo(repo.path()); + let id = create_task_with_args(repo.path(), EVIL_TITLE, &["--force"]); + + let result = run_json(repo.path(), ["show", &id]); + + assert_eq!(result.cli.code, 0, "stderr:\n{}", result.cli.stderr); + let title = result + .envelope + .get("data") + .and_then(|data| data.get("task")) + .and_then(|task| task.get("title")) + .and_then(Value::as_str) + .expect("expected data.task.title"); + assert_eq!( + title, EVIL_TITLE, + "--json must preserve the original bytes for machine consumers" + ); +} + +#[test] +fn human_find_output_escapes_control_sequences_in_title() { + let repo = common::make_repo(); + init_repo(repo.path()); + create_task_with_args(repo.path(), EVIL_TITLE, &["--force"]); + + let result = run_cli(repo.path(), ["find", "open"]); + + assert_eq!(result.code, 0, "stderr:\n{}", result.stderr); + assert_no_raw_esc(&result.stdout); +} + +#[test] +fn human_show_output_escapes_control_sequences_in_description() { + let repo = common::make_repo(); + init_repo(repo.path()); + let evil_description = format!("body{}", EVIL_TITLE); + let id = create_task_with_args( + repo.path(), + "Task with evil description", + &["--force", "--description", &evil_description], + ); + + let result = run_cli(repo.path(), ["show", &id]); + + assert_eq!(result.code, 0, "stderr:\n{}", result.stderr); + assert_no_raw_esc(&result.stdout); +} + +#[test] +fn json_show_output_preserves_raw_control_sequences_in_description() { + let repo = common::make_repo(); + init_repo(repo.path()); + let evil_description = format!("body{}", EVIL_TITLE); + let id = create_task_with_args( + repo.path(), + "Task with evil description", + &["--force", "--description", &evil_description], + ); + + let result = run_json(repo.path(), ["show", &id]); + + assert_eq!(result.cli.code, 0, "stderr:\n{}", result.cli.stderr); + let description = result + .envelope + .get("data") + .and_then(|data| data.get("task")) + .and_then(|task| task.get("description")) + .and_then(Value::as_str) + .expect("expected data.task.description"); + assert_eq!(description, evil_description); +} + +#[test] +fn human_note_output_escapes_control_sequences() { + let repo = common::make_repo(); + init_repo(repo.path()); + let id = create_task_with_args(repo.path(), "Task for note test", &["--force"]); + let evil_note = format!("note{}", EVIL_TITLE); + + let result = run_cli(repo.path(), ["note", &id, &evil_note]); + + assert_eq!(result.code, 0, "stderr:\n{}", result.stderr); + assert_no_raw_esc(&result.stdout); + + let list_result = run_cli(repo.path(), ["notes", &id]); + assert_eq!(list_result.code, 0, "stderr:\n{}", list_result.stderr); + assert_no_raw_esc(&list_result.stdout); +} + +#[test] +fn json_note_output_preserves_raw_control_sequences() { + let repo = common::make_repo(); + init_repo(repo.path()); + let id = create_task_with_args(repo.path(), "Task for note json test", &["--force"]); + let evil_note = format!("note{}", EVIL_TITLE); + + let result = run_json(repo.path(), ["note", &id, &evil_note]); + + assert_eq!(result.cli.code, 0, "stderr:\n{}", result.cli.stderr); + let text = result + .envelope + .get("data") + .and_then(|data| data.get("note")) + .and_then(|note| note.get("text")) + .and_then(Value::as_str) + .expect("expected data.note.text"); + assert_eq!(text, evil_note); +} + +#[test] +fn human_spec_show_output_escapes_control_sequences() { + let repo = common::make_repo(); + init_repo(repo.path()); + let id = create_task_with_args(repo.path(), "Task for spec test", &["--force"]); + let evil_spec = format!("# Context\nbody{}\nmore text", EVIL_TITLE); + + let attach = run_cli(repo.path(), ["spec", &id, "--text", &evil_spec]); + assert_eq!(attach.code, 0, "stderr:\n{}", attach.stderr); + + let show = run_cli(repo.path(), ["spec", &id, "--show"]); + assert_eq!(show.code, 0, "stderr:\n{}", show.stderr); + assert_no_raw_esc(&show.stdout); + // Real newlines from the spec body must still render as actual line + // breaks, not be collapsed into a single escaped line. + assert!( + show.stdout.contains("# Context\n"), + "expected real newlines to be preserved in spec content:\n{}", + show.stdout + ); +} + +#[test] +fn json_spec_content_preserves_raw_control_sequences() { + let repo = common::make_repo(); + init_repo(repo.path()); + let id = create_task_with_args(repo.path(), "Task for spec json test", &["--force"]); + let evil_spec = format!("# Context\nbody{}\nmore text", EVIL_TITLE); + + let attach = run_cli(repo.path(), ["spec", &id, "--text", &evil_spec]); + assert_eq!(attach.code, 0, "stderr:\n{}", attach.stderr); + + let show = run_json(repo.path(), ["spec", &id, "--show"]); + assert_eq!(show.cli.code, 0, "stderr:\n{}", show.cli.stderr); + let content = show + .envelope + .get("data") + .and_then(|data| data.get("spec")) + .and_then(|spec| spec.get("content")) + .and_then(Value::as_str) + .expect("expected data.spec.content"); + assert_eq!(content, evil_spec); +} diff --git a/tests/skills_refresh.rs b/tests/skills_refresh.rs index db88113..387e259 100644 --- a/tests/skills_refresh.rs +++ b/tests/skills_refresh.rs @@ -100,3 +100,76 @@ fn skills_refresh_json_wires_env_and_creates_no_repo_state() { "skills refresh must not create .tasque in cwd" ); } + +/// Regression test for the CWD-as-skill-source injection vector: a +/// `SKILLS/tasque/SKILL.md` sitting in the process's current working +/// directory must never be treated as a trusted refresh source, even when +/// `TSQ_SKILLS_DIR` is unset. Refresh must fall back to the trusted +/// exe-relative / embedded source instead of the untrusted CWD payload. +#[cfg(not(target_os = "windows"))] +#[test] +fn skills_refresh_ignores_untrusted_cwd_skills_source() { + let temp_root = Builder::new() + .prefix("tsq-skills-refresh-cwd-trust-test-") + .tempdir() + .expect("temp dir"); + let temp_path = temp_root.path(); + + // Attacker-controlled CWD carrying a malicious managed-looking skill. + let attacker_cwd = temp_path.join("attacker-cwd"); + let attacker_skill_dir = attacker_cwd.join("SKILLS").join("tasque"); + fs::create_dir_all(&attacker_skill_dir).expect("create attacker skill dir"); + fs::write( + attacker_skill_dir.join("SKILL.md"), + "\n# Malicious Tasque Skill\nINJECTED-BY-CWD-TEST\n", + ) + .expect("write attacker SKILL.md"); + + // A pre-existing managed install at the Claude target that refresh + // should update from a trusted source only. + let home_dir = temp_path.join("home"); + let codex_home = temp_path.join("codex-home"); + let claude_skill_dir = home_dir.join(".claude").join("skills").join("tasque"); + fs::create_dir_all(&claude_skill_dir).expect("create claude skill dir"); + fs::write( + claude_skill_dir.join("SKILL.md"), + "\n# Old Tasque Skill\n", + ) + .expect("write pre-existing managed SKILL.md"); + fs::create_dir_all(&codex_home).expect("create codex home dir"); + + let output = Command::new(tsq_bin()) + .args(["skills", "refresh", "--json"]) + .current_dir(&attacker_cwd) + .env("HOME", &home_dir) + .env("USERPROFILE", &home_dir) + .env("CODEX_HOME", &codex_home) + .env_remove("TSQ_SKILLS_DIR") + .output() + .expect("failed executing tsq binary"); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + let old_placeholder = "\n# Old Tasque Skill\n"; + assert!( + output.status.success(), + "expected refresh to succeed\nstdout:\n{}\nstderr:\n{}", + stdout, + stderr + ); + + let refreshed = fs::read_to_string(claude_skill_dir.join("SKILL.md")) + .expect("expected SKILL.md to still exist after refresh"); + assert_ne!( + refreshed, old_placeholder, + "managed SKILL.md should be refreshed from the trusted source, not left as the pre-existing placeholder\nstdout:\n{}\nstderr:\n{}", + stdout, stderr + ); + assert!( + !refreshed.contains("INJECTED-BY-CWD-TEST"), + "untrusted CWD SKILLS/ payload must never reach a managed target\nstdout:\n{}\nstderr:\n{}\nrefreshed SKILL.md:\n{}", + stdout, + stderr, + refreshed + ); +} diff --git a/tests/sync_branch.rs b/tests/sync_branch.rs index 19f7736..1f28983 100644 --- a/tests/sync_branch.rs +++ b/tests/sync_branch.rs @@ -1,9 +1,51 @@ mod common; -use common::{make_repo, run_cli}; +use common::{git, git_output, init_git_repo_with_identity, make_repo, run_cli}; use serde_json::Value; use std::fs; +fn git_out(repo: &std::path::Path, args: &[&str]) -> String { + let output = git_output(repo, args); + assert!( + output.status.success(), + "git {:?} failed: {}", + args, + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8_lossy(&output.stdout).trim().to_string() +} + +/// Assert that events were migrated out of the main tree into the sync +/// worktree: root `events.jsonl` cleared, sync worktree `events.jsonl` +/// populated. Shared by the implicit- and explicit-migration tests. +fn assert_events_migrated_to_sync_worktree(root: &std::path::Path) { + let root_events = + fs::read_to_string(root.join(".tasque").join("events.jsonl")).expect("root events"); + assert!( + root_events.is_empty(), + "expected main-tree events cleared after local migration" + ); + let sync_events = fs::read_to_string( + root.join(".git") + .join("tsq-sync") + .join(".tasque") + .join("events.jsonl"), + ) + .expect("sync worktree events"); + assert!( + !sync_events.is_empty(), + "expected events present in sync worktree" + ); +} + +fn create_bare_origin(base: &std::path::Path) -> std::path::PathBuf { + let remote = base.join("origin.git"); + let remote_arg = remote.to_string_lossy().to_string(); + git(base, &["init", "--bare", remote_arg.as_str()]); + git(&remote, &["symbolic-ref", "HEAD", "refs/heads/main"]); + remote +} + #[test] fn sync_branch_requires_git_repo() { let repo = make_repo(); @@ -29,3 +71,186 @@ fn sync_branch_requires_git_repo() { .and_then(Value::as_str); assert_eq!(code, Some("GIT_NOT_AVAILABLE")); } + +const EXPECTED_MERGE_DRIVER: &str = "tsq merge-driver %O %A %B"; + +/// Fresh clones only receive the sync branch and its committed +/// `.gitattributes` (`merge=tasque-events`) via git; the merge driver +/// *definition* lives in local git config and does not travel with a clone. +/// Materializing the sync worktree on a clone (via any data command) must +/// configure the driver so `git merge`/`git pull` on a diverged sync branch +/// invokes `tsq merge-driver` instead of falling back to a default textual +/// 3-way merge on JSONL. +#[test] +fn clone_materializes_worktree_and_configures_merge_driver() { + let repo = make_repo(); + let base = repo.path(); + let source = base.join("source"); + fs::create_dir(&source).expect("source dir"); + + init_git_repo_with_identity(&source, Some("main")); + + let init = run_cli(&source, ["init"]); + assert_eq!(init.code, 0, "stderr: {}", init.stderr); + let create = run_cli(&source, ["create", "Driver clone task"]); + assert_eq!(create.code, 0, "stderr: {}", create.stderr); + + git(&source, &["add", ".tasque/config.json", ".gitattributes"]); + git(&source, &["commit", "-m", "seed main config"]); + + let remote = create_bare_origin(base); + let remote_arg = remote.to_string_lossy().to_string(); + git(&source, &["remote", "add", "origin", remote_arg.as_str()]); + git(&source, &["push", "origin", "HEAD:main"]); + git(&source, &["push", "origin", "tsq-sync"]); + + let clone = base.join("clone"); + let clone_arg = clone.to_string_lossy().to_string(); + git(base, &["clone", remote_arg.as_str(), clone_arg.as_str()]); + + // Sanity: a fresh clone has no local merge driver config at all. + let pre_check = git_output(&clone, &["config", "--get", "merge.tasque-events.driver"]); + assert!( + !pre_check.status.success(), + "expected no merge driver config before the worktree materializes" + ); + + // First data command in the clone materializes the sync worktree + // (cold `ensure_worktree` path in `resolve_effective_root`). + let list = run_cli(&clone, ["find", "open", "--json"]); + assert_eq!(list.code, 0, "stderr: {}", list.stderr); + assert!( + list.stdout.contains("Driver clone task"), + "expected cloned repo to read remote sync task:\n{}", + list.stdout + ); + + let driver = git_out(&clone, &["config", "--get", "merge.tasque-events.driver"]); + assert_eq!( + driver, EXPECTED_MERGE_DRIVER, + "expected merge driver to be configured after worktree materializes" + ); + + // `tsq sync` heals a clone whose worktree was materialized on a + // pre-fix binary: manually unset the driver, then verify `tsq sync` + // restores it. + git(&clone, &["config", "--unset", "merge.tasque-events.driver"]); + let missing = git_output(&clone, &["config", "--get", "merge.tasque-events.driver"]); + assert!( + !missing.status.success(), + "expected merge driver config to be unset before sync" + ); + + let sync_result = run_cli(&clone, ["sync", "--no-push"]); + assert_eq!(sync_result.code, 0, "stderr: {}", sync_result.stderr); + + let restored = git_out(&clone, &["config", "--get", "merge.tasque-events.driver"]); + assert_eq!( + restored, EXPECTED_MERGE_DRIVER, + "expected `tsq sync` to re-ensure the merge driver config" + ); +} + +/// Build a legacy repo: main-tree `.tasque` data (no `sync_branch` in +/// config), then git-init it and point `origin` at a nonexistent path so +/// any push fails. +fn make_legacy_repo_with_unreachable_origin( + base: &std::path::Path, + title: &str, +) -> std::path::PathBuf { + let root = base.join("repo"); + fs::create_dir(&root).expect("repo dir"); + + let init = run_cli(&root, ["init"]); + assert_eq!(init.code, 0, "stderr: {}", init.stderr); + let create = run_cli(&root, ["create", title]); + assert_eq!(create.code, 0, "stderr: {}", create.stderr); + + let config_text = fs::read_to_string(root.join(".tasque").join("config.json")).expect("config"); + let config: Value = serde_json::from_str(&config_text).expect("config must be valid JSON"); + assert!( + config.get("sync_branch").is_none(), + "expected legacy config without sync_branch:\n{}", + config_text + ); + + init_git_repo_with_identity(&root, Some("main")); + let missing_remote = base.join("missing-origin.git"); + let missing_remote_arg = missing_remote.to_string_lossy().to_string(); + git( + &root, + &["remote", "add", "origin", missing_remote_arg.as_str()], + ); + + root +} + +/// Implicit migration (any data command in a legacy repo) must not hard-fail +/// a read path when the push to origin fails: push is best-effort with a +/// stderr warning, and the main-tree events are cleared once the local +/// worktree commit succeeds. +#[test] +fn implicit_migration_survives_unreachable_origin_with_warning() { + let repo = make_repo(); + let base = repo.path(); + let root = make_legacy_repo_with_unreachable_origin(base, "Offline implicit task"); + + let list = run_cli(&root, ["find", "open", "--json"]); + assert_eq!( + list.code, 0, + "expected read command to succeed despite failed push\nstdout:\n{}\nstderr:\n{}", + list.stdout, list.stderr + ); + let envelope: Value = serde_json::from_str(list.stdout.trim()).expect("json envelope"); + assert_eq!(envelope.get("ok").and_then(Value::as_bool), Some(true)); + assert!( + list.stdout.contains("Offline implicit task"), + "expected migrated task in output:\n{}", + list.stdout + ); + assert!( + list.stderr + .contains("tsq: warning: migrated events to sync branch but push to 'origin' failed"), + "expected best-effort push warning on stderr:\n{}", + list.stderr + ); + + assert_events_migrated_to_sync_worktree(&root); +} + +/// Explicit `tsq migrate` keeps push failures fatal and preserves the original +/// main-tree events when the required push fails. The sync worktree commit has +/// landed locally, so a later successful migrate can dedupe and clear safely. +#[test] +fn explicit_migrate_fails_on_unreachable_origin_after_local_migration() { + let repo = make_repo(); + let base = repo.path(); + let root = make_legacy_repo_with_unreachable_origin(base, "Offline explicit task"); + + let migrate = run_cli(&root, ["migrate", "--json"]); + assert_eq!( + migrate.code, 2, + "expected explicit migrate to fail with storage/IO error when push fails\nstdout:\n{}\nstderr:\n{}", + migrate.stdout, migrate.stderr + ); + let envelope: Value = serde_json::from_str(migrate.stdout.trim()).expect("json envelope"); + assert_eq!(envelope.get("ok").and_then(Value::as_bool), Some(false)); + + let root_events = + fs::read_to_string(root.join(".tasque").join("events.jsonl")).expect("root events"); + assert!( + !root_events.is_empty(), + "expected main-tree events preserved after required push failure" + ); + let sync_events = fs::read_to_string( + root.join(".git") + .join("tsq-sync") + .join(".tasque") + .join("events.jsonl"), + ) + .expect("sync worktree events"); + assert!( + !sync_events.is_empty(), + "expected events present in sync worktree" + ); +} diff --git a/tui-opentui/README.md b/tui-opentui/README.md index 7ee1a0a..9616b73 100644 --- a/tui-opentui/README.md +++ b/tui-opentui/README.md @@ -1,38 +1,70 @@ # Tasque OpenTUI (Read-Only) -OpenTUI React app for Tasque with tabbed views: -- `Tasks` -- `Epics` -- `Board` +OpenTUI React app for Tasque with four tabs: +- `Tasks`: task tree with planning/spec/updated metadata +- `Epics`: one progress row per epic (done/in-progress/open counts) +- `Board`: open / in-progress / done lanes +- `Deps`: dependency tree of the selected task The app is read-only. It does not mutate Tasque data. -## Data sources (fallback order) -1. `tsq-rs list --json` -2. `tsq list --json` -3. `.tasque/state.json` -4. `.tasque/events.jsonl` +## Data source + +There is a single data source: the TUI spawns the `tsq` CLI with `--json` and +parses the standard envelope. There is no fallback to `.tasque/state.json` or +`.tasque/events.jsonl`. + +Commands used: +- `tsq --json watch --once --status [--assignee ]` — task list (every refresh) +- `tsq --json deps --direction both --depth 4` — dependency tree (Deps tab) +- `tsq --json spec --show` — spec content (spec dialog) + +## Environment variables + +- `TSQ_TUI_BIN`: path to the `tsq` binary (default: `tsq` on `PATH`) +- `TSQ_TUI_INTERVAL`: refresh interval in seconds, clamped to 1-60 (default: `2`) +- `TSQ_TUI_STATUS`: status CSV passed to `watch --once` and used for the first + filter preset (default: `open,in_progress,blocked,deferred,closed,canceled`; + tabs filter client-side, so narrowing this hides tasks from Board/Epics too) +- `TSQ_TUI_ASSIGNEE`: filter tasks by assignee +- `TSQ_TUI_VIEW`: initial tab; one of `tasks`, `epics`, `board`, `deps` (default: `tasks`) ## Spec state -Every row/card/details pane includes spec state: -- `attached` -- `missing` -- `invalid` -When metadata exists, spec state is validated against file existence and fingerprint. +Every row/card includes spec state derived from task metadata: +- `attached`: `spec_path` and `spec_fingerprint` both present +- `missing`: neither present +- `invalid`: only one present ## Run + ```bash cd tui-opentui bun install bun run start ``` +## Test + +```bash +bun test # unit tests; contract test skips +TSQ_CONTRACT_BIN=/abs/path/to/tsq bun test # also runs real-binary contract test +``` + ## Keyboard -- `q` or `Esc`: quit (uses `renderer.destroy()`) -- `Ctrl+C`: quit (uses `renderer.destroy()`) -- `Tab` / `Left` / `Right`: switch tabs + +- `q` or `Esc`: quit +- `Ctrl+C`: quit +- `Tab`: next tab +- `1` / `2` / `3` / `4`: jump to Tasks / Epics / Board / Deps - `Up` / `Down` or `j` / `k`: move selection -- `h` / `l`: switch board lane (Board tab) +- `h` / `l` or `Left` / `Right`: switch board lane (Board tab only) +- `f`: cycle filter preset +- `Enter`: open spec dialog for the selected task (when a spec is attached) - `r`: refresh now -- `p`: pause/resume auto-refresh + +Spec dialog: +- `Esc` / `Enter` / `q`: close +- `j` / `k` or `Up` / `Down`: scroll +- `PgUp` / `PgDn`: page +- `Home` / `End`: jump to start/end diff --git a/tui-opentui/package.json b/tui-opentui/package.json index f8027d3..e9fc242 100644 --- a/tui-opentui/package.json +++ b/tui-opentui/package.json @@ -6,7 +6,8 @@ "scripts": { "dev": "bun run --watch src/index.tsx", "start": "bun run src/index.tsx", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test": "bun test" }, "devDependencies": { "@types/bun": "latest", diff --git a/tui-opentui/src/data.ts b/tui-opentui/src/data.ts index c751aa5..eee8eed 100644 --- a/tui-opentui/src/data.ts +++ b/tui-opentui/src/data.ts @@ -33,7 +33,7 @@ export interface DependencyNode { children: DependencyNode[]; } -const DEFAULT_STATUS = "open,in_progress"; +const DEFAULT_STATUS = "open,in_progress,blocked,deferred,closed,canceled"; export function readConfigFromEnv(): TuiConfig { const intervalRaw = process.env.TSQ_TUI_INTERVAL ?? "2"; @@ -56,58 +56,115 @@ export function readConfigFromEnv(): TuiConfig { }; } -export function fetchTasks(config: TuiConfig): DataSnapshot { - const args = ["--json", "list"]; - if (config.assignee) { - args.push("--assignee", config.assignee); - } +export interface TsqSpawnResult { + exitCode: number; + stdout: string; + stderr: string; +} - const subprocess = Bun.spawnSync([config.tsqBin, ...args], { +export async function runTsq( + argv: string[], + options: { timeoutMs?: number } = {}, +): Promise { + const subprocess = Bun.spawn(argv, { stdin: "ignore", stdout: "pipe", stderr: "pipe", + timeout: options.timeoutMs ?? 10_000, + killSignal: "SIGKILL", }); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(subprocess.stdout).text().catch(() => ""), + new Response(subprocess.stderr).text().catch(() => ""), + subprocess.exited, + ]); + + return { exitCode: exitCode ?? -1, stdout, stderr }; +} + +export function spawnWarning(bin: string, error: unknown): string { + const message = + error instanceof Error && error.message ? error.message : "unknown error"; + return `Failed to run ${bin}: ${message}`; +} + +export async function fetchTasks(config: TuiConfig): Promise { + const args = ["--json", "watch", "--once", "--status", config.statusCsv]; + if (config.assignee) { + args.push("--assignee", config.assignee); + } + + let result: TsqSpawnResult; + try { + result = await runTsq([config.tsqBin, ...args]); + } catch (error) { + return { + fetchedAt: new Date().toISOString(), + tasks: [], + warning: spawnWarning(config.tsqBin, error), + }; + } + const fetchedAt = new Date().toISOString(); - if (subprocess.exitCode !== 0) { - const stderr = bytesToString(subprocess.stderr).trim(); + if (result.exitCode !== 0) { return { fetchedAt, tasks: [], - warning: stderr || `Failed to run ${config.tsqBin} ${args.join(" ")}`, + warning: + result.stderr.trim() || + `Failed to run ${config.tsqBin} ${args.join(" ")}`, }; } - const stdout = bytesToString(subprocess.stdout); + const parsed = parseTasksEnvelope(result.stdout); + return { + fetchedAt, + tasks: parsed.tasks, + warning: parsed.warning, + }; +} - let payload: ListEnvelope; +export function parseTasksEnvelope(stdout: string): { + tasks: TasqueTask[]; + warning?: string; +} { + let payload: unknown; try { - payload = JSON.parse(stdout) as ListEnvelope; + payload = JSON.parse(stdout); } catch { return { - fetchedAt, tasks: [], - warning: "Unable to parse JSON output from tsq list", + warning: "Unable to parse JSON output from tsq watch --once", }; } - if (!payload.ok) { + if (!payload || typeof payload !== "object") { return { - fetchedAt, tasks: [], - warning: payload.error?.message ?? "tsq list returned an error", + warning: "Unexpected payload from tsq watch --once", }; } + const envelope = payload as ListEnvelope; - return { - fetchedAt, - tasks: payload.data?.tasks ?? [], - }; -} + if (typeof envelope.ok !== "boolean") { + return { tasks: [], warning: "Unexpected payload from tsq watch --once" }; + } + + if (!envelope.ok) { + return { + tasks: [], + warning: envelope.error?.message ?? "tsq watch returned an error", + }; + } + + const tasks = envelope.data?.tasks; + if (tasks !== undefined && !Array.isArray(tasks)) { + return { tasks: [], warning: "Task payload missing tasks array" }; + } -function bytesToString(buffer: Uint8Array): string { - return new TextDecoder().decode(buffer); + return { tasks: tasks ?? [] }; } interface DepEnvelope { @@ -121,48 +178,96 @@ interface DepEnvelope { }; } -export function fetchDependencyTree( +export async function fetchDependencyTree( tsqBin: string, taskId: string, -): { root?: DependencyNode; warning?: string } { - const subprocess = Bun.spawnSync( - [ +): Promise<{ root?: DependencyNode; warning?: string }> { + let result: TsqSpawnResult; + try { + result = await runTsq([ tsqBin, "--json", - "dep", - "tree", + "deps", taskId, "--direction", "both", "--depth", "4", - ], - { - stdin: "ignore", - stdout: "pipe", - stderr: "pipe", - }, - ); - - if (subprocess.exitCode !== 0) { - const stderr = bytesToString(subprocess.stderr).trim(); + ]); + } catch (error) { + return { warning: spawnWarning(tsqBin, error) }; + } + + if (result.exitCode !== 0) { return { - warning: stderr || `Failed to run ${tsqBin} dep tree ${taskId}`, + warning: result.stderr.trim() || `Failed to run ${tsqBin} deps ${taskId}`, }; } - let payload: DepEnvelope; + return parseDependencyEnvelope(result.stdout); +} + +export function createInFlightGuard( + fetcher: () => Promise, +): () => Promise { + let inFlight = false; + return async () => { + if (inFlight) { + return undefined; + } + inFlight = true; + try { + return await fetcher(); + } finally { + inFlight = false; + } + }; +} + +export function createLatestGuard(): ( + fetcher: () => Promise, +) => Promise { + let sequence = 0; + return async (fetcher: () => Promise) => { + sequence += 1; + const ticket = sequence; + try { + const result = await fetcher(); + return ticket === sequence ? result : undefined; + } catch (error) { + if (ticket !== sequence) { + return undefined; + } + throw error; + } + }; +} + +export function parseDependencyEnvelope(stdout: string): { + root?: DependencyNode; + warning?: string; +} { + let payload: unknown; try { - payload = JSON.parse(bytesToString(subprocess.stdout)) as DepEnvelope; + payload = JSON.parse(stdout); } catch { - return { warning: "Unable to parse JSON output from tsq dep tree" }; + return { warning: "Unable to parse JSON output from tsq deps" }; + } + + if (!payload || typeof payload !== "object") { + return { warning: "Unexpected payload from tsq deps" }; + } + const envelope = payload as DepEnvelope; + + if (typeof envelope.ok !== "boolean") { + return { warning: "Unexpected payload from tsq deps" }; } - if (!payload.ok) { - return { warning: payload.error?.message ?? "tsq dep tree returned an error" }; + if (!envelope.ok) { + return { warning: envelope.error?.message ?? "tsq deps returned an error" }; } - const root = normalizeDependencyNode(payload.data?.root); + const root = normalizeDependencyNode(envelope.data?.root); if (!root) { return { warning: "Dependency tree payload missing root node" }; } diff --git a/tui-opentui/src/model.ts b/tui-opentui/src/model.ts index 0bae35a..ac32c16 100644 --- a/tui-opentui/src/model.ts +++ b/tui-opentui/src/model.ts @@ -156,34 +156,42 @@ export function boardColumns( return lanes; } -export function buildEpicProgress(tasks: TasqueTask[]): EpicProgress | null { +export function buildEpicProgressList(tasks: TasqueTask[]): EpicProgress[] { const epics = tasks.filter((task) => task.kind === "epic"); - const epic = epics.at(0); - if (!epic) { - return null; - } - const children = tasks.filter((task) => task.parent_id === epic.id); - - let done = 0; - let open = 0; - let inProgress = 0; - for (const child of children) { - if (child.status === "closed" || child.status === "canceled") { - done += 1; - } else if (child.status === "in_progress" || child.status === "blocked") { - inProgress += 1; - } else { - open += 1; + const childrenByParent = new Map(); + for (const task of tasks) { + if (!task.parent_id) { + continue; } + const children = childrenByParent.get(task.parent_id) ?? []; + children.push(task); + childrenByParent.set(task.parent_id, children); } - return { - epic, - children, - done, - open, - inProgress, - }; + return epics.map((epic) => { + const children = childrenByParent.get(epic.id) ?? []; + + let done = 0; + let open = 0; + let inProgress = 0; + for (const child of children) { + if (child.status === "closed" || child.status === "canceled") { + done += 1; + } else if (child.status === "in_progress" || child.status === "blocked") { + inProgress += 1; + } else { + open += 1; + } + } + + return { + epic, + children, + done, + open, + inProgress, + }; + }); } export function normalizeTab(value: string | undefined): TabKey { diff --git a/tui-opentui/src/tui-app.tsx b/tui-opentui/src/tui-app.tsx index 8ee868a..af63244 100644 --- a/tui-opentui/src/tui-app.tsx +++ b/tui-opentui/src/tui-app.tsx @@ -1,7 +1,10 @@ import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { + type DataSnapshot, type DependencyNode, + createInFlightGuard, + createLatestGuard, fetchDependencyTree, fetchTasks, readConfigFromEnv, @@ -11,7 +14,7 @@ import { type TabKey, type TasqueTask, boardColumns, - buildEpicProgress, + buildEpicProgressList, computeSummary, sortTasks, specState, @@ -38,8 +41,11 @@ export function App() { const dimensions = useTerminalDimensions(); const [tab, setTab] = useState(config.initialTab); - const [snapshot, setSnapshot] = useState(() => fetchTasks(config)); - const [warning, setWarning] = useState(snapshot.warning); + const [snapshot, setSnapshot] = useState({ + fetchedAt: "-", + tasks: [], + }); + const [warning, setWarning] = useState(); const [selectedByTab, setSelectedByTab] = useState({ tasks: 0, epics: 0, @@ -57,21 +63,38 @@ export function App() { const [filterIndex, setFilterIndex] = useState(0); const [dependencyRoot, setDependencyRoot] = useState(); const [dependencyWarning, setDependencyWarning] = useState(); + const refreshTasksRef = useRef<() => void>(() => {}); useEffect(() => { setFilterIndex((current) => Math.min(current, filterPresets.length - 1)); }, [filterPresets.length]); useEffect(() => { - const refresh = () => { - const next = fetchTasks(config); + let cancelled = false; + const guardedFetch = createInFlightGuard(() => fetchTasks(config)); + + const refresh = async () => { + const next = await guardedFetch(); + if (cancelled || !next) { + // Skipped poll (previous fetch still in flight) or effect torn down. + return; + } setSnapshot(next); setWarning(next.warning); }; - refresh(); - const timer = setInterval(refresh, config.intervalSeconds * 1000); - return () => clearInterval(timer); + refreshTasksRef.current = () => { + void refresh(); + }; + + void refresh(); + const timer = setInterval(() => { + void refresh(); + }, config.intervalSeconds * 1000); + return () => { + cancelled = true; + clearInterval(timer); + }; }, [config]); const allTasks = useMemo(() => sortTasks(snapshot.tasks), [snapshot.tasks]); @@ -81,18 +104,15 @@ export function App() { [activeFilter?.statuses, allTasks], ); const summary = useMemo(() => computeSummary(filteredTasks), [filteredTasks]); - const epicProgress = useMemo(() => buildEpicProgress(filteredTasks), [filteredTasks]); + const epicProgressList = useMemo(() => buildEpicProgressList(filteredTasks), [filteredTasks]); const board = useMemo(() => boardColumns(filteredTasks), [filteredTasks]); const visibleTasks = useMemo(() => { if (tab === "epics") { - if (!epicProgress) { - return [] as TasqueTask[]; - } - return epicProgress.children.length > 0 ? epicProgress.children : [epicProgress.epic]; + return epicProgressList.map((progress) => progress.epic); } return filteredTasks; - }, [tab, filteredTasks, epicProgress]); + }, [tab, filteredTasks, epicProgressList]); const treeLines = useMemo(() => buildTreeLines(filteredTasks), [filteredTasks]); const selectedIndex = selectedByTab[tab]; @@ -125,19 +145,39 @@ export function App() { const tableRowBudget = Math.max(4, dimensions.height - 18); const specDialogBodyRows = Math.max(6, dimensions.height - 21); + const latestDependencyFetch = useMemo(() => createLatestGuard(), []); + useEffect(() => { if (tab !== "deps") { return; } - if (!selectedTask?.id) { + const taskId = selectedTask?.id; + if (!taskId) { setDependencyRoot(undefined); setDependencyWarning(undefined); return; } - const dependency = fetchDependencyTree(config.tsqBin, selectedTask.id); - setDependencyRoot(dependency.root); - setDependencyWarning(dependency.warning); - }, [config.tsqBin, selectedTask?.id, tab]); + setDependencyRoot(undefined); + setDependencyWarning(undefined); + let cancelled = false; + // Debounce rapid selection changes; the latest-guard discards responses + // that resolve after a newer fetch has been issued. + const timer = setTimeout(() => { + void latestDependencyFetch(() => + fetchDependencyTree(config.tsqBin, taskId), + ).then((dependency) => { + if (cancelled || !dependency) { + return; + } + setDependencyRoot(dependency.root); + setDependencyWarning(dependency.warning); + }); + }, 150); + return () => { + cancelled = true; + clearTimeout(timer); + }; + }, [config.tsqBin, latestDependencyFetch, selectedTask?.id, tab]); useEffect(() => { if (rowCount === 0 || tab === "board") { @@ -161,18 +201,18 @@ export function App() { if (specState(task) !== "attached" || !task.spec_path) { return; } - const specPath = task.spec_path; setSpecDialog({ taskId: task.id, taskTitle: task.title, - specPath, + specPath: task.spec_path, lines: ["Loading spec..."], offset: 0, loading: true, }); - void readSpecLines(specPath).then((result) => { + void readSpecLines(config.tsqBin, task.id).then((result) => { setSpecDialog((current) => { - if (!current || current.taskId !== task.id || current.specPath !== specPath) { + // Stale check: apply only if the dialog is still open for this task. + if (!current || current.taskId !== task.id || current.specPath !== task.spec_path) { return current; } return { @@ -237,9 +277,7 @@ export function App() { } if (key.name === "r") { - const next = fetchTasks(config); - setSnapshot(next); - setWarning(next.warning); + refreshTasksRef.current(); return; } @@ -322,7 +360,10 @@ export function App() { }; }); }); - const itemBudget = tab === "tasks" ? Math.max(2, Math.floor(tableRowBudget / 2)) : tableRowBudget; + const itemBudget = + tab === "tasks" || tab === "epics" + ? Math.max(2, Math.floor(tableRowBudget / 2)) + : tableRowBudget; const [start, end] = visibleRange(selectedIndex, rowCount, itemBudget); return ( @@ -412,9 +453,8 @@ export function App() { {tab === "epics" ? ( ) : null} diff --git a/tui-opentui/src/tui-helpers.ts b/tui-opentui/src/tui-helpers.ts index bb7c17b..b335764 100644 --- a/tui-opentui/src/tui-helpers.ts +++ b/tui-opentui/src/tui-helpers.ts @@ -1,4 +1,4 @@ -import type { DependencyNode } from "./data"; +import { type DependencyNode, type TsqSpawnResult, runTsq, spawnWarning } from "./data"; import { TAB_ORDER, TASK_STATUS_ORDER, @@ -123,29 +123,103 @@ function buildAncestorPrefix(siblingTrail: boolean[]): string { } export async function readSpecLines( - specPath: string, + tsqBin: string, + taskId: string, ): Promise<{ lines: string[]; warning?: string }> { + let result: TsqSpawnResult; try { - const text = await Bun.file(specPath).text(); - if (text.length === 0) { - return { lines: ["(empty spec)"] }; + result = await runTsq([tsqBin, "--json", "spec", taskId, "--show"]); + } catch (error) { + return { lines: [], warning: spawnWarning(tsqBin, error) }; + } + + if (result.exitCode !== 0) { + if (result.stdout.trim()) { + const parsed = parseSpecEnvelopeResult(result.stdout); + if (parsed.kind === "envelope-error" || parsed.kind === "parse-error") { + return { lines: parsed.lines, warning: parsed.warning }; + } } return { - lines: text.replaceAll("\r\n", "\n").split("\n"), + lines: [], + warning: + result.stderr.trim() || `Failed to run ${tsqBin} spec ${taskId} --show`, }; - } catch (error) { + } + + return parseSpecEnvelope(result.stdout); +} + +type SpecEnvelopeResult = { + kind: "ok" | "envelope-error" | "parse-error"; + lines: string[]; + warning?: string; +}; + +export function parseSpecEnvelope(stdout: string): { + lines: string[]; + warning?: string; +} { + const parsed = parseSpecEnvelopeResult(stdout); + return { lines: parsed.lines, warning: parsed.warning }; +} + +function parseSpecEnvelopeResult(stdout: string): SpecEnvelopeResult { + let payload: unknown; + try { + payload = JSON.parse(stdout); + } catch { return { + kind: "parse-error", lines: [], - warning: `Failed to open spec: ${errorMessage(error)}`, + warning: "Unable to parse JSON output from tsq spec --show", + }; + } + + if (!payload || typeof payload !== "object") { + return { + kind: "parse-error", + lines: [], + warning: "Unexpected payload from tsq spec --show", + }; + } + const envelope = payload as Record; + + if (envelope.ok !== true) { + const error = + envelope.error && typeof envelope.error === "object" + ? (envelope.error as Record) + : undefined; + const message = + typeof error?.message === "string" ? error.message : undefined; + return { + kind: "envelope-error", + lines: [], + warning: message ?? "tsq spec --show returned an error", + }; + } + + const data = + envelope.data && typeof envelope.data === "object" + ? (envelope.data as Record) + : undefined; + const spec = + data?.spec && typeof data.spec === "object" + ? (data.spec as Record) + : undefined; + const content = typeof spec?.content === "string" ? spec.content : undefined; + if (content === undefined) { + return { + kind: "envelope-error", + lines: [], + warning: "Spec payload missing content", }; } -} -function errorMessage(error: unknown): string { - if (error instanceof Error && error.message) { - return error.message; + if (content.length === 0) { + return { kind: "ok", lines: ["(empty spec)"] }; } - return "unknown error"; + return { kind: "ok", lines: content.replace(/\r\n?/g, "\n").split("\n") }; } export function buildFilterPresets(statusCsv: string): FilterPreset[] { diff --git a/tui-opentui/src/tui-views.tsx b/tui-opentui/src/tui-views.tsx index 4b96061..f512a3f 100644 --- a/tui-opentui/src/tui-views.tsx +++ b/tui-opentui/src/tui-views.tsx @@ -14,7 +14,6 @@ import { } from "./model"; import { THEME, - buildTableLayout, buildTreePrefix, flattenDependencyTree, formatUpdatedAt, @@ -24,10 +23,9 @@ import { renderMeter, specLabel, statusIcon, - tableHeader, treeDisplayId, } from "./tui-helpers"; -import type { SelectedByLane, SpecDialogState, TableLayout, TreeLine } from "./tui-types"; +import type { SelectedByLane, SpecDialogState, TreeLine } from "./tui-types"; export function TabChip({ tab, @@ -48,36 +46,34 @@ export function TabChip({ } export function EpicsView({ - tasks, + progressList, selectedTaskId, - epicProgress, width, }: { - tasks: TasqueTask[]; + progressList: EpicProgress[]; selectedTaskId?: string; - epicProgress: EpicProgress | null; width: number; }) { - const layout = useMemo(() => buildTableLayout(width), [width]); + const titleWidth = Math.max(16, width - 48); return ( - {epicProgress ? ( - + {progressList.map(({ epic, children, done, inProgress, open }) => ( + - {epicProgress.epic.id} - {titleWithEllipsis(epicProgress.epic.title, 48)} - {renderMeter(epicProgress.done, epicProgress.children.length, 12)} - {epicProgress.done}/{epicProgress.children.length} + {statusIcon(epic.status)} + {epic.id} + {titleWithEllipsis(epic.title, titleWidth)} + {renderMeter(done, children.length, 12)} + {done}/{children.length} +
+ done {done} | in_progress {inProgress} | open {open}
- ) : null} - - {tableHeader(layout)} - - {tasks.map((task) => ( - ))} - {tasks.length === 0 ? ( + {progressList.length === 0 ? ( No epic tasks found @@ -86,38 +82,6 @@ export function EpicsView({ ); } -function TaskRow({ - task, - selected, - layout, -}: { - task: TasqueTask; - selected: boolean; - layout: TableLayout; -}) { - const spec = specState(task); - return ( - - - {statusIcon(task.status)} - {pad(task.id, layout.idWidth)} - {pad(kindLabel(task.kind), layout.typeWidth)} - - {pad(titleWithEllipsis(task.title, layout.titleWidth), layout.titleWidth)}{" "} - - - {pad(`P${task.priority}`, layout.priorityWidth)} - {layout.showSpec ? ( - <> - - - - ) : null} - - - ); -} - export function BoardView({ tasks, lane, diff --git a/tui-opentui/tests/async-guards.test.ts b/tui-opentui/tests/async-guards.test.ts new file mode 100644 index 0000000..8fb31cf --- /dev/null +++ b/tui-opentui/tests/async-guards.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "bun:test"; +import { createInFlightGuard, createLatestGuard } from "../src/data"; + +function deferred(): { promise: Promise; resolve: (value: T) => void } { + let resolve!: (value: T) => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +} + +describe("createInFlightGuard", () => { + it("skips a call while a fetch is in flight instead of queueing it", async () => { + let calls = 0; + const gate = deferred(); + const guarded = createInFlightGuard(() => { + calls += 1; + return gate.promise; + }); + + const first = guarded(); + const second = guarded(); + + expect(await second).toBeUndefined(); + expect(calls).toBe(1); + + gate.resolve("done"); + expect(await first).toBe("done"); + }); + + it("allows the next call after the previous one resolves", async () => { + let calls = 0; + const guarded = createInFlightGuard(async () => { + calls += 1; + return calls; + }); + + expect(await guarded()).toBe(1); + expect(await guarded()).toBe(2); + expect(calls).toBe(2); + }); + + it("releases the guard when the fetcher rejects", async () => { + let calls = 0; + const guarded = createInFlightGuard(async () => { + calls += 1; + if (calls === 1) { + throw new Error("boom"); + } + return "recovered"; + }); + + await expect(guarded()).rejects.toThrow("boom"); + expect(await guarded()).toBe("recovered"); + }); +}); + +describe("createLatestGuard", () => { + it("discards an older response that resolves after a newer request", async () => { + const guard = createLatestGuard(); + const slow = deferred(); + const fast = deferred(); + + const first = guard(() => slow.promise); + const second = guard(() => fast.promise); + + fast.resolve("new"); + expect(await second).toBe("new"); + + slow.resolve("old"); + expect(await first).toBeUndefined(); + }); + + it("passes through sequential responses", async () => { + const guard = createLatestGuard(); + expect(await guard(async () => "a")).toBe("a"); + expect(await guard(async () => "b")).toBe("b"); + }); +}); diff --git a/tui-opentui/tests/contract.test.ts b/tui-opentui/tests/contract.test.ts new file mode 100644 index 0000000..0000ed1 --- /dev/null +++ b/tui-opentui/tests/contract.test.ts @@ -0,0 +1,86 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { type TuiConfig, fetchDependencyTree, fetchTasks } from "../src/data"; +import { readSpecLines } from "../src/tui-helpers"; + +const bin = process.env.TSQ_CONTRACT_BIN; + +if (!bin) { + console.log( + "contract.test.ts: TSQ_CONTRACT_BIN not set; skipping real-binary contract tests", + ); +} + +const describeContract = bin ? describe : describe.skip; + +describeContract("tsq CLI contract", () => { + let dir = ""; + let previousCwd = ""; + + const run = (args: string[]): string => { + const subprocess = Bun.spawnSync([bin as string, ...args], { + cwd: dir, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + if (subprocess.exitCode !== 0) { + const stderr = new TextDecoder().decode(subprocess.stderr); + throw new Error(`tsq ${args.join(" ")} failed: ${stderr}`); + } + return new TextDecoder().decode(subprocess.stdout); + }; + + beforeAll(() => { + dir = mkdtempSync(join(tmpdir(), "tsq-contract-")); + previousCwd = process.cwd(); + run(["init", "--no-wizard"]); + run(["create", "epic demo", "--kind", "epic", "--force"]); // tsq-1 + run(["create", "child", "--parent", "tsq-1", "--force"]); // tsq-1.1 + run(["create", "blocker", "--force"]); // tsq-2 + run(["block", "tsq-1.1", "by", "tsq-2"]); + run(["spec", "tsq-1.1", "--text", "# hello contract"]); + // fetchTasks and friends spawn tsq without an explicit cwd, so run the + // suite from inside the initialized repo, mirroring the TUI launcher. + process.chdir(dir); + }); + + afterAll(() => { + if (previousCwd) { + process.chdir(previousCwd); + } + if (dir) { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("fetchTasks returns tasks via watch --once", async () => { + const config: TuiConfig = { + intervalSeconds: 2, + statusCsv: "open,in_progress,blocked,deferred,closed,canceled", + initialTab: "tasks", + tsqBin: bin as string, + }; + const snapshot = await fetchTasks(config); + expect(snapshot.warning).toBeUndefined(); + const ids = snapshot.tasks.map((task) => task.id); + expect(ids).toContain("tsq-1"); + expect(ids).toContain("tsq-1.1"); + expect(ids).toContain("tsq-2"); + }); + + it("fetchDependencyTree returns the root via the deps verb", async () => { + const result = await fetchDependencyTree(bin as string, "tsq-1.1"); + expect(result.warning).toBeUndefined(); + expect(result.root?.id).toBe("tsq-1.1"); + expect(result.root?.children.length).toBeGreaterThan(0); + }); + + it("readSpecLines returns spec content via spec --show", async () => { + const result = await readSpecLines(bin as string, "tsq-1.1"); + expect(result.warning).toBeUndefined(); + expect(result.lines[0]).toBe("# hello contract"); + }); +}); diff --git a/tui-opentui/tests/data.test.ts b/tui-opentui/tests/data.test.ts new file mode 100644 index 0000000..f3db590 --- /dev/null +++ b/tui-opentui/tests/data.test.ts @@ -0,0 +1,220 @@ +import { describe, expect, it } from "bun:test"; +import { parseDependencyEnvelope, parseTasksEnvelope, runTsq } from "../src/data"; +import { parseSpecEnvelope } from "../src/tui-helpers"; + +const sampleTask = { + id: "tsq-1", + kind: "task", + title: "demo", + status: "open", + priority: 2, + labels: [], + created_at: "2026-01-01T00:00:00.000Z", + updated_at: "2026-01-01T00:00:00.000Z", +}; + +describe("parseTasksEnvelope", () => { + it("returns tasks from a valid watch envelope", () => { + const stdout = JSON.stringify({ + ok: true, + data: { frame_ts: "2026-01-01T00:00:00.000Z", tasks: [sampleTask] }, + }); + const result = parseTasksEnvelope(stdout); + expect(result.warning).toBeUndefined(); + expect(result.tasks).toHaveLength(1); + expect(result.tasks[0]?.id).toBe("tsq-1"); + }); + + it("surfaces the error message from an ok:false envelope", () => { + const stdout = JSON.stringify({ + ok: false, + error: { code: "VALIDATION_ERROR", message: "bad filter" }, + }); + const result = parseTasksEnvelope(stdout); + expect(result.tasks).toEqual([]); + expect(result.warning).toBe("bad filter"); + }); + + it("warns on malformed JSON", () => { + const result = parseTasksEnvelope("not json"); + expect(result.tasks).toEqual([]); + expect(result.warning).toBe( + "Unable to parse JSON output from tsq watch --once", + ); + }); + + it("returns an empty task list when data is missing", () => { + const result = parseTasksEnvelope(JSON.stringify({ ok: true })); + expect(result.tasks).toEqual([]); + expect(result.warning).toBeUndefined(); + }); + + it("warns on a null payload", () => { + const result = parseTasksEnvelope("null"); + expect(result.tasks).toEqual([]); + expect(result.warning).toBe("Unexpected payload from tsq watch --once"); + }); + + it("warns on a non-object payload", () => { + const result = parseTasksEnvelope(JSON.stringify("oops")); + expect(result.tasks).toEqual([]); + expect(result.warning).toBe("Unexpected payload from tsq watch --once"); + }); + + it("warns on a non-boolean ok field", () => { + const result = parseTasksEnvelope( + JSON.stringify({ ok: "false", data: { tasks: [sampleTask] } }), + ); + expect(result.tasks).toEqual([]); + expect(result.warning).toBe("Unexpected payload from tsq watch --once"); + }); + + it("warns on a non-array tasks field", () => { + const result = parseTasksEnvelope( + JSON.stringify({ ok: true, data: { tasks: "not-a-list" } }), + ); + expect(result.tasks).toEqual([]); + expect(result.warning).toBe("Task payload missing tasks array"); + }); +}); + +describe("parseDependencyEnvelope", () => { + it("returns the root node from a valid deps envelope", () => { + const stdout = JSON.stringify({ + ok: true, + data: { + root: { + id: "tsq-1", + task: sampleTask, + children: [ + { + id: "tsq-2", + dep_type: "blocks", + direction: "outgoing", + children: [], + }, + ], + }, + }, + }); + const result = parseDependencyEnvelope(stdout); + expect(result.warning).toBeUndefined(); + expect(result.root?.id).toBe("tsq-1"); + expect(result.root?.task?.title).toBe("demo"); + expect(result.root?.children).toHaveLength(1); + expect(result.root?.children[0]?.depType).toBe("blocks"); + }); + + it("surfaces the error message from an ok:false envelope", () => { + const stdout = JSON.stringify({ + ok: false, + error: { message: "task not found" }, + }); + expect(parseDependencyEnvelope(stdout).warning).toBe("task not found"); + }); + + it("warns on malformed JSON", () => { + expect(parseDependencyEnvelope("{oops").warning).toBe( + "Unable to parse JSON output from tsq deps", + ); + }); + + it("warns when the root node is missing", () => { + const result = parseDependencyEnvelope( + JSON.stringify({ ok: true, data: {} }), + ); + expect(result.root).toBeUndefined(); + expect(result.warning).toBe("Dependency tree payload missing root node"); + }); + + it("warns on a null payload", () => { + const result = parseDependencyEnvelope("null"); + expect(result.root).toBeUndefined(); + expect(result.warning).toBe("Unexpected payload from tsq deps"); + }); + + it("warns on a non-object payload", () => { + const result = parseDependencyEnvelope(JSON.stringify(42)); + expect(result.root).toBeUndefined(); + expect(result.warning).toBe("Unexpected payload from tsq deps"); + }); + + it("warns on a non-boolean ok field", () => { + const result = parseDependencyEnvelope(JSON.stringify({ ok: "true" })); + expect(result.root).toBeUndefined(); + expect(result.warning).toBe("Unexpected payload from tsq deps"); + }); +}); + +describe("parseSpecEnvelope", () => { + it("splits data.spec.content into lines", () => { + const stdout = JSON.stringify({ + ok: true, + data: { + spec: { + path: ".tasque/specs/tsq-1/spec.md", + fingerprint: "abc123", + content: "# hello\r\nworld", + }, + }, + }); + const result = parseSpecEnvelope(stdout); + expect(result.warning).toBeUndefined(); + expect(result.lines).toEqual(["# hello", "world"]); + }); + + it("returns a placeholder for empty content", () => { + const stdout = JSON.stringify({ + ok: true, + data: { spec: { path: "p", fingerprint: "f", content: "" } }, + }); + expect(parseSpecEnvelope(stdout).lines).toEqual(["(empty spec)"]); + }); + + it("surfaces the error message from an ok:false envelope", () => { + const stdout = JSON.stringify({ + ok: false, + error: { message: "no spec attached" }, + }); + const result = parseSpecEnvelope(stdout); + expect(result.lines).toEqual([]); + expect(result.warning).toBe("no spec attached"); + }); + + it("warns on malformed JSON", () => { + expect(parseSpecEnvelope("").warning).toBe( + "Unable to parse JSON output from tsq spec --show", + ); + }); + + it("warns when data.spec.content is missing", () => { + const result = parseSpecEnvelope( + JSON.stringify({ ok: true, data: { spec: { path: "p" } } }), + ); + expect(result.lines).toEqual([]); + expect(result.warning).toBe("Spec payload missing content"); + }); +}); + +describe("runTsq", () => { + it("returns stdout, stderr, and exitCode for a normal process", async () => { + const result = await runTsq([ + process.execPath, + "-e", + "process.stdout.write('hi'); process.stderr.write('bye'); process.exit(3)", + ]); + + expect(result).toEqual({ exitCode: 3, stdout: "hi", stderr: "bye" }); + }); + + it("returns a non-zero exit when a process times out", async () => { + const start = Date.now(); + const result = await runTsq( + [process.execPath, "-e", "setInterval(() => {}, 1000)"], + { timeoutMs: 300 }, + ); + + expect(result.exitCode).not.toBe(0); + expect(Date.now() - start).toBeLessThan(5000); + }); +}); diff --git a/tui-opentui/tests/model.test.ts b/tui-opentui/tests/model.test.ts new file mode 100644 index 0000000..a248176 --- /dev/null +++ b/tui-opentui/tests/model.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from "bun:test"; +import { + type TasqueTask, + buildEpicProgressList, + computeSummary, + sortTasks, +} from "../src/model"; + +function makeTask(overrides: Partial & { id: string }): TasqueTask { + return { + kind: "task", + title: overrides.id, + status: "open", + priority: 2, + labels: [], + created_at: "2026-01-01T00:00:00.000Z", + updated_at: "2026-01-01T00:00:00.000Z", + ...overrides, + }; +} + +describe("buildEpicProgressList", () => { + it("returns an empty list when there are no epics", () => { + const tasks = [makeTask({ id: "tsq-1" }), makeTask({ id: "tsq-2" })]; + expect(buildEpicProgressList(tasks)).toEqual([]); + }); + + it("counts closed and canceled children as done for a single epic", () => { + const tasks = [ + makeTask({ id: "tsq-1", kind: "epic" }), + makeTask({ id: "tsq-1.1", parent_id: "tsq-1", status: "closed" }), + makeTask({ id: "tsq-1.2", parent_id: "tsq-1", status: "canceled" }), + makeTask({ id: "tsq-1.3", parent_id: "tsq-1", status: "in_progress" }), + makeTask({ id: "tsq-1.4", parent_id: "tsq-1", status: "blocked" }), + makeTask({ id: "tsq-1.5", parent_id: "tsq-1", status: "open" }), + makeTask({ id: "tsq-1.6", parent_id: "tsq-1", status: "deferred" }), + ]; + + const progress = buildEpicProgressList(tasks); + expect(progress).toHaveLength(1); + expect(progress[0]?.epic.id).toBe("tsq-1"); + expect(progress[0]?.children).toHaveLength(6); + expect(progress[0]?.done).toBe(2); + expect(progress[0]?.inProgress).toBe(2); + expect(progress[0]?.open).toBe(2); + }); + + it("returns one entry per epic with its own children", () => { + const tasks = [ + makeTask({ id: "tsq-1", kind: "epic" }), + makeTask({ id: "tsq-2", kind: "epic" }), + makeTask({ id: "tsq-3", kind: "epic" }), + makeTask({ id: "tsq-1.1", parent_id: "tsq-1", status: "closed" }), + makeTask({ id: "tsq-2.1", parent_id: "tsq-2", status: "open" }), + makeTask({ id: "tsq-2.2", parent_id: "tsq-2", status: "closed" }), + ]; + + const progress = buildEpicProgressList(tasks); + expect(progress.map((entry) => entry.epic.id)).toEqual([ + "tsq-1", + "tsq-2", + "tsq-3", + ]); + expect(progress[0]?.done).toBe(1); + expect(progress[1]?.children).toHaveLength(2); + expect(progress[1]?.done).toBe(1); + expect(progress[1]?.open).toBe(1); + expect(progress[2]?.children).toHaveLength(0); + expect(progress[2]?.done).toBe(0); + }); +}); + +describe("sortTasks", () => { + it("orders by status, then priority, then created_at", () => { + const tasks = [ + makeTask({ id: "tsq-1", status: "open", priority: 1 }), + makeTask({ id: "tsq-2", status: "in_progress", priority: 3 }), + makeTask({ + id: "tsq-3", + status: "open", + priority: 1, + created_at: "2025-01-01T00:00:00.000Z", + }), + makeTask({ id: "tsq-4", status: "closed" }), + ]; + + const sorted = sortTasks(tasks).map((task) => task.id); + expect(sorted).toEqual(["tsq-2", "tsq-3", "tsq-1", "tsq-4"]); + }); + + it("does not mutate the input array", () => { + const tasks = [ + makeTask({ id: "tsq-1", status: "closed" }), + makeTask({ id: "tsq-2", status: "in_progress" }), + ]; + sortTasks(tasks); + expect(tasks[0]?.id).toBe("tsq-1"); + }); +}); + +describe("computeSummary", () => { + it("counts open, in_progress, and blocked tasks", () => { + const tasks = [ + makeTask({ id: "tsq-1", status: "open" }), + makeTask({ id: "tsq-2", status: "in_progress" }), + makeTask({ id: "tsq-3", status: "blocked" }), + makeTask({ id: "tsq-4", status: "closed" }), + ]; + + expect(computeSummary(tasks)).toEqual({ + total: 4, + open: 1, + inProgress: 1, + blocked: 1, + }); + }); + + it("returns zeroes for an empty list", () => { + expect(computeSummary([])).toEqual({ + total: 0, + open: 0, + inProgress: 0, + blocked: 0, + }); + }); +});