#!/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 DEVDESK_MATRIX_WEBHOOK_FILE="$TMP/no-webhook" # never the real room 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 "hooks ignore Claude running in some other tmux server" { start_server other="$TMP/other.sock" tmux -S "$other" new-session -d -s stranger 'sleep 60' pane=$(tmux -S "$other" display -p -t =stranger '#{pane_id}') echo '{"session_id":"nope","source":"startup"}' | TMUX="$other,1,0" TMUX_PANE="$pane" "$REPO/libexec/devdesk-session-hook" echo '{"model":{"display_name":"Opus"},"context_window":{}}' | TMUX="$other,1,0" TMUX_PANE="$pane" "$REPO/libexec/devdesk-statusline" >/dev/null tmux -S "$other" kill-server [ ! -e "$DEVDESK_STATE_DIR/registry.json" ] || [ "$(jq -r '.sessions | has("stranger")' "$DEVDESK_STATE_DIR/registry.json")" = false ] [ ! -e "$DEVDESK_STATE_DIR/context/stranger.json" ] } @test "repo reports git state and recognises Gitea remotes" { export DEVDESK_GITEA_URL=http://127.0.0.1:9 # nothing listens: CI lookups fail fast repo="$DEVDESK_PROJECTS_ROOT/alpha" git -C "$repo" init -q -b main git -C "$repo" -c user.name=t -c user.email=t@t commit -q --allow-empty -m init git -C "$repo" remote add origin gitea@10.10.10.63:LotusGuild/alpha.git touch "$repo/new-file" run devdesk attach --repo alpha [ "$status" -eq 0 ] [ "$(jq -r '.git' <<<"$output")" = true ] [ "$(jq -r '.branch' <<<"$output")" = main ] [ "$(jq -r '.dirty' <<<"$output")" = 1 ] [ "$(jq -r '.repo' <<<"$output")" = LotusGuild/alpha ] [ "$(jq -r '.ci' <<<"$output")" = null ] git -C "$repo" remote set-url origin https://127.0.0.1:9/LotusGuild/alpha-https.git run devdesk repo alpha [ "$(jq -r '.repo' <<<"$output")" = LotusGuild/alpha-https ] git -C "$repo" remote set-url origin https://github.com/someone/else.git run devdesk repo alpha [ "$(jq -r '.repo' <<<"$output")" = null ] run devdesk repo main [ "$(jq -r '.git' <<<"$output")" = false ] } @test "env.local overrides the packaged env" { mkdir -p "$TMP/etc" "$DEVDESK_PROJECTS_ROOT/alpha/ops" printf 'DEVDESK_DEFAULT_DIR=%s\n' "$DEVDESK_PROJECTS_ROOT" >"$TMP/etc/env" printf 'DEVDESK_DEFAULT_DIR=%s\n' "$DEVDESK_PROJECTS_ROOT/alpha/ops" >"$TMP/etc/env.local" export DEVDESK_ENV="$TMP/etc/env" start_server devdesk new adhoc run tmux -S "$DEVDESK_SOCKET" display -p -t =adhoc:claude '#{pane_current_path}' [ "$output" = "$DEVDESK_PROJECTS_ROOT/alpha/ops" ] } @test "state hook tracks working, waiting and idle per session" { start_server devdesk new alpha pane=$(tmux -S "$DEVDESK_SOCKET" display -p -t =alpha:shell '#{pane_id}') hook() { TMUX="$DEVDESK_SOCKET,1,0" TMUX_PANE="$pane" "$REPO/libexec/devdesk-state-hook"; } state() { jq -r '.sessions[] | select(.name=="alpha") | .status.state // "none"' <<<"$(devdesk attach --list)"; } echo '{"hook_event_name":"UserPromptSubmit","prompt":"hi"}' | hook [ "$(state)" = working ] echo '{"hook_event_name":"Notification","message":"Claude needs your permission to use Bash"}' | hook [ "$(state)" = waiting ] [ "$(jq -r '.message' "$DEVDESK_STATE_DIR/state/alpha.json")" = "Claude needs your permission to use Bash" ] echo '{"hook_event_name":"PostToolUse","tool_name":"Bash"}' | hook [ "$(state)" = working ] echo '{"hook_event_name":"Stop"}' | hook [ "$(state)" = idle ] echo '{"hook_event_name":"PreCompact"}' | hook [ "$(state)" = idle ] devdesk rename alpha beta [ -f "$DEVDESK_STATE_DIR/state/beta.json" ] && [ ! -e "$DEVDESK_STATE_DIR/state/alpha.json" ] devdesk end beta [ ! -e "$DEVDESK_STATE_DIR/state/beta.json" ] } # Local stand-in for the hookshot webhook: appends each POST body to $TMP/posts. start_receiver() { python3 - "$TMP" <<'PY' & import http.server, sys tmp = sys.argv[1] class H(http.server.BaseHTTPRequestHandler): def do_POST(self): body = self.rfile.read(int(self.headers['Content-Length'])) with open(f'{tmp}/posts', 'ab') as f: f.write(body + b'\n') self.send_response(200); self.end_headers(); self.wfile.write(b'{"ok":true}') def log_message(self, *a): pass srv = http.server.HTTPServer(('127.0.0.1', 0), H) open(f'{tmp}/port', 'w').write(str(srv.server_port)) srv.serve_forever() PY RECEIVER=$! for _ in $(seq 1 50); do [ -s "$TMP/port" ] && break; sleep 0.1; done printf 'http://127.0.0.1:%s/webhook/test\n' "$(cat "$TMP/port")" >"$TMP/webhook" chmod 600 "$TMP/webhook" export DEVDESK_MATRIX_WEBHOOK_FILE="$TMP/webhook" } posts() { [ -f "$TMP/posts" ] && wc -l <"$TMP/posts" || echo 0; } @test "a wait nobody is attached to is posted to Matrix once (#28)" { start_server start_receiver export DEVDESK_MATRIX_DELAY=1 DEVDESK_PUBLIC_URL=https://dev.example.org devdesk new alpha pane=$(tmux -S "$DEVDESK_SOCKET" display -p -t =alpha:shell '#{pane_id}') hook() { TMUX="$DEVDESK_SOCKET,1,0" TMUX_PANE="$pane" "$REPO/libexec/devdesk-state-hook"; } echo '{"hook_event_name":"Notification","message":"Claude needs your permission to use Bash"}' | hook # a second notification in the same wait: no second message echo '{"hook_event_name":"Notification","message":"Claude is waiting for your input"}' | hook sleep 2.5 [ "$(posts)" -eq 1 ] run jq -r '[.session, .url, (.text | test("Claude needs you in alpha"))] | @tsv' "$TMP/posts" [ "$output" = "$(printf 'alpha\thttps://dev.example.org/?arg=alpha\ttrue')" ] # answered within the delay: nothing is posted echo '{"hook_event_name":"PostToolUse"}' | hook echo '{"hook_event_name":"Notification","message":"Claude needs your permission to use Edit"}' | hook echo '{"hook_event_name":"PostToolUse"}' | hook sleep 2 [ "$(posts)" -eq 1 ] # no webhook file: feature off rm "$TMP/webhook" echo '{"hook_event_name":"Notification","message":"again"}' | hook sleep 2 [ "$(posts)" -eq 1 ] kill "$RECEIVER" } @test "under ttyd, one-shot replies wait for the browser to hang up (#35)" { start_server # No tty (CLI, tests): no waiting. start=$(date +%s%N) DEVDESK_VIA_TTYD=1 DEVDESK_LINGER=2 devdesk attach --list >/dev/null [ $(( ($(date +%s%N) - start) / 1000000 )) -lt 1500 ] # On a tty, as under ttyd: the reply is printed, then it waits. start=$(date +%s%N) run script -qec "DEVDESK_VIA_TTYD=1 DEVDESK_LINGER=0.6 '$REPO/bin/devdesk' attach --list" /dev/null elapsed=$(( ($(date +%s%N) - start) / 1000000 )) [[ $output == *'"sessions"'* ]] [ "$elapsed" -ge 500 ] # Errors are printed before the wait, too. run script -qec "DEVDESK_VIA_TTYD=1 DEVDESK_LINGER=0.3 '$REPO/bin/devdesk' attach --rename nosuch x" /dev/null [[ $output == *"no session named"* ]] } @test "alerts reports unconfigured and unreachable Grafana cleanly" { run env DEVDESK_GRAFANA_URL= devdesk alerts [ "$status" -eq 0 ] [ "$(jq -r '.configured' <<<"$output")" = false ] run env DEVDESK_GRAFANA_URL=http://127.0.0.1:9 devdesk attach --alerts [ "$status" -eq 0 ] [ "$(jq -r '.configured' <<<"$output")" = true ] [ "$(jq -r '.error | length > 0' <<<"$output")" = true ] } @test "upload stores the exact bytes under a sanitised name" { head -c 50001 /dev/urandom >"$TMP/in.bin" run bash -c "base64 -w0 '$TMP/in.bin' | devdesk attach --upload alpha '../../etc/My Shot (1).png' 50001" [ "$status" -eq 0 ] path=$(tail -n1 <<<"$output" | jq -r .path) [[ $path == "$DEVDESK_STATE_DIR/uploads/alpha/"*-My_Shot__1_.png ]] cmp "$TMP/in.bin" "$path" run bash -c "printf short | devdesk attach --upload alpha x.bin 100" [ "$status" -ne 0 ] [[ $output == *"upload incomplete"* ]] run devdesk attach --upload alpha x.bin 999999999999 [ "$status" -ne 0 ] run devdesk attach --upload ../x x.bin 10 [ "$status" -ne 0 ] } @test "end run from inside the session leaves no ghost registry entry (#3)" { start_server devdesk new alpha tmux -S "$DEVDESK_SOCKET" send-keys -t =alpha:shell "$REPO/bin/devdesk end alpha" Enter for _ in $(seq 1 50); do tmux -S "$DEVDESK_SOCKET" has-session -t =alpha 2>/dev/null || break sleep 0.1 done run tmux -S "$DEVDESK_SOCKET" has-session -t =alpha [ "$status" -ne 0 ] [ "$(jq -r '.sessions | has("alpha")' "$DEVDESK_STATE_DIR/registry.json")" = false ] } @test "attach --existing refuses sessions that were ended or renamed (#4)" { start_server run devdesk attach --existing ghost [ "$status" -ne 0 ] [[ $output == *"no longer exists"* ]] run devdesk attach --existing ../x [ "$status" -ne 0 ] } @test "same-second uploads of the same name get distinct files (#6)" { printf 'one' | base64 -w0 >"$TMP/a.b64" printf 'two' | base64 -w0 >"$TMP/b.b64" p1=$(devdesk attach --upload alpha image.png 3 <"$TMP/a.b64" | tail -n1 | jq -r .path) p2=$(devdesk attach --upload alpha image.png 3 <"$TMP/b.b64" | tail -n1 | jq -r .path) [ "$p1" != "$p2" ] [ "$(cat "$p1")" = one ] [ "$(cat "$p2")" = two ] [[ $p1 == *-image.png ]] } @test "a tab in a project path does not break --list (#10)" { mkdir -p "$DEVDESK_PROJECTS_ROOT/odd name" start_server run devdesk attach --list [ "$status" -eq 0 ] jq -e '.projects | map(.dir) | any(endswith("odd\tname"))' <<<"$output" >/dev/null } @test "repo hides credentials embedded in the remote URL (#11)" { export DEVDESK_GITEA_URL=http://127.0.0.1:9 repo="$DEVDESK_PROJECTS_ROOT/alpha" git -C "$repo" init -q -b main git -C "$repo" remote add origin https://jared:hunter2@example.com/x/y.git run devdesk repo alpha [ "$status" -eq 0 ] [ "$(jq -r .remote <<<"$output")" = https://example.com/x/y.git ] [[ $output != *hunter2* ]] } @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" ] }