#!/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 CLAUDE_CONFIG_DIR="$TMP/claude" # never the real conversation history 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" [ -z "${FAKE_EXIT:-}" ] || exit "$FAKE_EXIT" 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&existing=1\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. script's # stdin stays open (like a browser that hasn't hung up); a closed stdin # would hand the command EOF at once. start=$(date +%s%N) run bash -c "sleep 2 | 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 bash -c "sleep 1 | 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 id=abcabcab-1234-1234-1234-123412341234 mkdir -p "$CLAUDE_CONFIG_DIR/projects/-p" && touch "$CLAUDE_CONFIG_DIR/projects/-p/$id.jsonl" echo "{\"session_id\":\"$id\",\"source\":\"startup\"}" | DEVDESK_SESSION=alpha "$REPO/libexec/devdesk-session-hook" run jq -r '.sessions.alpha.claude_session_id' "$DEVDESK_STATE_DIR/registry.json" [ "$output" = "$id" ] devdesk new alpha run claude_args alpha [ "$output" = "--resume $id --settings $REPO/share/claude-settings.json" ] } @test "a conversation whose file is gone starts fresh; a failed resume keeps the id (#12)" { start_server id=abcabcab-1234-1234-1234-123412341234 echo "{\"session_id\":\"$id\"}" | DEVDESK_SESSION=alpha "$REPO/libexec/devdesk-session-hook" devdesk new alpha run claude_args alpha [ "$output" = "-n alpha --settings $REPO/share/claude-settings.json" ] tmux -S "$DEVDESK_SOCKET" kill-server # The file exists, but Claude exits at once (Ctrl+C, network): the menu, not a new conversation. mkdir -p "$CLAUDE_CONFIG_DIR/projects/-p" && touch "$CLAUDE_CONFIG_DIR/projects/-p/$id.jsonl" rm -f "$FAKE_LOG/alpha" FAKE_EXIT=1 start_server devdesk new alpha run claude_args alpha [ "$output" = "--resume $id --settings $REPO/share/claude-settings.json" ] for _ in $(seq 1 30); do tmux -S "$DEVDESK_SOCKET" capture-pane -p -t =alpha:claude | grep -q "Claude exited" && break sleep 0.1 done tmux -S "$DEVDESK_SOCKET" capture-pane -p -t =alpha:claude | grep -q "Claude exited" [ "$(wc -l <"$FAKE_LOG/alpha")" = 1 ] [ "$(jq -r '.sessions.alpha.claude_session_id' "$DEVDESK_STATE_DIR/registry.json")" = "$id" ] } @test "env files are parsed like systemd does, never run (#12)" { cat >"$TMP/env" <<'ENV' # comment ; also a comment DEVDESK_DEFAULT_DIR=/srv/ops CLAUDE_ARGS=--model opus --verbose DEVDESK_PUBLIC_URL="https://dev.example.org" DEVDESK_GRAFANA_URL='http://g:3000' DEVDESK_MATRIX_DELAY=$(touch /tmp/devdesk-env-pwned) lowercase=x not a line DEVDESK_BIND="unterminated ENV run env DEVDESK_ENV="$TMP/env" DEVDESK_ENV_LOCAL=/dev/null bash -c '. "$1/libexec/devdesk-lib.sh"; printf "%s|" "$DEVDESK_DEFAULT_DIR" "$CLAUDE_ARGS" "$DEVDESK_PUBLIC_URL" "$DEVDESK_GRAFANA_URL" "$DEVDESK_MATRIX_DELAY" "${lowercase:-unset}" "${DEVDESK_BIND:-unset}"' _ "$REPO" [ ! -e /tmp/devdesk-env-pwned ] [[ $output == *'/srv/ops|--model opus --verbose|https://dev.example.org|http://g:3000|$(touch /tmp/devdesk-env-pwned)|unset|unset|'* ]] [[ $output == *"env:8: not a KEY=value line"* ]] [[ $output == *"env:10: unmatched quote"* ]] } @test "the SessionStart hook reads the session name under the rename lock (#12)" { start_server devdesk new alpha claude_args alpha >/dev/null pane=$(tmux -S "$DEVDESK_SOCKET" display -p -t =alpha:claude '#{pane_id}') # A rename holds the lock; the hook fires meanwhile, while tmux still says "alpha". ( flock 9 sleep 0.5 tmux -S "$DEVDESK_SOCKET" rename-session -t =alpha beta DEVDESK_ENV=/dev/null bash -c '. "$1/libexec/devdesk-lib.sh"; DD_REGISTRY_LOCKED=1 dd_registry_rename alpha beta' _ "$REPO" ) 9>"$DEVDESK_STATE_DIR/registry.json.lock" 3>&- & renamer=$! sleep 0.1 echo '{"session_id":"ffffffff-0000-0000-0000-000000000000","source":"clear"}' | TMUX="$DEVDESK_SOCKET,1,0" TMUX_PANE=$pane "$REPO/libexec/devdesk-session-hook" wait "$renamer" [ "$(jq -r '.sessions | keys | join(",")' "$DEVDESK_STATE_DIR/registry.json")" = beta ] [ "$(jq -r '.sessions.beta.claude_session_id' "$DEVDESK_STATE_DIR/registry.json")" = ffffffff-0000-0000-0000-000000000000 ] } @test "leftover temp files of killed writers are swept (#12)" { mkdir -p "$DEVDESK_STATE_DIR/state" "$DEVDESK_STATE_DIR/context" touch -d '20 minutes ago' "$DEVDESK_STATE_DIR/state/.alpha.Ab12Cd" "$DEVDESK_STATE_DIR/context/.alpha.Xy34Zw" "$DEVDESK_STATE_DIR/registry.json.Qq11Rr" touch "$DEVDESK_STATE_DIR/state/.beta.Ab12Cd" "$DEVDESK_STATE_DIR/state/alpha.json" echo '{"session_id":"x"}' | DEVDESK_SESSION=alpha "$REPO/libexec/devdesk-session-hook" [ ! -e "$DEVDESK_STATE_DIR/state/.alpha.Ab12Cd" ] [ ! -e "$DEVDESK_STATE_DIR/context/.alpha.Xy34Zw" ] [ ! -e "$DEVDESK_STATE_DIR/registry.json.Qq11Rr" ] [ -e "$DEVDESK_STATE_DIR/state/.beta.Ab12Cd" ] [ -e "$DEVDESK_STATE_DIR/state/alpha.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" ] } # Decode a blob reply (header line, base64 between markers) into $TMP/blob. blob_body() { sed -n '/^DEVDESK-BLOB-BEGIN$/,/^DEVDESK-BLOB-END$/p' <<<"$1" | sed '1d;$d' | base64 -d >"$TMP/blob" } @test "fs-list lists one level, folders first, and stays inside the session folder (#19)" { p="$DEVDESK_PROJECTS_ROOT/alpha" mkdir -p "$p/src/deep" "$p/.git" echo hi >"$p/README.md" echo x >"$p/src/a.sh" ln -s "$TMP/outside" "$p/escape-link" ln -s src "$p/src-link" run devdesk attach --fs-list alpha [ "$status" -eq 0 ] # Folders first (a symlink to a folder counts, even one that escapes: opening it fails). [ "$(jq -r '[.entries[].n] | join(",")' <<<"$output")" = "escape-link,src,src-link,README.md" ] run devdesk attach --fs-list alpha src [ "$(jq -r '.dir + ":" + ([.entries[].n] | join(","))' <<<"$output")" = "src:deep,a.sh" ] for bad in ../ /etc escape-link "src/../../alpha/../../outside"; do run devdesk attach --fs-list alpha "$bad" [ "$status" -ne 0 ] done run devdesk attach --fs-list alpha src-link [ "$status" -eq 0 ] } @test "fs-read returns text, images and refuses binaries, big files and escapes (#19)" { p="$DEVDESK_PROJECTS_ROOT/alpha" printf 'line 1\r\nüñí\n' >"$p/notes.md" printf '\x89PNG\r\n\x1a\n\0\0\0' >"$p/pic.png" head -c 100 /dev/zero >"$p/blob.bin" head -c 3000 /dev/zero | tr '\0' a >"$p/big.txt" run devdesk attach --fs-read alpha notes.md [ "$status" -eq 0 ] [ "$(head -n1 <<<"$output" | jq -r '[.path, .binary, .utf8, .body] | @tsv')" = "$(printf 'notes.md\tfalse\ttrue\ttrue')" ] blob_body "$output" cmp "$TMP/blob" "$p/notes.md" run devdesk attach --fs-read alpha pic.png [ "$(head -n1 <<<"$output" | jq -r '[.mime, .body] | @tsv')" = "$(printf 'image/png\ttrue')" ] run devdesk attach --fs-read alpha blob.bin [ "$(head -n1 <<<"$output" | jq -r '[.binary, .body] | @tsv')" = "$(printf 'true\tfalse')" ] run env DEVDESK_FS_MAX=1000 "$REPO/bin/devdesk" attach --fs-read alpha big.txt [ "$(head -n1 <<<"$output" | jq -r '.tooLarge')" = true ] run devdesk attach --fs-read alpha ../alpha/../../outside [ "$status" -ne 0 ] } @test "git-status and git-show report changes, line counts and Claude's edits (#19)" { p="$DEVDESK_PROJECTS_ROOT/alpha" git -C "$p" init -q -b main git -C "$p" -c user.name=t -c user.email=t@t commit -q --allow-empty -m init printf 'one\ntwo\n' >"$p/kept.txt" printf 'old\n' >"$p/moved.txt" git -C "$p" add . && git -C "$p" -c user.name=t -c user.email=t@t commit -q -m files printf 'one\nTWO\nthree\n' >"$p/kept.txt" git -C "$p" mv moved.txt renamed.txt echo new >"$p/fresh.txt" mkdir -p "$DEVDESK_STATE_DIR/edits" printf '%s\n' "$p/kept.txt" "$p/notes-outside-git.txt" >"$DEVDESK_STATE_DIR/edits/alpha.txt" run devdesk attach --git-status alpha [ "$status" -eq 0 ] [ "$(jq -r '.files | map("\(.x)\(.y) \(.path) +\(.add) -\(.del) \(.claude)") | sort | join("|")' <<<"$output")" = \ " notes-outside-git.txt +null -null true| M kept.txt +2 -1 true|?? fresh.txt +null -null false|R renamed.txt +null -null false" ] [ "$(jq -r '.files[] | select(.path == "renamed.txt") | .from' <<<"$output")" = moved.txt ] run devdesk attach --git-show alpha kept.txt blob_body "$output" [ "$(cat "$TMP/blob")" = "$(printf 'one\ntwo')" ] run devdesk attach --git-show alpha fresh.txt [ "$(head -n1 <<<"$output" | jq -r '.size')" = 0 ] run devdesk attach --git-status alpha run env DEVDESK_PROJECTS_ROOT="$TMP/outside" "$REPO/bin/devdesk" attach --git-status alpha [ "$status" -eq 0 ] } @test "the state hook logs Claude's edits; rename moves and end removes the log (#19)" { 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"; } echo '{"hook_event_name":"PostToolUse","tool_name":"Edit","tool_input":{"file_path":"/x/a.txt"}}' | hook echo '{"hook_event_name":"PostToolUse","tool_name":"Write","tool_input":{"file_path":"/x/a.txt"}}' | hook echo '{"hook_event_name":"PostToolUse","tool_name":"Bash","tool_input":{"command":"ls"}}' | hook echo '{"hook_event_name":"PostToolUse","tool_name":"NotebookEdit","tool_input":{"notebook_path":"/x/n.ipynb"}}' | hook [ "$(cat "$DEVDESK_STATE_DIR/edits/alpha.txt")" = "$(printf '/x/a.txt\n/x/n.ipynb')" ] devdesk rename alpha beta [ -f "$DEVDESK_STATE_DIR/edits/beta.txt" ] && [ ! -e "$DEVDESK_STATE_DIR/edits/alpha.txt" ] devdesk end beta [ ! -e "$DEVDESK_STATE_DIR/edits/beta.txt" ] } @test "asset serves only named .js files from the assets folder (#19)" { mkdir -p "$TMP/assets" echo 'window.X = 1;' >"$TMP/assets/editor.js" run env DEVDESK_ASSETS="$TMP/assets" "$REPO/bin/devdesk" attach --asset editor.js [ "$status" -eq 0 ] blob_body "$output" cmp "$TMP/blob" "$TMP/assets/editor.js" for bad in ../etc/passwd editor.css nope.js; do run env DEVDESK_ASSETS="$TMP/assets" "$REPO/bin/devdesk" attach --asset "$bad" [ "$status" -ne 0 ] done } @test "fs-list flags gitignored entries; fs-find lists files for go-to-file (#37)" { p="$DEVDESK_PROJECTS_ROOT/alpha" git -C "$p" init -q -b main mkdir -p "$p/src" "$p/build" "$p/node_modules/x" printf 'build/\n*.log\n' >"$p/.gitignore" echo a >"$p/src/a.js" echo b >"$p/debug.log" echo c >"$p/node_modules/x/i.js" run devdesk attach --fs-list alpha [ "$(jq -r '[.entries[] | select(.ig) | .n] | join(",")' <<<"$output")" = "build,debug.log" ] run devdesk attach --fs-find alpha [ "$(jq -r '.files | sort | join(",")' <<<"$output")" = ".gitignore,node_modules/x/i.js,src/a.js" ] # Outside git: find, without node_modules rm -rf "$p/.git" run devdesk attach --fs-find alpha [ "$(jq -r '.files | sort | join(",")' <<<"$output")" = ".gitignore,debug.log,src/a.js" ] # Hitting the cap must not fail the command (SIGPIPE in the producer). for i in $(seq 1 3000); do : >"$p/src/f$i.txt"; done run env DEVDESK_FS_FIND_MAX=1 "$REPO/bin/devdesk" attach --fs-find alpha [ "$status" -eq 0 ] [ "$(jq -r '[(.files | length), .truncated] | @tsv' <<<"$output")" = "$(printf '1\ttrue')" ] run env DEVDESK_FS_LIST_MAX=5 "$REPO/bin/devdesk" attach --fs-list alpha src [ "$status" -eq 0 ] [ "$(jq -r '[(.entries | length), .truncated] | @tsv' <<<"$output")" = "$(printf '5\ttrue')" ] } # Save through --fs-write the way the browser does: content as base64 on stdin. fs_write() { # SESSION PATH BASE CONTENT-FILE local size size=$(stat -c %s "$4") base64 -w0 "$4" | devdesk attach --fs-write "$1" "$2" "$size" "$3" } @test "fs-write saves atomically, keeps the mode, and refuses stale bases (#20)" { p="$DEVDESK_PROJECTS_ROOT/alpha" printf 'v1\n' >"$p/conf.sh" chmod 0750 "$p/conf.sh" base=$(sha256sum "$p/conf.sh" | cut -d' ' -f1) printf 'v2\n' >"$TMP/new" run fs_write alpha conf.sh "$base" "$TMP/new" [ "$status" -eq 0 ] [ "$(tail -n1 <<<"$output" | jq -r '.ok')" = true ] [ "$(cat "$p/conf.sh")" = v2 ] [ "$(stat -c %a "$p/conf.sh")" = 750 ] [ "$(tail -n1 <<<"$output" | jq -r '.sha')" = "$(sha256sum "$p/conf.sh" | cut -d' ' -f1)" ] # Someone (Claude) changed it since: the old base no longer matches. printf 'v3 from me\n' >"$TMP/mine" run fs_write alpha conf.sh "$base" "$TMP/mine" [ "$(tail -n1 <<<"$output" | jq -r '[.ok, .conflict] | @tsv')" = "$(printf 'false\ttrue')" ] [ "$(cat "$p/conf.sh")" = v2 ] # No temp files left behind [ -z "$(find "$p" -name '*.devdesk-*')" ] # Empty files are fine : >"$TMP/empty" base=$(sha256sum "$p/conf.sh" | cut -d' ' -f1) run fs_write alpha conf.sh "$base" "$TMP/empty" [ "$(tail -n1 <<<"$output" | jq -r '.ok')" = true ] [ ! -s "$p/conf.sh" ] } @test "fs-write creates new files (and folders) but never overwrites or escapes (#20)" { p="$DEVDESK_PROJECTS_ROOT/alpha" printf 'hello\n' >"$TMP/c" run fs_write alpha docs/notes/new.md new "$TMP/c" [ "$(tail -n1 <<<"$output" | jq -r '[.ok, .path] | @tsv')" = "$(printf 'true\tdocs/notes/new.md')" ] [ "$(cat "$p/docs/notes/new.md")" = hello ] run fs_write alpha docs/notes/new.md new "$TMP/c" [ "$status" -ne 0 ] [[ $output == *"already exists"* ]] ln -s "$TMP/outside" "$p/out" for bad in ../escape.txt /etc/x out/pwned.txt out/newdir/x.txt docs/ ""; do run fs_write alpha "$bad" new "$TMP/c" [ "$status" -ne 0 ] done [ ! -e "$TMP/outside/pwned.txt" ] && [ ! -e "$TMP/outside/newdir" ] && [ ! -e "$DEVDESK_PROJECTS_ROOT/escape.txt" ] # A short stream fails without touching anything run bash -c "printf aGVs | '$REPO/bin/devdesk' attach --fs-write alpha docs/notes/new.md 6 $(sha256sum "$p/docs/notes/new.md" | cut -d' ' -f1)" [ "$status" -ne 0 ] [ "$(cat "$p/docs/notes/new.md")" = hello ] } @test "permission requests are recorded and answered only while their prompt shows (#22)" { start_server devdesk new alpha claude_args alpha >/dev/null 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":"PermissionRequest","tool_name":"Bash","tool_input":{"command":"rm -rf /tmp/x","description":"Remove x"}}' | hook echo '{"hook_event_name":"Notification","message":"Claude needs your permission","notification_type":"permission_prompt"}' | hook st="$DEVDESK_STATE_DIR/state/alpha.json" [ "$(jq -r '[.state, .request.tool, .request.detail, .request.description, .message] | @tsv' "$st")" = \ "$(printf 'waiting\tBash\trm -rf /tmp/x\tRemove x\tClaude wants to use Bash')" ] since=$(jq -r .since "$st") # The fake Claude window shows no prompt: refused, nothing typed. run devdesk attach --answer alpha "$since" allow [ "$status" -ne 0 ] [[ $output == *"isn't on screen"* ]] # Put a prompt on screen (what Claude draws), then allow. tmux -S "$DEVDESK_SOCKET" respawn-window -k -t =alpha:claude "printf ' Do you want to proceed?\n ❯ 1. Yes\n 2. No\n'; read -r -n1 k; echo \"got:\$k\" > '$TMP/key'; sleep 30" sleep 0.5 run devdesk attach --answer alpha 999 allow [ "$status" -ne 0 ] [[ $output == *"already answered"* ]] run devdesk attach --answer alpha "$since" allow [ "$status" -eq 0 ] sleep 0.5 [ "$(cat "$TMP/key")" = "got:1" ] [ "$(jq -r .state "$st")" = working ] # Answered: a second click is refused. run devdesk attach --answer alpha "$since" deny [ "$status" -ne 0 ] } @test "peek shows the last exchange; reply pastes into the Claude window (#23)" { start_server devdesk new alpha claude_args alpha >/dev/null export CLAUDE_CONFIG_DIR="$TMP/claude" id=11111111-2222-3333-4444-555555555555 mkdir -p "$CLAUDE_CONFIG_DIR/projects/-x" cat >"$CLAUDE_CONFIG_DIR/projects/-x/$id.jsonl" <<'JSONL' {"type":"user","message":{"role":"user","content":"Please upgrade PBS"},"timestamp":"2026-10-10T10:00:00Z"} {"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Bash","input":{}}]},"timestamp":"2026-10-10T10:00:05Z"} {"type":"user","message":{"role":"user","content":[{"type":"tool_result","content":"ok"}]},"timestamp":"2026-10-10T10:00:06Z"} {"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"PBS is on 4.1. Reboot now?"}]},"timestamp":"2026-10-10T10:00:09Z"} JSONL jq --arg id "$id" '.sessions.alpha.claude_session_id = $id' "$DEVDESK_STATE_DIR/registry.json" >"$TMP/r" && mv "$TMP/r" "$DEVDESK_STATE_DIR/registry.json" run devdesk attach --peek alpha [ "$status" -eq 0 ] [ "$(jq -r '[.assistant.text, .user.text] | @tsv' <<<"$output")" = "$(printf 'PBS is on 4.1. Reboot now?\tPlease upgrade PBS')" ] # reply: lands in the Claude window as one paste + Enter tmux -S "$DEVDESK_SOCKET" respawn-window -k -t =alpha:claude "IFS= read -r line; printf '%s' \"\$line\" > '$TMP/got'; sleep 30" sleep 0.3 printf 'yes, reboot it' >"$TMP/msg" run bash -c "base64 -w0 '$TMP/msg' | '$REPO/bin/devdesk' attach --reply alpha 14" [ "$status" -eq 0 ] sleep 0.5 [ "$(cat "$TMP/got")" = "yes, reboot it" ] # not while a permission prompt waits mkdir -p "$DEVDESK_STATE_DIR/state" printf '{"state":"waiting","since":1,"request":{"tool":"Bash"}}\n' >"$DEVDESK_STATE_DIR/state/alpha.json" run bash -c "base64 -w0 '$TMP/msg' | '$REPO/bin/devdesk' attach --reply alpha 14" [ "$status" -ne 0 ] [[ $output == *"answer that first"* ]] } @test "the guard asks before destructive commands and lets the rest through (#27)" { guard() { jq -cn --arg c "$1" '{hook_event_name: "PreToolUse", tool_name: "Bash", tool_input: {command: $c}}' | "$REPO/libexec/devdesk-guard"; } asks=("pct destroy 105" "ssh root@large1 'pct destroy 105 --purge'" "qm destroy 210" "zfs destroy rpool/data/vm-105-disk-0" "zpool destroy tank" "ceph osd pool delete rbd rbd --yes-i-really-really-mean-it" "rbd rm vm-105-disk-0" "rm -rf /" "rm -rf /etc" "sudo rm -r -f /var/" "dd if=/dev/zero of=/dev/sdb bs=1M" "mkfs.ext4 /dev/sdb1" "wipefs -a /dev/sdc" "git push --force origin main" "git push -f" "psql -c 'drop table users'" "systemctl restart devdesk-tmux" "ssh pve1 reboot" "systemctl poweroff" "cd /tmp && shutdown -h now") for c in "${asks[@]}"; do run guard "$c" [ "$(jq -r '.hookSpecificOutput.permissionDecision' <<<"$output")" = ask ] || { echo "should ask: $c"; false; } done passes=("pct list" "pct exec 105 -- df -h" "zfs list" "rm -rf /tmp/build-cache" "rm -rf node_modules" "rm -rf ./dist" "git push origin main" "dd if=a.img of=b.img" "echo reboot later" "systemctl restart devdesk" "ceph -s" "ls -la /") for c in "${passes[@]}"; do run guard "$c" [ -z "$output" ] || { echo "should pass: $c -> $output"; false; } done # Local rules and the off switch printf 'nuke-it\tis a local rule\n' >"$TMP/guard.local" run env DEVDESK_GUARD_LOCAL="$TMP/guard.local" bash -c "jq -cn '{tool_input: {command: \"nuke-it now\"}}' | '$REPO/libexec/devdesk-guard'" [[ $output == *"is a local rule"* ]] run env DEVDESK_GUARD=0 bash -c "jq -cn '{tool_input: {command: \"pct destroy 1\"}}' | '$REPO/libexec/devdesk-guard'" [ -z "$output" ] } @test "Bash commands are logged and tag the session with the hosts it works on (#27)" { start_server devdesk new alpha pane=$(tmux -S "$DEVDESK_SOCKET" display -p -t =alpha:shell '#{pane_id}') bash_cmd() { jq -cn --arg c "$1" '{hook_event_name: "PostToolUse", tool_name: "Bash", tool_input: {command: $c}}' | TMUX="$DEVDESK_SOCKET,1,0" TMUX_PANE="$pane" "$REPO/libexec/devdesk-state-hook"; } bash_cmd "ssh -o BatchMode=yes root@large1 'pct exec 167 -- systemctl status authelia'" bash_cmd "ls -la" bash_cmd "ssh pve2 qm status 210" [ "$(wc -l <"$DEVDESK_STATE_DIR/commands/alpha.jsonl")" -eq 3 ] run devdesk attach --list [ "$(jq -r '.sessions[] | select(.name == "alpha") | .tags | join(",")' <<<"$output")" = "pve2,VM 210,large1,CT 167" ] run devdesk attach --peek alpha [ "$(jq -r '.commands | map(.cmd) | .[1]' <<<"$output")" = "ls -la" ] devdesk rename alpha beta [ -f "$DEVDESK_STATE_DIR/commands/beta.jsonl" ] && [ -f "$DEVDESK_STATE_DIR/tags/beta.json" ] devdesk end beta [ ! -e "$DEVDESK_STATE_DIR/commands/beta.jsonl" ] && [ ! -e "$DEVDESK_STATE_DIR/tags/beta.json" ] } # A fake Claude history: one conversation in $1 with id $2. fake_convo() { mkdir -p "$CLAUDE_CONFIG_DIR/projects/-p" jq -cn --arg cwd "$1" --arg id "$2" ' {type: "user", cwd: $cwd, sessionId: $id, timestamp: "2026-08-01T10:00:00Z", message: {role: "user", content: "Fix the NFS mount on pbs"}}, {type: "assistant", timestamp: "2026-08-01T10:00:05Z", message: {role: "assistant", content: [{type: "tool_use", name: "Bash", input: {command: "mount -t nfs 10.10.10.9:/backup /mnt/pbs"}}]}}, {type: "assistant", timestamp: "2026-08-01T10:00:09Z", message: {role: "assistant", content: [{type: "text", text: "The NFS export needed no_root_squash."}]}}' \ >"$CLAUDE_CONFIG_DIR/projects/-p/$2.jsonl" } @test "search finds past conversations by what was said and run (#24)" { export CLAUDE_CONFIG_DIR="$TMP/claude" fake_convo "$DEVDESK_PROJECTS_ROOT/alpha" 11111111-1111-1111-1111-111111111111 run devdesk attach --search "no_root_squash" [ "$status" -eq 0 ] [ "$(jq -r '.results[0] | [.id, .title, .cwd, .matches[0].role] | @tsv' <<<"$output")" = \ "$(printf '11111111-1111-1111-1111-111111111111\tFix the NFS mount on pbs\t%s\tclaude' "$DEVDESK_PROJECTS_ROOT/alpha")" ] run devdesk attach --search "MOUNT -t NFS" [ "$(jq -r '.results[0].matches[0].role' <<<"$output")" = command ] run devdesk attach --search "nothing like this anywhere" [ "$(jq -r '.results | length' <<<"$output")" = 0 ] run devdesk attach --search "x" [ "$status" -ne 0 ] } @test "resume-convo opens a past conversation in its folder; fork is passed once (#24)" { export CLAUDE_CONFIG_DIR="$TMP/claude" id=22222222-2222-2222-2222-222222222222 fake_convo "$DEVDESK_PROJECTS_ROOT/alpha" "$id" start_server run devdesk attach --resume-convo nfs-fix "$id" fork [ "$status" -eq 0 ] claude_args nfs-fix >/dev/null [[ "$(claude_args nfs-fix)" == "--resume $id --fork-session --settings"* ]] [ "$(tmux -S "$DEVDESK_SOCKET" display -p -t =nfs-fix:claude '#{pane_current_path}')" = "$DEVDESK_PROJECTS_ROOT/alpha" ] [ "$(jq -r '.sessions["nfs-fix"].fork // "cleared"' "$DEVDESK_STATE_DIR/registry.json")" = cleared ] # Resuming (not forking) the same conversation while it's open elsewhere is refused. jq --arg id "$id" '.sessions["nfs-fix"].claude_session_id = $id' "$DEVDESK_STATE_DIR/registry.json" >"$TMP/r" && mv "$TMP/r" "$DEVDESK_STATE_DIR/registry.json" run devdesk attach --resume-convo nfs-again "$id" [ "$status" -ne 0 ] [[ $output == *"open in session nfs-fix"* ]] run devdesk attach --resume-convo nfs-fix "$id" fork [ "$status" -ne 0 ] [[ $output == *"already running"* ]] } @test "presence marks a session while a tab shows it (#26)" { export DEVDESK_PRESENCE_DIR="$TMP/presence" start_server devdesk new alpha claude_args alpha >/dev/null sid=$(tmux -S "$DEVDESK_SOCKET" display -p -t =alpha: '#{session_id}') run devdesk attach --presence alpha on [ "$status" -eq 0 ] [ -e "$TMP/presence/${sid#\$}" ] # The Claude window was told where the marker lives. [ "$(tmux -S "$DEVDESK_SOCKET" show-environment -t =alpha CLAUDE_CLIENT_PRESENCE_FILE 2>/dev/null || true)" = "" ] # set per process, not per session run devdesk attach --presence alpha off [ ! -e "$TMP/presence/${sid#\$}" ] }