2026-10-06 23:11:07 -04:00
#!/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
2026-10-11 22:07:39 -04:00
unset DEVDESK_REMOTE_CONTROL CLAUDE_ARGS DEVDESK_SESSION TMUX TMUX_PANE # when run from inside DevDesk
2026-10-06 23:11:07 -04:00
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
2026-10-07 23:34:59 -04:00
export DEVDESK_MATRIX_WEBHOOK_FILE = " $TMP /no-webhook" # never the real room
2026-10-11 23:28:11 -04:00
export DEVDESK_PRESENCE_DIR = " $TMP /presence" # never the live tabs
2026-10-06 23:11:07 -04:00
export FAKE_LOG = " $TMP /claude-args"
export CLAUDE_BIN = " $TMP /fake-claude"
2026-10-11 01:01:51 -04:00
export CLAUDE_CONFIG_DIR = " $TMP /claude" # never the real conversation history
2026-10-06 23:11:07 -04:00
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"
2026-10-11 01:01:51 -04:00
[ -z "${FAKE_EXIT:-}" ] || exit "$FAKE_EXIT"
2026-10-06 23:11:07 -04:00
exec sleep 300
EOF
chmod +x " $CLAUDE_BIN "
}
teardown() {
2026-10-11 23:28:11 -04:00
[ -z " ${ RECEIVER :- } " ] || kill " $RECEIVER " 2>/dev/null || true
2026-10-06 23:11:07 -04:00
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
2026-10-07 03:03:09 -04:00
for bad in ../etc "a b" .hidden " $( printf 'x%.0s' { 1..70} ) " ; do
2026-10-06 23:11:07 -04:00
run devdesk attach " $bad "
[ " $status " -ne 0 ]
[[ $output == *"invalid session name" * ]]
done
2026-10-07 03:03:09 -04:00
run devdesk attach -x
[ " $status " -ne 0 ]
[[ $output == *"unknown option" * ]]
2026-10-06 23:11:07 -04:00
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" ]
}
2026-10-07 03:03:09 -04:00
@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 " ]
}
2026-10-06 23:11:07 -04:00
@test "symlinks may not escape the projects root" {
start_server
run devdesk new escape
2026-10-07 03:03:09 -04:00
[ " $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 ]
2026-10-11 23:07:04 -04:00
[[ $output == *"alpha" * && $output == *"ctx 34% 346k/1M" * ]]
2026-10-07 03:03:09 -04:00
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 ]
}
2026-10-11 22:07:39 -04:00
@test "statusline footer shows plan use on a subscription and dollars only on API billing" {
sub = '{"model":{"display_name":"Opus"},"context_window":{"used_percentage":10},
"cost":{"total_cost_usd":1186.99},"rate_limits":{"five_hour":{"used_percentage":8.4}}}'
run bash -c "echo ' $sub ' | ' $REPO /libexec/devdesk-statusline'"
[[ $output == *"5h 8%" * ]]
[[ $output != *'$' * ]]
api = '{"model":{"display_name":"Opus"},"context_window":{"used_percentage":10},"cost":{"total_cost_usd":1.5}}'
run bash -c "echo ' $api ' | ' $REPO /libexec/devdesk-statusline'"
[[ $output == *'$1.5' * ]]
}
2026-10-07 12:54:28 -04:00
@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" ]
}
2026-10-07 13:09:32 -04:00
@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" ]
}
2026-10-07 19:26:06 -04:00
@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" ]
}
2026-10-07 23:34:59 -04:00
# 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; }
2026-10-11 23:28:11 -04:00
@test "a real wait is posted to Matrix once, saying what Claude asks (#28, #50)" {
2026-10-07 23:34:59 -04:00
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" ; }
2026-10-11 23:28:11 -04:00
st = " $DEVDESK_STATE_DIR /state/alpha.json"
2026-10-07 23:34:59 -04:00
2026-10-11 23:28:11 -04:00
# Claude's idle reminder after a turn is not a request: no red, no post.
echo '{"hook_event_name":"Stop"}' | hook
echo '{"hook_event_name":"Notification","message":"Claude is waiting for your input","notification_type":"idle_prompt"}' | hook
2026-10-07 23:34:59 -04:00
echo '{"hook_event_name":"Notification","message":"Claude is waiting for your input"}' | hook
2026-10-11 23:28:11 -04:00
[ " $( jq -r .state " $st " ) " = idle ]
echo '{"hook_event_name":"PermissionRequest","tool_name":"Bash","tool_input":{"command":"systemctl restart nginx","description":"Restart nginx"}}' | hook
# a second notification in the same wait: no second message
echo '{"hook_event_name":"Notification","message":"Claude needs your permission to use Bash","notification_type":"permission_prompt"}' | hook
2026-10-07 23:34:59 -04:00
sleep 2.5
[ " $( posts) " -eq 1 ]
2026-10-11 23:28:11 -04:00
run jq -r '.text' " $TMP /posts"
[ " $output " = " $( printf 'alpha: Claude wants to run Bash (Restart nginx)\nsystemctl restart nginx\nAnswer: https://dev.example.org/?arg=alpha&existing=1' ) " ]
2026-10-07 23:34:59 -04:00
# 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 ]
2026-10-11 23:28:11 -04:00
# another real wait soon after: quiet period, still one message
echo '{"hook_event_name":"Notification","message":"Claude needs your permission to use Edit"}' | hook
sleep 2.5
[ " $( posts) " -eq 1 ]
echo '{"hook_event_name":"PostToolUse"}' | hook
# past the quiet period, but a DevDesk tab is in view: nothing
rm -rf " $DEVDESK_STATE_DIR /matrix-sent"
mkdir -p " $DEVDESK_PRESENCE_DIR " && touch " $DEVDESK_PRESENCE_DIR /1"
echo '{"hook_event_name":"Notification","message":"Claude needs your permission to use Edit"}' | hook
sleep 2.5
2026-10-07 23:34:59 -04:00
[ " $( posts) " -eq 1 ]
}
2026-10-08 00:32:23 -04:00
@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 ]
2026-10-08 00:36:50 -04:00
# 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.
2026-10-08 00:32:23 -04:00
start = $( date +%s%N)
2026-10-08 00:36:50 -04:00
run bash -c "sleep 2 | script -qec \"DEVDESK_VIA_TTYD=1 DEVDESK_LINGER=0.6 ' $REPO /bin/devdesk' attach --list\" /dev/null"
2026-10-08 00:32:23 -04:00
elapsed = $(( ( $( date +%s%N) - start) / 1000000 ))
[[ $output == *'"sessions"' * ]]
[ " $elapsed " -ge 500 ]
# Errors are printed before the wait, too.
2026-10-08 00:36:50 -04:00
run bash -c "sleep 1 | script -qec \"DEVDESK_VIA_TTYD=1 DEVDESK_LINGER=0.3 ' $REPO /bin/devdesk' attach --rename nosuch x\" /dev/null"
2026-10-08 00:32:23 -04:00
[[ $output == *"no session named" * ]]
}
2026-10-07 19:26:06 -04:00
@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 ]
}
2026-10-07 20:15:47 -04:00
@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* ]]
}
2026-10-07 03:03:09 -04:00
@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
2026-10-06 23:11:07 -04:00
[ " $status " -ne 0 ]
2026-10-07 03:03:09 -04:00
run devdesk attach --bogus
2026-10-06 23:11:07 -04:00
[ " $status " -ne 0 ]
}
@test "session hook records the Claude session and the next start resumes it" {
start_server
2026-10-11 01:01:51 -04:00
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"
2026-10-06 23:11:07 -04:00
run jq -r '.sessions.alpha.claude_session_id' " $DEVDESK_STATE_DIR /registry.json"
2026-10-11 01:01:51 -04:00
[ " $output " = " $id " ]
2026-10-06 23:11:07 -04:00
devdesk new alpha
run claude_args alpha
2026-10-11 01:01:51 -04:00
[ " $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" ]
2026-10-06 23:11:07 -04:00
}
@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" ]
}
2026-10-08 01:16:37 -04:00
# 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
}
2026-10-08 01:40:47 -04:00
@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" ]
2026-10-08 01:50:50 -04:00
# 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
2026-10-08 01:40:47 -04:00
run env DEVDESK_FS_FIND_MAX = 1 " $REPO /bin/devdesk" attach --fs-find alpha
2026-10-08 01:50:50 -04:00
[ " $status " -eq 0 ]
2026-10-08 01:40:47 -04:00
[ " $( jq -r '[(.files | length), .truncated] | @tsv' <<< " $output " ) " = " $( printf '1\ttrue' ) " ]
2026-10-08 01:50:50 -04:00
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' ) " ]
2026-10-08 01:40:47 -04:00
}
2026-10-10 20:10:45 -04:00
# 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 ]
}
2026-10-10 20:22:37 -04:00
@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" * ]]
}
2026-10-10 20:37:46 -04:00
@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" ]
}
2026-10-10 21:45:26 -04:00
# 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 # \$ } " ]
}
2026-10-11 11:24:49 -04:00
# A fake Proxmox cluster for `devdesk guests`: DEVDESK_PVE_SSH answers the
# inventory (pvesh) from $FAKE_PVE/inventory and each node's scan from node-<name>.
fake_pve() {
export FAKE_PVE = " $TMP /pve" DEVDESK_PVE_SSH = " $TMP /fake-ssh" DEVDESK_PVE_NODES = "down-node large1"
mkdir -p " $FAKE_PVE "
cat >" $DEVDESK_PVE_SSH " <<'EOF'
#!/bin/bash
while [ "${1:-}" = -o ]; do shift 2; done
case $1:$2 in
down-node:*) exit 255 ;;
*:*pvesh*) cat "$FAKE_PVE/inventory" ;;
*) cat "$FAKE_PVE/node-$1" 2>/dev/null || exit 255 ;;
esac
EOF
chmod +x " $DEVDESK_PVE_SSH "
pve_state running
printf 'large1\t10.10.10.2 10.10.90.10 fe80::1\n139\t10.10.10.27 \n102\t127.0.0.1 10.10.10.23 \n' >" $FAKE_PVE /node-large1"
}
# Draupnir (CT 110) running on monitor-02, or stopped (and so absent from the node scan).
pve_state() {
printf '%s\n' \
'[{"vmid":110,"name":"draupnir","node":"monitor-02","status":"' " $1 " '","type":"lxc","hastate":"' " ${ 1 /running/started } " '"},' \
' {"vmid":139,"name":"nginxproxymanager","node":"large1","status":"running","type":"lxc","hastate":"started"},' \
' {"vmid":102,"name":"homeAssistant","node":"large1","status":"running","type":"qemu"}]' \
'[{"id":"cluster","type":"cluster"},{"name":"large1","ip":"10.10.90.10","online":1,"type":"node"},' \
' {"name":"monitor-02","ip":"10.10.10.9","online":1,"type":"node"}]' >" $FAKE_PVE /inventory"
if [ " $1 " = running ] ; then
printf 'monitor-02\t10.10.10.9\n110\t10.10.10.24\n' >" $FAKE_PVE /node-monitor-02"
else
printf 'monitor-02\t10.10.10.9\n' >" $FAKE_PVE /node-monitor-02"
fi
}
# Grafana's rules API, served from a file.
fake_grafana() {
export DEVDESK_GRAFANA_URL = "file:// $TMP /grafana" DEVDESK_GRAFANA_PUBLIC_URL = https://grafana.example
mkdir -p " $TMP /grafana/api/prometheus/grafana/api/v1"
jq -n '{data: {groups: [{name: "Bots", file: "Matrix", rules: [
{uid: "u-draupnir", name: "Draupnir Down", state: "firing", query: "up{job=\"draupnir-node\"}", labels: {severity: "critical"},
annotations: {summary: "Draupnir is unreachable", description: "Cannot scrape 10.10.10.24:9100"},
alerts: [{state: "Alerting", activeAt: "2026-10-11T07:10:00Z", value: "0", labels: {instance: "10.10.10.24:9100", job: "draupnir-node", alertname: "Draupnir Down"}}]},
{uid: "u-ceph", name: "Ceph Unhealthy", state: "firing", query: "ceph_health_status", labels: {severity: "warning"},
annotations: {summary: "status={{ $value }}"},
alerts: [{state: "Alerting", value: "1e+00", labels: {instance: "http://10.10.10.2:9283/metrics", job: "ceph"}}]},
{uid: "u-count", name: "Exporter Down", state: "firing", query: "count(up == 0)", labels: {},
annotations: {summary: "{{ $value }} down"}, alerts: [{state: "Alerting", value: "2", labels: {alertname: "Exporter Down"}}]},
{uid: "u-lan", name: "Printer Offline", state: "pending", query: "up", labels: {},
annotations: {}, alerts: [{state: "Pending", value: "0", labels: {instance: "10.99.0.1:9100"}}]},
{uid: "u-ok", name: "Fine", state: "inactive", alerts: []}]}]}}' >" $TMP /grafana/api/prometheus/grafana/api/v1/rules"
}
@test "guests: where each CT/VM runs, from any node that answers; stopped ones keep their last IP (#39)" {
fake_pve
run devdesk guests
[ " $status " -eq 0 ]
[[ $output == *"110" *"draupnir" *"monitor-02" *"running" *"started" *"10.10.10.24" * ]]
[[ $output == *"102" *"vm" *"homeAssistant" *"large1" *"10.10.10.23" * ]]
[[ $output != *"127.0.0.1" * ]]
run devdesk guests --json 10.10.10.27
[ " $( jq -r '[.guests[].id] | join(",")' <<< " $output " ) " = 139 ]
[ " $( jq -r '.nodes[] | select(.name == "large1") | .ips | join(",")' <<< " $output " ) " = 10.10.10.2,10.10.90.10 ]
# Draupnir stops: its IP is remembered, marked as last seen.
pve_state stopped
run devdesk guests drau
[[ $output == *"stopped" *"10.10.10.24 (last seen)" * ]]
# No node answers: the cache, with a warning.
run env DEVDESK_PVE_NODES = down-node devdesk guests draupnir
[ " $status " -eq 0 ]
[[ $output == *"no Proxmox node answered" *"10.10.10.24" * ]]
run env DEVDESK_PVE_NODES = devdesk guests
[ " $status " -ne 0 ]
[[ $output == *"DEVDESK_PVE_NODES" * ]]
}
@test "alerts name the guest or node each one is about (#39)" {
fake_pve
fake_grafana
devdesk guests >/dev/null
pve_state stopped
devdesk guests >/dev/null
run devdesk alerts
[ " $status " -eq 0 ]
t() { jq -c --arg u " $1 " '.alerts[] | select(.uid == $u) | .targets | map({kind, id, name, node, by})' <<< " $output " ; }
[ " $( t u-draupnir) " = '[{"kind":"ct","id":110,"name":"draupnir","node":"monitor-02","by":"ip"}]' ]
[ " $( t u-ceph) " = '[{"kind":"node","id":null,"name":"large1","node":"large1","by":"ip"}]' ]
[ " $( t u-count) " = '[]' ]
[ " $( t u-lan) " = '[{"kind":"unknown","id":null,"name":null,"node":null,"by":null}]' ]
[ " $( jq -r '.alerts[] | select(.uid == "u-ceph") | .summary' <<< " $output " ) " = "status=1" ]
[ " $( jq -r '[.alerts[].uid] | join(",")' <<< " $output " ) " = u-draupnir,u-ceph,u-count,u-lan ]
# Never seen running: matched by the name in its job label instead.
rm " $DEVDESK_STATE_DIR /guests.json"
devdesk guests >/dev/null
run devdesk alerts
[ " $( t u-draupnir) " = '[{"kind":"ct","id":110,"name":"draupnir","node":"monitor-02","by":"name"}]' ]
}
@test "investigate starts Claude in its own session with the alert and where it runs (#39)" {
fake_pve
fake_grafana
devdesk guests >/dev/null
start_server
run devdesk attach --investigate alert-draupnir-down u-draupnir
[ " $status " -eq 0 ]
[ " $( jq -c . <<< " $output " ) " = '{"ok":true,"session":"alert-draupnir-down","started":true}' ]
claude_args alert-draupnir-down >/dev/null
log = " $FAKE_LOG /alert-draupnir-down"
grep -q '^-n alert-draupnir-down Investigate this Grafana alert' " $log "
grep -q 'Alert: Draupnir Down (critical, firing since' " $log "
grep -q 'Query: `up{job="draupnir-node"}`' " $log "
grep -q '10.10.10.24 is CT 110 "draupnir" on node monitor-02, running, HA state started; commands inside it: `ssh monitor-02 pct exec 110 -- <command>`' " $log "
grep -q '`devdesk guests 110` shows where it is now' " $log "
# The prompt is sent once: not again when Claude is resumed.
[ ! -e " $DEVDESK_STATE_DIR /prompts/alert-draupnir-down.txt" ]
run devdesk attach --investigate alert-draupnir-down u-draupnir
[ " $( jq -r .started <<< " $output " ) " = false ]
# Aggregates get a hint instead; alerts that resolved meanwhile are refused.
run devdesk attach --investigate exporters u-count
claude_args exporters >/dev/null
grep -q 'The rule aggregates' " $FAKE_LOG /exporters"
run grep -c 'Where it runs' " $FAKE_LOG /exporters"
[ " $output " = 0 ]
run devdesk attach --investigate gone u-ok
[ " $status " -ne 0 ]
[[ $output == *"no longer firing" * ]]
run devdesk attach --investigate 'bad name' u-ok
[[ $output == *"invalid session name" * ]]
}
2026-10-11 23:28:11 -04:00
@test "last-reply returns every text part of the answer to your last message (#47)" {
start_server
devdesk new alpha
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":"First question"},"timestamp":"2026-10-10T09:00:00Z"}
{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Old answer"}]},"timestamp":"2026-10-10T09:00:01Z"}
{"type":"user","message":{"role":"user","content":"Please upgrade PBS"},"timestamp":"2026-10-10T10:00:00Z"}
{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Checking the version."}]},"timestamp":"2026-10-10T10:00:04Z"}
{"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":"## Done\n\nPBS is on **4.1**."}]},"timestamp":"2026-10-10T10:00:09Z"}
JSONL
run devdesk attach --last-reply alpha
[ " $status " -ne 0 ] # no conversation recorded yet
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 --last-reply alpha
[ " $status " -eq 0 ]
[ " $( jq -r .text <<< " $output " ) " = " $( printf 'Checking the version.\n\n## Done\n\nPBS is on **4.1**.' ) " ]
[ " $( jq -r .at <<< " $output " ) " = 2026-10-10T10:00:09Z ]
}
@test "worktree sessions get their own checkout; ending removes it only when clean (#44)" {
start_server
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
mkdir -p " $DEVDESK_PROJECTS_ROOT /plain"
run devdesk new solo --worktree
[ " $status " -ne 0 ]
run devdesk new p1 plain --worktree
[ " $status " -ne 0 ]
[[ $output == *"not a git repository" * ]]
devdesk new wt1 alpha --worktree
wt = " $DEVDESK_PROJECTS_ROOT /.worktrees/alpha--wt1"
[ " $( git -C " $wt " symbolic-ref --short HEAD) " = devdesk/wt1 ]
run tmux -S " $DEVDESK_SOCKET " display -p -t = wt1:claude '#{pane_current_path}'
[ " $output " = " $wt " ]
run devdesk attach --list
[ " $( jq -r '.sessions[] | select(.name=="wt1") | .worktree.branch' <<< " $output " ) " = devdesk/wt1 ]
[ " $( jq -r '.projects[] | select(.name=="alpha") | .git' <<< " $output " ) " = true ]
[ " $( jq -r '.projects[] | select(.name=="plain") | .git' <<< " $output " ) " = false ]
[ " $( jq -r '[.projects[].name] | index(".worktrees")' <<< " $output " ) " = null ]
# clean and merged: worktree and branch go
devdesk end wt1
[ ! -e " $wt " ]
run git -C " $repo " show-ref --verify refs/heads/devdesk/wt1
[ " $status " -ne 0 ]
# uncommitted work: everything stays
devdesk new wt2 alpha --worktree
wt = " $DEVDESK_PROJECTS_ROOT /.worktrees/alpha--wt2"
echo work >" $wt /file"
devdesk end wt2
[ -f " $wt /file" ]
git -C " $repo " show-ref -q --verify refs/heads/devdesk/wt2
}
@test "repo lists the first changed files for the panel (#46)" {
export DEVDESK_GITEA_URL = http://127.0.0.1:9
repo = " $DEVDESK_PROJECTS_ROOT /alpha"
git -C " $repo " init -q -b main
printf 'a\n' >" $repo /tracked"
git -C " $repo " add tracked
git -C " $repo " -c user.name= t -c user.email= t@t commit -q -m init
printf 'a\nb\n' >" $repo /tracked"
touch " $repo /new-file"
run devdesk attach --repo alpha
[ " $status " -eq 0 ]
[ " $( jq -c '[.changes[] | [.path, .x, .y, .add]]' <<< " $output " ) " = '[["tracked"," ","M",1],["new-file","?","?",null]]' ]
}
@test "plan usage past the threshold pings Matrix once per window, only when away (#45)" {
start_server
start_receiver
devdesk new alpha
pane = $( tmux -S " $DEVDESK_SOCKET " display -p -t = alpha:shell '#{pane_id}' )
sl() { echo "{\"model\":{\"display_name\":\"Opus\"},\"context_window\":{},\"rate_limits\":{\"five_hour\":{\"used_percentage\": $1 ,\"resets_at\": $2 }}}" |
TMUX = " $DEVDESK_SOCKET ,1,0" TMUX_PANE = " $pane " " $REPO /libexec/devdesk-statusline" >/dev/null; }
sl 50 2000000000
sl 91 2000000000
sl 93 2000000000
for _ in $( seq 1 30) ; do [ " $( posts) " -ge 1 ] && break; sleep 0.1; done
sleep 0.5
[ " $( posts) " -eq 1 ]
[[ " $( jq -r .text " $TMP /posts" ) " == *"91% of the 5-hour window" * ]]
# next window, but a client is attached: no post, and that window is done
tmux -S " $DEVDESK_SOCKET " new-session -d -s watcher 'sleep 30'
script -qc "tmux -S ' $DEVDESK_SOCKET ' attach -t =alpha" /dev/null </dev/null >/dev/null 2>& 1 &
for _ in $( seq 1 30) ; do [ -n " $( tmux -S " $DEVDESK_SOCKET " list-clients -F x) " ] && break; sleep 0.1; done
sl 95 2000018000
sleep 1
[ " $( posts) " -eq 1 ]
[ " $( cat " $DEVDESK_STATE_DIR /usage-warned" ) " = 2000018000 ]
}