- devdesk-state-hook (Claude UserPromptSubmit/PostToolUse/Notification/Stop/ SessionStart/SessionEnd) records whether each session is working, waiting on you or idle; --list carries it. The sidebar shows it, the header shows the current session's state, and a pulsing "needs you" chip jumps to sessions waiting for input. Session refresh no longer depends on the sidebar being open. - `devdesk attach --upload SESSION FILE BYTES` receives a file as base64 terminal input (raw mode, exact length, size-checked, sanitised name) into /var/lib/devdesk/uploads/<session>/. The UI uploads dropped or pasted files and pastes the path into Claude, which attaches images itself. - `devdesk alerts` / `--alerts`: firing and pending Grafana alert rules with severity, instances, summary and a link. The panel's "Ask Claude" types an investigation prompt into the current session. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
365 lines
15 KiB
Bash
365 lines
15 KiB
Bash
#!/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 "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" ]
|
|
}
|
|
|
|
@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 "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" ]
|
|
}
|