diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-04-03 02:37:57 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-04-03 02:37:57 +0300 |
| commit | a9b1ef64ddd8942525ba8989271b0c232aa686a4 (patch) | |
| tree | 350899d0dee21db91a90a678a0bf2ba0d1807b41 | |
| parent | 685862e276ef4911029e47bdffad79143b096d0a (diff) | |
chore: remove llm integration tests and prep release docs
| -rw-r--r-- | AGENTS.md | 8 | ||||
| -rw-r--r-- | CHANGELOG.md | 25 | ||||
| -rw-r--r-- | CONFIGURATION.md | 1 | ||||
| -rw-r--r-- | README.md | 9 | ||||
| -rw-r--r-- | SKILL.md | 10 | ||||
| -rw-r--r-- | justfile | 17 | ||||
| -rw-r--r-- | src/persist.rs | 160 | ||||
| -rw-r--r-- | tests/integration/ext/herdr-test.ts | 146 | ||||
| -rwxr-xr-x | tests/integration/pdeathsig.py | 44 | ||||
| -rw-r--r-- | tests/integration/results/.gitignore | 2 | ||||
| -rwxr-xr-x | tests/integration/run_all.sh | 116 | ||||
| -rwxr-xr-x | tests/integration/run_spec.sh | 126 | ||||
| -rw-r--r-- | tests/integration/specs/create_workspace.md | 12 | ||||
| -rw-r--r-- | tests/integration/specs/sidebar_collapse.md | 13 | ||||
| -rw-r--r-- | tests/integration/specs/split_and_navigate.md | 11 | ||||
| -rw-r--r-- | tests/integration/specs/workspace_switching.md | 12 | ||||
| -rw-r--r-- | tests/integration/system.md | 48 |
17 files changed, 190 insertions, 570 deletions
@@ -16,19 +16,13 @@ Terminal workspace manager for AI coding agents. Rust + ratatui. ```bash just check # formatting + unit tests just test # unit tests -just test-integration # LLM-based integration tests (needs pi + tmux) -just test-all # check + integration tests -just clean-tests # kill orphaned test tmux sessions +just test-all # full local test suite ``` Default flow: run `just check` before committing. -`just test-all` includes an experimental LLM-driven end-to-end test pass. Do not run it unless Can asks for it explicitly. - Unit tests live next to the code (`#[cfg(test)] mod tests`). If you add behavior to `AppState` or `Workspace`, it should be testable with `AppState::test_new()` and `Workspace::test_new()` — no PTYs. -Integration tests are markdown specs in `tests/integration/specs/`. A pi agent executes them against herdr in an isolated tmux server. See `tests/integration/system.md` for the test agent prompt. - ## Conventions - Conventional commits, lowercase, no emojis. diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ebe34c6..a98377ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,19 +2,28 @@ ## Unreleased -### Added +### Major Changes - Added tabs within workspaces, so a single workspace can now hold multiple terminal tab contexts with their own pane layouts. -- Added optional direct pane-focus keybindings for terminal mode, so you can switch panes with modifier shortcuts like `alt+h` or `alt+right` without entering navigate mode first. -- Added built-in direct integrations for pi, claude code, codex, and opencode, with install/uninstall commands under `herdr integration ...`. -- Added authoritative hook-driven state reporting support, so pane state can be updated directly from agent integrations instead of relying only on heuristics. +- Added first-class tab support to the local socket API and CLI wrappers, including `herdr tab ...` commands and tab ids like `1:2` alongside workspace-scoped pane ids. +- Added built-in direct integrations for pi, claude code, codex, and opencode, plus authoritative hook-driven state reporting so supported agents can report semantic state directly instead of relying only on screen heuristics. +- Added a post-update release-notes screen so herdr can explain what changed after an update is installed. -### Changed -- Refactored keybind discoverability so the in-app keybind help now shows all supported actions, including optional bindings that are currently unset. +### UX and Controls +- Added optional direct pane-focus keybindings for terminal mode, so you can switch panes with modifier shortcuts like `alt+h` or `alt+right` without entering navigate mode first. +- Reworked keybind discoverability so the in-app keybind help now shows all supported actions, including optional bindings that are currently unset. - Keybind help now uses a centered scrollable modal with mouse and keyboard scrolling, matching the release-notes interaction model more closely. -- Popups and action-button interactions were refactored to use more consistent modal geometry and button semantics across the ui. +- Popups and action-button interactions now use more consistent modal geometry and button semantics across the UI. +- Polished the sidebar agent section so it focuses on detected agents only and uses clearer two-line agent cards with more breathing room. + +### Behavior Fixes +- Hook-driven agent state updates now stay correct in tabbed workspaces. +- Modifier-only keypresses no longer leak into panes as stray input. +- Multi-tab agent labels now include tab names when that extra context matters. +- Workspace identity now follows the first tab's root pane again instead of stale creation-time cwd. +- Background notification suppression is now tab-aware rather than workspace-wide, so background tabs in the current workspace can still alert correctly. ### Documentation -- Updated the README, configuration guide, and socket api docs to reflect tabs, unset optional keybindings, direct terminal-mode navigation examples, and the current workspace-scoped pane id model. +- Updated the README, configuration guide, integrations guide, skill, and socket API docs to reflect tabs, direct integrations, unset optional keybindings, direct terminal-mode navigation examples, workspace-scoped pane ids, and the current workspace identity/sidebar model. ## [0.2.4] - 2026-04-01 diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 9c2ba770..d28617e8 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -200,6 +200,7 @@ current v1 behavior: - one toast at a time - top-right placement - shown for background agent events like `needs attention` and `finished` +- suppression is tab-aware: the active tab stays quiet, but background tabs in the same workspace can still notify - no keyboard action or temporary key semantics ## sound @@ -56,7 +56,7 @@ this keeps workspaces lightweight. they are project contexts first, labels secon the sidebar is split into two layers: - **top:** workspaces, each with one aggregate state dot -- **bottom:** agents inside the selected or active workspace +- **bottom:** detected agents inside the selected or active workspace herdr automatically detects running agents by looking at the foreground process and reading terminal output. the top section compresses each workspace into one prioritized signal so you can scan the whole workspace list quickly; the bottom section shows which specific agent is causing it. @@ -70,7 +70,9 @@ workspace and agent states map to: workspace rollups prefer the most urgent thing happening in that workspace: blocked first, then unseen finished work, then working, then idle. -if you want more interruption than ambient sidebar awareness, herdr can also play sounds or show top-right toast notifications for background events. +plain shells still contribute to workspace rollups, but the sidebar's agent section intentionally hides non-agent panes so the detail list stays focused on actual agents. + +if you want more interruption than ambient sidebar awareness, herdr can also play sounds or show top-right toast notifications for background events. notification suppression is tab-aware: the active tab stays quiet, but background tabs in the same workspace can still alert. ## agents can use herdr too @@ -339,8 +341,7 @@ cargo build --release ```bash just test # unit tests -just test-integration # LLM-based integration tests -just test-all # both +just test-all # full local test suite ``` ## license @@ -23,7 +23,7 @@ if you need the raw protocol or full api reference, read [`SOCKET_API.md`](./SOC ## concepts -**workspaces** are project contexts. each workspace has one or more tabs. +**workspaces** are project contexts. each workspace has one or more tabs. unless manually renamed, a workspace's label follows the first tab's root pane — usually the repo name, otherwise the root pane's current folder name. **tabs** are subcontexts inside a workspace. each tab has one or more panes. @@ -34,7 +34,9 @@ if you need the raw protocol or full api reference, read [`SOCKET_API.md`](./SOC - `idle` — agent finished and the state is calm - `working` — agent is actively working - `blocked` — agent needs input or approval -- `unknown` — no recognized agent, or just a shell +- `unknown` — no recognized agent + +plain shells still exist as panes, but herdr's sidebar agent section intentionally focuses on detected agents rather than listing every shell. **ids** — workspace ids look like `1`, `2`. tab ids look like `1:1`, `1:2`, `2:1`. pane ids look like `1-1`, `1-2`, `2-1`. these are compact public ids for the current live session. @@ -259,6 +261,6 @@ herdr pane read 1-1 --source recent --lines 100 - `workspace list`, `workspace create`, `tab list`, `tab create`, `tab get`, `tab focus`, `tab rename`, `tab close`, `pane list`, `pane get`, `pane split`, `wait output`, and `wait agent-state` print json on success. - `pane read` prints text, not json. - `pane send-text`, `pane send-keys`, and `pane run` print nothing on success. -- parse ids from `workspace create` and `pane split` responses when you need the new ids. do not guess. -- `--no-focus` on split and workspace create keeps your current terminal context focused. +- parse ids from `workspace create`, `tab create`, and `pane split` responses when you need new ids. do not guess. +- `--no-focus` on split, tab create, and workspace create keeps your current terminal context focused. - if you are running inside herdr, the `HERDR_ENV` environment variable is set to `1`. @@ -11,26 +11,13 @@ check: cargo test python3 -m unittest scripts.test_changelog -# Run integration tests (LLM-based, requires pi + tmux) -test-integration: - ./tests/integration/run_all.sh - -# Run all tests -test-all: check test-integration +# Run the full local test suite +test-all: check # Build release binary build: cargo build --release -# Kill any leftover test tmux sessions and clean results -clean-tests: - @for sock in ${TMPDIR:-/tmp}/herdr-test-sockets/*/tmux.sock; do \ - [ -S "$$sock" ] && tmux -S "$$sock" kill-server 2>/dev/null || true; \ - done - @rm -rf ${TMPDIR:-/tmp}/herdr-test-sockets 2>/dev/null || true - @rm -f tests/integration/results/*.json tests/integration/results/*.txt 2>/dev/null || true - @echo "cleaned" - # Finalize changelog, bump version, commit, tag, push, trigger release build (usage: just release 0.1.1) release version: @if [ -n "$(git status --porcelain)" ]; then \ diff --git a/src/persist.rs b/src/persist.rs index ba88df8e..c296b0ac 100644 --- a/src/persist.rs +++ b/src/persist.rs @@ -8,7 +8,7 @@ use std::sync::atomic::AtomicBool; use std::sync::Arc; use ratatui::layout::Direction; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize}; use tracing::{error, info, warn}; use tokio::sync::{mpsc, Notify}; @@ -32,7 +32,7 @@ pub struct SessionSnapshot { pub selected: usize, } -#[derive(Serialize, Deserialize)] +#[derive(Serialize)] pub struct WorkspaceSnapshot { #[serde(default)] pub custom_name: Option<String>, @@ -42,6 +42,41 @@ pub struct WorkspaceSnapshot { pub active_tab: usize, } +#[derive(Deserialize)] +struct WorkspaceSnapshotWire { + #[serde(default)] + custom_name: Option<String>, + #[serde(default)] + identity_cwd: Option<PathBuf>, + #[serde(default)] + tabs: Vec<TabSnapshot>, + #[serde(default)] + active_tab: usize, + #[serde(default)] + layout: Option<LayoutSnapshot>, + #[serde(default)] + panes: HashMap<u32, PaneSnapshot>, + #[serde(default)] + zoomed: Option<bool>, + #[serde(default)] + focused: Option<u32>, + #[serde(default)] + root_pane: Option<u32>, +} + +#[derive(Deserialize)] +struct LegacyWorkspaceSnapshot { + #[serde(default)] + custom_name: Option<String>, + layout: LayoutSnapshot, + panes: HashMap<u32, PaneSnapshot>, + zoomed: bool, + #[serde(default)] + focused: Option<u32>, + #[serde(default)] + root_pane: Option<u32>, +} + #[derive(Serialize, Deserialize)] pub struct TabSnapshot { #[serde(default)] @@ -78,6 +113,95 @@ pub enum DirectionSnapshot { Vertical, } +impl From<LegacyWorkspaceSnapshot> for WorkspaceSnapshot { + fn from(snap: LegacyWorkspaceSnapshot) -> Self { + let identity_cwd = legacy_identity_cwd(&snap); + let tab = TabSnapshot { + custom_name: None, + layout: snap.layout, + panes: snap.panes, + zoomed: snap.zoomed, + focused: snap.focused, + root_pane: snap.root_pane, + }; + + Self { + custom_name: snap.custom_name, + identity_cwd, + tabs: vec![tab], + active_tab: 0, + } + } +} + +impl<'de> Deserialize<'de> for WorkspaceSnapshot { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: Deserializer<'de>, + { + let snap = WorkspaceSnapshotWire::deserialize(deserializer)?; + + if let Some(identity_cwd) = snap.identity_cwd { + return Ok(Self { + custom_name: snap.custom_name, + identity_cwd, + tabs: snap.tabs, + active_tab: snap.active_tab, + }); + } + + if let Some(layout) = snap.layout { + // Backward-compat path for pre-tab session snapshots (<= v0.2.4). + // Remove this migration once we intentionally drop support for v2 sessions. + return Ok(LegacyWorkspaceSnapshot { + custom_name: snap.custom_name, + layout, + panes: snap.panes, + zoomed: snap.zoomed.unwrap_or(false), + focused: snap.focused, + root_pane: snap.root_pane, + } + .into()); + } + + Err(<D::Error as serde::de::Error>::custom( + "workspace snapshot is neither current nor legacy format", + )) + } +} + +fn legacy_identity_cwd(snap: &LegacyWorkspaceSnapshot) -> PathBuf { + let root_pane = snap + .root_pane + .or_else(|| first_pane_id_in_layout(&snap.layout)); + + root_pane + .and_then(|pane_id| snap.panes.get(&pane_id)) + .map(|pane| pane.cwd.clone()) + .or_else(|| { + first_pane_id_in_layout(&snap.layout) + .and_then(|pane_id| snap.panes.get(&pane_id)) + .map(|pane| pane.cwd.clone()) + }) + .or_else(|| { + snap.panes + .keys() + .min() + .and_then(|pane_id| snap.panes.get(pane_id)) + .map(|pane| pane.cwd.clone()) + }) + .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| "/".into())) +} + +fn first_pane_id_in_layout(layout: &LayoutSnapshot) -> Option<u32> { + match layout { + LayoutSnapshot::Pane(id) => Some(*id), + LayoutSnapshot::Split { first, second, .. } => { + first_pane_id_in_layout(first).or_else(|| first_pane_id_in_layout(second)) + } + } +} + // --- Capture --- /// Capture the current app state into a serializable snapshot. @@ -513,6 +637,38 @@ mod tests { } #[test] + fn legacy_workspace_snapshot_migrates_to_single_tab() { + let json = serde_json::json!({ + "version": 2, + "workspaces": [{ + "custom_name": "legacy", + "layout": LayoutSnapshot::Pane(0), + "panes": { + "0": { "cwd": "/tmp/pion" } + }, + "zoomed": false, + "focused": 0, + "root_pane": 0 + }], + "active": 0, + "selected": 0 + }) + .to_string(); + + let snap: SessionSnapshot = serde_json::from_str(&json).unwrap(); + let ws = &snap.workspaces[0]; + + assert_eq!(snap.workspaces.len(), 1); + assert_eq!(ws.custom_name.as_deref(), Some("legacy")); + assert_eq!(ws.identity_cwd, PathBuf::from("/tmp/pion")); + assert_eq!(ws.active_tab, 0); + assert_eq!(ws.tabs.len(), 1); + assert_eq!(ws.tabs[0].focused, Some(0)); + assert_eq!(ws.tabs[0].root_pane, Some(0)); + assert_eq!(ws.tabs[0].panes[&0].cwd, PathBuf::from("/tmp/pion")); + } + + #[test] fn capture_and_restore_node_round_trip() { // Create a tree: Split(H, 0.5, Pane(0), Split(V, 0.3, Pane(1), Pane(2))) let node = Node::Split { diff --git a/tests/integration/ext/herdr-test.ts b/tests/integration/ext/herdr-test.ts deleted file mode 100644 index 4b6c17d5..00000000 --- a/tests/integration/ext/herdr-test.ts +++ /dev/null @@ -1,146 +0,0 @@ -/** - * Minimal tmux extension for herdr integration testing. - * - * Two actions: send and read. No wait, no pane creation, no discovery loops. - * Talks directly to a pane tagged @pi_name=herdr. - */ - -import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; -import { StringEnum } from "@mariozechner/pi-ai"; -import { Text } from "@mariozechner/pi-tui"; -import { Type } from "@sinclair/typebox"; - -function stripAnsi(text: string): string { - return text - .replace(/\x1b\].*?(?:\x07|\x1b\\)/g, "") - .replace(/\x1bPtmux;.*?\x1b\\/g, "") - .replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "") - .replace(/\x1b[^[\]P]/g, "") - .replace(/\r/g, ""); -} - -export default function (pi: ExtensionAPI) { - let herdrPaneId: string | null = null; - let windowId: string | null = null; - - pi.on("session_start", async () => { - if (!process.env.TMUX) return; - - try { - const winResult = await pi.exec("tmux", [ - "display-message", "-p", "-t", process.env.TMUX_PANE || "", - "#{window_id}", - ]); - if (winResult.code === 0) windowId = winResult.stdout.trim(); - - if (windowId) { - const listResult = await pi.exec("tmux", [ - "list-panes", "-t", windowId, - "-F", "#{pane_id}\t#{@pi_name}", - ]); - if (listResult.code === 0) { - for (const line of listResult.stdout.trim().split("\n")) { - const [paneId, name] = line.split("\t"); - if (name === "herdr") { - herdrPaneId = paneId; - break; - } - } - } - } - } catch {} - }); - - pi.registerTool({ - name: "herdr", - label: "herdr", - description: - "Interact with the herdr instance under test. " + - "Actions: send (send keys or text to herdr), read (capture herdr screen).", - parameters: Type.Object({ - action: StringEnum(["send", "read"] as const, { - description: "Action: send or read", - }), - keys: Type.Optional( - Type.String({ - description: - "Keys to send, space-separated (for send). Examples: C-s, Enter, n, v, Escape", - }), - ), - text: Type.Optional( - Type.String({ description: "Literal text to type (for send). Sent as-is." }), - ), - lines: Type.Optional( - Type.Number({ description: "Scrollback lines to capture (for read, default: 50)" }), - ), - }), - - async execute(_toolCallId, params) { - if (!herdrPaneId) { - throw new Error("herdr pane not found. Is herdr running in this tmux window?"); - } - - switch (params.action) { - case "send": { - if (!params.keys && !params.text) { - throw new Error("send requires 'keys' or 'text'"); - } - if (params.text) { - await pi.exec("tmux", [ - "send-keys", "-l", "-t", herdrPaneId, params.text, - ]); - } - if (params.keys) { - const keyArgs = params.keys.split(/\s+/).filter(Boolean); - await pi.exec("tmux", [ - "send-keys", "-t", herdrPaneId, ...keyArgs, - ]); - } - const desc = [ - params.text && `"${params.text}"`, - params.keys, - ].filter(Boolean).join(" + "); - return { - content: [{ type: "text", text: `Sent: ${desc}` }], - }; - } - - case "read": { - const lines = params.lines ?? 50; - const result = await pi.exec("tmux", [ - "capture-pane", "-t", herdrPaneId, "-p", "-S", `-${lines}`, - ]); - if (result.code !== 0) { - throw new Error(`capture-pane failed: ${result.stderr}`); - } - let output = stripAnsi(result.stdout); - output = output.replace(/\n+$/, "\n"); - return { - content: [{ type: "text", text: output }], - }; - } - - default: - throw new Error(`Unknown action: ${params.action}`); - } - }, - - renderCall(args, theme) { - const action = args.action || "?"; - let t = theme.fg("toolTitle", theme.bold("herdr ")); - t += theme.fg("accent", action); - if (args.text) t += theme.fg("dim", ` "${args.text}"`); - if (args.keys) t += theme.fg("dim", ` ${args.keys}`); - return new Text(t, 0, 0); - }, - - renderResult(result, _opts, theme) { - const c = result.content?.[0]; - const text = c?.type === "text" ? c.text : ""; - if (text.length > 200) { - return new Text(theme.fg("dim", text.slice(0, 200) + "…"), 0, 0); - } - return new Text(theme.fg("dim", text), 0, 0); - }, - }); -} diff --git a/tests/integration/pdeathsig.py b/tests/integration/pdeathsig.py deleted file mode 100755 index d4a25ac7..00000000 --- a/tests/integration/pdeathsig.py +++ /dev/null @@ -1,44 +0,0 @@ -#!/usr/bin/env python3 -"""Exec a command with PR_SET_PDEATHSIG — receive a signal when parent dies. - -Usage: pdeathsig.py SIGNAL COMMAND [ARGS...] - -Fallback for systems without `setpriv --pdeathsig`. -""" -import ctypes -import ctypes.util -import os -import signal -import sys - -PR_SET_PDEATHSIG = 1 - -SIGNALS = { - "TERM": signal.SIGTERM, "SIGTERM": signal.SIGTERM, - "INT": signal.SIGINT, "SIGINT": signal.SIGINT, - "HUP": signal.SIGHUP, "SIGHUP": signal.SIGHUP, - "KILL": signal.SIGKILL, "SIGKILL": signal.SIGKILL, -} - -def main() -> int: - if len(sys.argv) < 3: - print(f"usage: {sys.argv[0]} SIGNAL COMMAND [ARGS...]", file=sys.stderr) - return 2 - sig_name = sys.argv[1] - argv = sys.argv[2:] - if sig_name not in SIGNALS: - print(f"unknown signal: {sig_name}", file=sys.stderr) - return 2 - sig = SIGNALS[sig_name] - libc = ctypes.CDLL(ctypes.util.find_library("c") or "libc.so.6", use_errno=True) - if libc.prctl(PR_SET_PDEATHSIG, sig, 0, 0, 0) != 0: - err = ctypes.get_errno() - raise OSError(err, os.strerror(err)) - # Race fix: parent may have died before prctl took effect - if os.getppid() == 1: - os.kill(os.getpid(), sig) - os.execvp(argv[0], argv) - return 127 - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tests/integration/results/.gitignore b/tests/integration/results/.gitignore deleted file mode 100644 index d6b7ef32..00000000 --- a/tests/integration/results/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore diff --git a/tests/integration/run_all.sh b/tests/integration/run_all.sh deleted file mode 100755 index cd373af4..00000000 --- a/tests/integration/run_all.sh +++ /dev/null @@ -1,116 +0,0 @@ -#!/usr/bin/env bash -# -# Run all herdr integration tests in parallel. -# -# Each spec gets its own tmux server (isolated socket). -# Workers are launched with PDEATHSIG so they get SIGTERM if this script dies. -# A stale sweep cleans up sockets from any previous crashed run. -# -set -uo pipefail - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -SPECS_DIR="$SCRIPT_DIR/specs" -RESULTS_DIR="$SCRIPT_DIR/results" -TMUX_ROOT="${TMPDIR:-/tmp}/herdr-test-sockets" - -mkdir -p "$RESULTS_DIR" "$TMUX_ROOT" - -# Clean previous results -rm -f "$RESULTS_DIR"/*.json "$RESULTS_DIR"/*.txt 2>/dev/null - -# Sweep stale tmux sockets from crashed previous runs -for dir in "$TMUX_ROOT"/*/; do - [ -d "$dir" ] || continue - owner_pid="" - [ -f "$dir/owner.pid" ] && owner_pid="$(<"$dir/owner.pid")" - # If owning worker is still alive, leave it - if [ -n "$owner_pid" ] && kill -0 "$owner_pid" 2>/dev/null; then - continue - fi - # Dead owner — kill the tmux server and remove the dir - [ -S "$dir/tmux.sock" ] && tmux -S "$dir/tmux.sock" kill-server 2>/dev/null || true - rm -rf "$dir" -done - -# Collect specs -SPECS=("$SPECS_DIR"/*.md) -if [ ${#SPECS[@]} -eq 0 ]; then - echo "No test specs found in $SPECS_DIR" - exit 0 -fi - -echo "Running ${#SPECS[@]} integration tests..." - -# Detect PDEATHSIG support -PDEATHSIG_CMD=() -if command -v setpriv >/dev/null 2>&1 && setpriv --help 2>&1 | grep -q -- '--pdeathsig'; then - PDEATHSIG_CMD=(setpriv --pdeathsig TERM --) -elif [ -f "$SCRIPT_DIR/pdeathsig.py" ]; then - PDEATHSIG_CMD=(python3 "$SCRIPT_DIR/pdeathsig.py" TERM) -fi - -# Launch all specs in parallel — directly from this shell (no subshell wrapper) -PIDS=() -NAMES=() - -for spec in "${SPECS[@]}"; do - name="$(basename "$spec" .md)" - NAMES+=("$name") - - "${PDEATHSIG_CMD[@]}" "$SCRIPT_DIR/run_spec.sh" "$spec" "$RESULTS_DIR" "$TMUX_ROOT" & - PIDS+=($!) -done - -# Wait for all, ignoring individual exit codes (we read results from files) -for pid in "${PIDS[@]}"; do - wait "$pid" 2>/dev/null || true -done - -echo "" - -# Print summary from result files -PASSED=0 -FAILED=0 -SKIPPED=0 - -for name in "${NAMES[@]}"; do - result_file="$RESULTS_DIR/$name.json" - - if [[ ! -f "$result_file" ]]; then - echo " ? $name (no result file)" - FAILED=$((FAILED + 1)) - continue - fi - - result=$(python3 -c "import sys,json; print(json.load(sys.stdin).get('result','unknown'))" < "$result_file" 2>/dev/null || echo "unknown") - - case "$result" in - pass) - echo " ✓ $name" - PASSED=$((PASSED + 1)) - ;; - skip) - echo " - $name (skipped)" - SKIPPED=$((SKIPPED + 1)) - ;; - *) - echo " ✗ $name" - python3 -c " -import sys, json -data = json.load(sys.stdin) -for c in data.get('checks', []): - if not c.get('pass', True): - print(f' ✗ {c[\"name\"]}: {c.get(\"detail\", \"\")}') -notes = data.get('notes', '') -if notes and data.get('result') == 'error': - print(f' {notes}') -" < "$result_file" 2>/dev/null - FAILED=$((FAILED + 1)) - ;; - esac -done - -echo "" -echo "$PASSED passed, $FAILED failed, $SKIPPED skipped" - -[ $FAILED -eq 0 ] diff --git a/tests/integration/run_spec.sh b/tests/integration/run_spec.sh deleted file mode 100755 index 43056f6e..00000000 --- a/tests/integration/run_spec.sh +++ /dev/null @@ -1,126 +0,0 @@ -#!/usr/bin/env bash -# -# Run a single herdr integration test spec via pi. -# -# Usage: ./run_spec.sh <spec.md> [results_dir] [tmux_socket_root] -# -# Each invocation creates its own tmux server (isolated socket). -# Cleanup kills the entire server — no orphaned sessions possible. -# -set -euo pipefail - -SPEC="$1" -SPEC_NAME="$(basename "$SPEC" .md)" -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -PROJECT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" -RESULTS_DIR="${2:-$SCRIPT_DIR/results}" -TMUX_ROOT="${3:-${TMPDIR:-/tmp}/herdr-test-sockets}" -SYSTEM_PROMPT_FILE="$SCRIPT_DIR/system.md" -HERDR="$PROJECT_DIR/target/release/herdr" -EXTENSION="$SCRIPT_DIR/ext/herdr-test.ts" -MODEL="${HERDR_TEST_MODEL:-minimax/MiniMax-M2.7}" - -mkdir -p "$RESULTS_DIR" "$TMUX_ROOT" - -# Per-spec tmux socket — this spec owns its entire tmux server -TMUX_DIR="$(mktemp -d "$TMUX_ROOT/${SPEC_NAME}.XXXXXX")" -TMUX_SOCKET="$TMUX_DIR/tmux.sock" -SESSION_NAME="test" - -# Track ownership for stale sweep -printf '%s\n' "$$" > "$TMUX_DIR/owner.pid" - -# Temp files (initialized for safe cleanup) -PROMPT_FILE="" -RAW_OUTPUT="" - -cleanup() { - # Kill our entire tmux server — takes everything with it - tmux -S "$TMUX_SOCKET" kill-server 2>/dev/null || true - [[ -n "$PROMPT_FILE" ]] && rm -f "$PROMPT_FILE" 2>/dev/null || true - rm -rf "$TMUX_DIR" 2>/dev/null || true - # RAW_OUTPUT is moved to results dir, not cleaned here -} -trap cleanup EXIT INT TERM HUP - -TMUX=(tmux -S "$TMUX_SOCKET") - -# Prerequisites -RESULT_FILE="$RESULTS_DIR/$SPEC_NAME.json" -if ! command -v tmux &>/dev/null; then echo '{"test":"'"$SPEC_NAME"'","result":"skip","checks":[],"notes":"tmux not found"}' > "$RESULT_FILE"; exit 0; fi -if ! command -v pi &>/dev/null; then echo '{"test":"'"$SPEC_NAME"'","result":"skip","checks":[],"notes":"pi not found"}' > "$RESULT_FILE"; exit 0; fi -if [[ ! -x "$HERDR" ]]; then echo '{"test":"'"$SPEC_NAME"'","result":"error","checks":[],"notes":"herdr binary not found"}' > "$RESULT_FILE"; exit 1; fi - -# Start herdr in its own tmux server -"${TMUX[@]}" new-session -d -s "$SESSION_NAME" -x 120 -y 50 -"${TMUX[@]}" send-keys -t "$SESSION_NAME" "$HERDR --no-session" Enter -HERDR_PANE=$("${TMUX[@]}" list-panes -t "$SESSION_NAME" -F '#{pane_id}' | head -1) -"${TMUX[@]}" set-option -p -t "$HERDR_PANE" @pi_name "herdr" - -sleep 1 - -# Build prompt -PROMPT_FILE=$(mktemp /tmp/herdr-test-prompt-XXXXX.md) -RAW_OUTPUT=$(mktemp /tmp/herdr-test-raw-XXXXX.txt) -cat "$SYSTEM_PROMPT_FILE" > "$PROMPT_FILE" -echo -e "\n---\n" >> "$PROMPT_FILE" -cat "$SPEC" >> "$PROMPT_FILE" - -# Run pi in the same tmux server -SESSION_DIR="$RESULTS_DIR/sessions" -mkdir -p "$SESSION_DIR" - -"${TMUX[@]}" split-window -t "$SESSION_NAME" -h \ - "cd $PROJECT_DIR && pi -p \ - --session-dir '$SESSION_DIR' \ - --no-extensions --no-skills --no-prompt-templates \ - -e '$EXTENSION' \ - --no-tools \ - --model '$MODEL' \ - --system-prompt '$PROMPT_FILE' \ - 'Execute the test now. Use the herdr tool to send keys and read the screen. Output the JSON result block when done.' \ - > '$RAW_OUTPUT' 2>&1; \ - echo '___DONE___' >> '$RAW_OUTPUT'; \ - sleep 1" - -# Wait for completion -TIMEOUT=180 -ELAPSED=0 -while [ $ELAPSED -lt $TIMEOUT ]; do - if grep -q '___DONE___' "$RAW_OUTPUT" 2>/dev/null; then - break - fi - sleep 2 - ELAPSED=$((ELAPSED + 2)) -done - -if [ $ELAPSED -ge $TIMEOUT ]; then - echo '{"test":"'"$SPEC_NAME"'","result":"error","checks":[],"notes":"pi timed out after '"$TIMEOUT"'s"}' > "$RESULT_FILE" - cp "$RAW_OUTPUT" "$RESULTS_DIR/$SPEC_NAME.raw.txt" 2>/dev/null || true - rm -f "$RAW_OUTPUT" - exit 1 -fi - -# Always save raw output -cp "$RAW_OUTPUT" "$RESULTS_DIR/$SPEC_NAME.raw.txt" 2>/dev/null || true -rm -f "$RAW_OUTPUT" - -# Extract JSON -JSON_RESULT=$(awk '/^```json$/,/^```$/' "$RESULTS_DIR/$SPEC_NAME.raw.txt" | grep -v '^```') - -if [[ -z "$JSON_RESULT" ]]; then - echo '{"test":"'"$SPEC_NAME"'","result":"error","checks":[],"notes":"no JSON in pi output"}' > "$RESULT_FILE" - exit 1 -fi - -if echo "$JSON_RESULT" | python3 -m json.tool > "$RESULT_FILE" 2>/dev/null; then - RESULT=$(python3 -c "import sys,json; print(json.load(sys.stdin).get('result','unknown'))" < "$RESULT_FILE") - if [[ "$RESULT" == "pass" ]]; then - exit 0 - else - exit 1 - fi -else - echo '{"test":"'"$SPEC_NAME"'","result":"error","checks":[],"notes":"invalid JSON from pi"}' > "$RESULT_FILE" - exit 1 -fi diff --git a/tests/integration/specs/create_workspace.md b/tests/integration/specs/create_workspace.md deleted file mode 100644 index 0f588c69..00000000 --- a/tests/integration/specs/create_workspace.md +++ /dev/null @@ -1,12 +0,0 @@ -# Test: Create workspace - -## Steps - -1. Read the screen to verify herdr started in empty state (should show "No active workspace" or "Press n to create one") -2. Press `n` to enter create mode -3. Type `test-workspace` -4. Press Enter to create -5. Read the screen and verify: - - The sidebar shows `1` followed by `test-workspace` - - A shell prompt is visible in the main pane area - - The active marker `▸` is next to the workspace diff --git a/tests/integration/specs/sidebar_collapse.md b/tests/integration/specs/sidebar_collapse.md deleted file mode 100644 index b5d42c25..00000000 --- a/tests/integration/specs/sidebar_collapse.md +++ /dev/null @@ -1,13 +0,0 @@ -# Test: Sidebar collapse and expand - -## Steps - -1. Press `n` to create a workspace named `test` -2. Read the screen and verify the expanded sidebar shows the full workspace name `test` -3. Press prefix (ctrl+s), then `b` to collapse the sidebar -4. Read the screen and verify: - - The sidebar is now narrow (just numbers and state dots, no workspace name visible) - - The `»` expand icon is visible at the bottom of the sidebar - - The terminal pane takes up more horizontal space -5. Press prefix (ctrl+s), then `b` again to expand -6. Verify the full workspace name `test` is visible again in the sidebar diff --git a/tests/integration/specs/split_and_navigate.md b/tests/integration/specs/split_and_navigate.md deleted file mode 100644 index cb75d72d..00000000 --- a/tests/integration/specs/split_and_navigate.md +++ /dev/null @@ -1,11 +0,0 @@ -# Test: Split pane and navigate - -## Steps - -1. Press `n` to create a workspace named `dev` -2. Press prefix (ctrl+s), then `v` to split vertically -3. Read the screen and verify two panes are visible side by side, each with a shell prompt -4. Verify the sidebar shows a pane count indicator like `[2]` -5. Press prefix (ctrl+s), then `h` to navigate to the left pane -6. Press prefix (ctrl+s), then `l` to navigate back to the right pane -7. Verify the thick border (focused indicator) moved between panes diff --git a/tests/integration/specs/workspace_switching.md b/tests/integration/specs/workspace_switching.md deleted file mode 100644 index 0711c575..00000000 --- a/tests/integration/specs/workspace_switching.md +++ /dev/null @@ -1,12 +0,0 @@ -# Test: Workspace switching with prefix+N - -## Steps - -1. Press `n` to create a workspace named `first` -2. Press prefix (ctrl+s), then `n` to create a second workspace named `second` -3. Read the screen and verify the sidebar shows two workspaces numbered 1 and 2 -4. Verify `second` is the active workspace (has the `▸` marker) -5. Press prefix (ctrl+s), then `1` to switch to the first workspace -6. Read the screen and verify `first` is now active (has the `▸` marker) -7. Press prefix (ctrl+s), then `2` to switch back to `second` -8. Verify `second` is active again diff --git a/tests/integration/system.md b/tests/integration/system.md deleted file mode 100644 index d6ccb05b..00000000 --- a/tests/integration/system.md +++ /dev/null @@ -1,48 +0,0 @@ -# herdr Integration Test Agent - -You are a test executor for herdr, a terminal workspace manager. Your job is to execute a test specification by interacting with herdr through tmux, then report whether the test passed or failed. - -## Environment - -- herdr is running in tmux pane `herdr` with `--no-session` (clean state, no saved workspaces) -- You have the `tmux` tool to interact with it: `send` keys, `read` screen output -- herdr starts in Navigate mode with no workspaces (empty state) - -## herdr Controls - -- **Navigate mode**: press prefix key (ctrl+s) from Terminal mode, or start with no workspaces -- **Create workspace**: press `n`, type name, press Enter -- **Switch workspace**: press `1`-`9` (by number) or arrow keys + Enter -- **Split pane**: press `v` (vertical split) or `-` (horizontal split) -- **Close pane**: press `x` -- **Close workspace**: press `d` (then `y` to confirm) -- **Collapse sidebar**: press `b` -- **Resize mode**: press `r`, then h/j/k/l to resize, Esc to exit -- **Back to terminal**: press Esc or prefix key again -- **Quit**: press `q` in Navigate mode - -After creating a workspace or pressing Enter on one, you enter Terminal mode. Press ctrl+s (prefix) to go back to Navigate mode. - -## How to Execute - -1. Read the test specification carefully -2. Execute each step using tmux send/read -3. After each action, wait briefly then read the screen to verify -4. When done, output your result as a JSON object - -## Output Format - -Your final message MUST be a JSON code block with this structure: - -```json -{ - "test": "<test name>", - "result": "pass" | "fail", - "checks": [ - {"name": "<what was checked>", "pass": true|false, "detail": "<what you saw>"} - ], - "notes": "<any additional observations>" -} -``` - -Be precise. Read the screen carefully. Don't assume — verify by reading. |
