#!/usr/bin/env bats # Exercises bin/devdesk and the libexec helpers against a throwaway tmux server. REPO=$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd) setup() { TMP=$(mktemp -d /tmp/devdesk-test.XXXXXX) # short path: unix socket limit export DEVDESK_ENV=/dev/null export DEVDESK_PROJECTS_ROOT="$TMP/projects" export DEVDESK_STATE_DIR="$TMP/state" export DEVDESK_SOCKET="$TMP/tmux.sock" export DEVDESK_LIBEXEC="$REPO/libexec" export DEVDESK_SHARE="$REPO/share" export DEVDESK_NO_SYSTEMD=1 export FAKE_LOG="$TMP/claude-args" export CLAUDE_BIN="$TMP/fake-claude" export PATH="$REPO/bin:$PATH" mkdir -p "$DEVDESK_PROJECTS_ROOT/alpha" "$DEVDESK_PROJECTS_ROOT/test code" "$TMP/outside" "$FAKE_LOG" ln -s "$TMP/outside" "$DEVDESK_PROJECTS_ROOT/escape" cat >"$CLAUDE_BIN" <<'EOF' #!/bin/bash printf '%s\n' "$*" >>"$FAKE_LOG/$DEVDESK_SESSION" exec sleep 300 EOF chmod +x "$CLAUDE_BIN" } teardown() { tmux -S "$DEVDESK_SOCKET" kill-server 2>/dev/null || true rm -rf "$TMP" } start_server() { tmux -u -D -S "$DEVDESK_SOCKET" -f "$REPO/share/tmux.conf" & "$REPO/libexec/devdesk-tmux-ready" } # Wait for the fake claude in SESSION to record its arguments. claude_args() { for _ in $(seq 1 50); do [ -s "$FAKE_LOG/$1" ] && { tail -n1 "$FAKE_LOG/$1"; return 0; } sleep 0.1 done return 1 } @test "rejects invalid session names" { start_server for bad in ../etc "a b" .hidden "$(printf 'x%.0s' {1..70})"; do run devdesk attach "$bad" [ "$status" -ne 0 ] [[ $output == *"invalid session name"* ]] done run devdesk attach -x [ "$status" -ne 0 ] [[ $output == *"unknown option"* ]] run devdesk attach one two [ "$status" -ne 0 ] } @test "fails clearly when the tmux server is down" { run devdesk new alpha [ "$status" -ne 0 ] [[ $output == *"tmux server is not running"* ]] } @test "new session gets claude and shell windows in the project dir" { start_server run devdesk new alpha [ "$status" -eq 0 ] run tmux -S "$DEVDESK_SOCKET" list-windows -t =alpha -F '#{window_name}:#{pane_current_path}' [ "${lines[0]}" = "claude:$DEVDESK_PROJECTS_ROOT/alpha" ] [ "${lines[1]}" = "shell:$DEVDESK_PROJECTS_ROOT/alpha" ] run claude_args alpha [ "$output" = "-n alpha --settings $REPO/share/claude-settings.json" ] run jq -r '.sessions.alpha.dir' "$DEVDESK_STATE_DIR/registry.json" [ "$output" = "$DEVDESK_PROJECTS_ROOT/alpha" ] } @test "creating an existing session is a no-op" { start_server devdesk new alpha run devdesk new alpha [ "$status" -eq 0 ] run tmux -S "$DEVDESK_SOCKET" list-sessions -F '#{session_name}' [ "$output" = "alpha" ] } @test "default session maps to the projects root" { start_server devdesk new main run tmux -S "$DEVDESK_SOCKET" display -p -t =main:claude '#{pane_current_path}' [ "$output" = "$DEVDESK_PROJECTS_ROOT" ] } @test "directories with spaces are reachable by slug" { start_server run devdesk new test-code [ "$status" -eq 0 ] run tmux -S "$DEVDESK_SOCKET" display -p -t =test-code:claude '#{pane_current_path}' [ "$output" = "$DEVDESK_PROJECTS_ROOT/test code" ] } @test "sessions without a project start in the default folder" { start_server run devdesk new pbs-upgrade [ "$status" -eq 0 ] run tmux -S "$DEVDESK_SOCKET" display -p -t =pbs-upgrade:claude '#{pane_current_path}' [ "$output" = "$DEVDESK_PROJECTS_ROOT" ] } @test "symlinks may not escape the projects root" { start_server run devdesk new escape [ "$status" -eq 0 ] run tmux -S "$DEVDESK_SOCKET" display -p -t =escape:claude '#{pane_current_path}' [ "$output" = "$DEVDESK_PROJECTS_ROOT" ] run devdesk new other escape [ "$status" -ne 0 ] [[ $output == *"no project 'escape'"* ]] } @test "new NAME PROJECT starts the session in that project" { start_server run devdesk new ops-alpha alpha [ "$status" -eq 0 ] run tmux -S "$DEVDESK_SOCKET" display -p -t =ops-alpha:claude '#{pane_current_path}' [ "$output" = "$DEVDESK_PROJECTS_ROOT/alpha" ] run devdesk new ops-alpha alpha [ "$status" -ne 0 ] [[ $output == *"already exists"* ]] } @test "rename moves a running session, its registry entry and its context" { start_server devdesk new alpha echo '{"session_id":"abc-123","source":"startup"}' | DEVDESK_SESSION=alpha "$REPO/libexec/devdesk-session-hook" mkdir -p "$DEVDESK_STATE_DIR/context" && echo '{"used_pct":12}' >"$DEVDESK_STATE_DIR/context/alpha.json" run devdesk rename alpha ops [ "$status" -eq 0 ] run tmux -S "$DEVDESK_SOCKET" has-session -t =ops [ "$status" -eq 0 ] run tmux -S "$DEVDESK_SOCKET" show-environment -t =ops DEVDESK_SESSION [ "$output" = "DEVDESK_SESSION=ops" ] [ "$(jq -r '.sessions.ops.claude_session_id' "$DEVDESK_STATE_DIR/registry.json")" = abc-123 ] [ "$(jq -r '.sessions | has("alpha")' "$DEVDESK_STATE_DIR/registry.json")" = false ] [ -f "$DEVDESK_STATE_DIR/context/ops.json" ] [ ! -e "$DEVDESK_STATE_DIR/context/alpha.json" ] } @test "rename refuses clashes and unknown sessions" { start_server devdesk new alpha devdesk new main run devdesk rename alpha main [ "$status" -ne 0 ] [[ $output == *"already exists"* ]] run devdesk rename nosuch other [ "$status" -ne 0 ] [[ $output == *"no session named"* ]] run devdesk rename alpha "bad name" [ "$status" -ne 0 ] } @test "rename works for dormant sessions" { echo '{"session_id":"old-1","source":"resume"}' | DEVDESK_SESSION=gone "$REPO/libexec/devdesk-session-hook" jq '.sessions.gone.dir = "/tmp"' "$DEVDESK_STATE_DIR/registry.json" >"$TMP/r" && mv "$TMP/r" "$DEVDESK_STATE_DIR/registry.json" run devdesk rename gone archived [ "$status" -eq 0 ] [ "$(jq -r '.sessions.archived.claude_session_id' "$DEVDESK_STATE_DIR/registry.json")" = old-1 ] } @test "hooks follow the tmux session name after a rename" { start_server devdesk new alpha devdesk rename alpha renamed pane=$(tmux -S "$DEVDESK_SOCKET" display -p -t =renamed:shell '#{pane_id}') echo '{"session_id":"after-rename","source":"clear"}' | TMUX="$DEVDESK_SOCKET,1,0" TMUX_PANE="$pane" DEVDESK_SESSION=alpha "$REPO/libexec/devdesk-session-hook" [ "$(jq -r '.sessions.renamed.claude_session_id' "$DEVDESK_STATE_DIR/registry.json")" = after-rename ] [ "$(jq -r '.sessions | has("alpha")' "$DEVDESK_STATE_DIR/registry.json")" = false ] } @test "statusline snapshots context for the session and --list reports it" { start_server devdesk new alpha pane=$(tmux -S "$DEVDESK_SOCKET" display -p -t =alpha:shell '#{pane_id}') status_json='{"session_id":"s1","model":{"id":"m","display_name":"Opus"},"effort":{"level":"high"}, "context_window":{"context_window_size":1000000,"used_percentage":34.6, "current_usage":{"input_tokens":1000,"cache_creation_input_tokens":2000,"cache_read_input_tokens":343000}}, "cost":{"total_cost_usd":1.5,"total_lines_added":10,"total_lines_removed":2}}' run bash -c "echo '$status_json' | TMUX='$DEVDESK_SOCKET,1,0' TMUX_PANE='$pane' '$REPO/libexec/devdesk-statusline'" [ "$status" -eq 0 ] [[ $output == *"alpha"* && $output == *"ctx 34%"* ]] run devdesk attach --list [ "$(jq -r '.sessions[] | select(.name=="alpha") | .context.used_tokens' <<<"$output")" = 346000 ] [ "$(jq -r '.sessions[] | select(.name=="alpha") | .context.model' <<<"$output")" = Opus ] } @test "web actions run through attach and answer with JSON" { start_server run devdesk attach --new web1 alpha [ "$output" = '{"ok":true}' ] run devdesk attach --rename web1 web2 [ "$output" = '{"ok":true}' ] run devdesk attach --end web2 [ "$output" = '{"ok":true}' ] run tmux -S "$DEVDESK_SOCKET" has-session -t =web2 [ "$status" -ne 0 ] run devdesk attach --bogus [ "$status" -ne 0 ] } @test "session hook records the Claude session and the next start resumes it" { start_server echo '{"session_id":"abc-123","source":"startup"}' | DEVDESK_SESSION=alpha "$REPO/libexec/devdesk-session-hook" run jq -r '.sessions.alpha.claude_session_id' "$DEVDESK_STATE_DIR/registry.json" [ "$output" = "abc-123" ] devdesk new alpha run claude_args alpha [ "$output" = "--resume abc-123 --settings $REPO/share/claude-settings.json" ] } @test "session hook ignores sessions it does not own" { echo '{"session_id":"zzz"}' | DEVDESK_SESSION= "$REPO/libexec/devdesk-session-hook" echo '{"session_id":"zzz"}' | DEVDESK_SESSION='../x' "$REPO/libexec/devdesk-session-hook" [ ! -e "$DEVDESK_STATE_DIR/registry.json" ] } @test "--list reports running, dormant and project entries" { start_server devdesk new alpha echo '{"session_id":"old-1","source":"resume"}' | DEVDESK_SESSION=gone "$REPO/libexec/devdesk-session-hook" run devdesk attach --list [ "$status" -eq 0 ] [ "$(jq -r '.default' <<<"$output")" = main ] [ "$(jq -r '.sessions[] | select(.name=="alpha") | .running' <<<"$output")" = true ] [ "$(jq -r '.sessions[] | select(.name=="gone") | .running' <<<"$output")" = false ] [ "$(jq -r '.sessions[] | select(.name=="gone") | .claude_session_id' <<<"$output")" = old-1 ] [ "$(jq -r '[.projects[].name] | sort | join(",")' <<<"$output")" = "alpha,main,test-code" ] } @test "--list works with the server down" { run devdesk attach --list [ "$status" -eq 0 ] [ "$(jq -r '.sessions | length' <<<"$output")" = 0 ] } @test "end kills the session and forgets it" { start_server devdesk new alpha run devdesk end alpha [ "$status" -eq 0 ] run tmux -S "$DEVDESK_SOCKET" has-session -t =alpha [ "$status" -ne 0 ] run jq -r '.sessions | has("alpha")' "$DEVDESK_STATE_DIR/registry.json" [ "$output" = false ] } @test "remote control flag is passed when enabled" { start_server tmux -S "$DEVDESK_SOCKET" set-environment -g DEVDESK_REMOTE_CONTROL 1 devdesk new alpha run claude_args alpha [ "$output" = "-n alpha --settings $REPO/share/claude-settings.json --remote-control devdesk-alpha" ] }