diff options
| -rw-r--r-- | README.md | 12 | ||||
| -rwxr-xr-x | tests/fake-model.py | 59 | ||||
| -rwxr-xr-x | tests/pi-live.sh | 135 |
3 files changed, 205 insertions, 1 deletions
@@ -145,6 +145,7 @@ dingger does not need tmux running first; it attaches when a server appears. ```bash cargo test # engine tests + herdr state-machine tests tests/e2e.sh # end to end against an isolated tmux server +tests/pi-live.sh <pi> # a real pi binary against dingger (new pi releases) ``` `tests/e2e.sh` starts its own tmux server, its own daemon (polling only every @@ -157,6 +158,15 @@ ranking, selection), the status-line segment, latency with a deliberately slow notifier, and state surviving a daemon restart. It never touches your tmux server. +`tests/pi-live.sh <pi binary>` checks a real pi release on a tmux server and +daemon of its own, with a fake local model (`tests/fake-model.py`) and its +own pi agent directory: no network, no credentials, your pi untouched. It +covers identification, the extension's state and session reports, a turn, +`/reload`, an extension's blocked event, screen detection without the +extension, restore, and exit. To try a release without installing it: +`npm install --prefix /tmp/pi-next @earendil-works/pi-coding-agent@<version>` +(through `sfw`), then `tests/pi-live.sh /tmp/pi-next/node_modules/.bin/pi`. + ## Layout - `crates/detect-core`: the detection engine and 22 manifests, from herdr. @@ -164,4 +174,4 @@ touches your tmux server. - `menubar/`: the menu bar app (`model.swift` rows and colors, `panel.swift` the floating panel, `hotkey.swift` the global shortcut and settings). - `packaging/`: launchd agents and the tmux snippet. -- `tests/e2e.sh`: the end-to-end suite. +- `tests/e2e.sh`: the end-to-end suite; `tests/pi-live.sh`: a real pi. diff --git a/tests/fake-model.py b/tests/fake-model.py new file mode 100755 index 0000000..a02b98c --- /dev/null +++ b/tests/fake-model.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +"""A fake OpenAI-compatible model for tests/pi-live.sh: streams a short reply +to /v1/chat/completions, after a delay taken from the last user message +("slow 3" waits 3 seconds). Prints its port, then serves until killed.""" +import json +import re +import sys +import time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + + +def last_user_text(body): + for message in reversed(body.get("messages", [])): + if message.get("role") == "user": + content = message.get("content") + if isinstance(content, list): + return " ".join(part.get("text", "") for part in content if isinstance(part, dict)) + return content or "" + return "" + + +class Handler(BaseHTTPRequestHandler): + def log_message(self, *args): + pass + + def do_GET(self): + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(json.dumps({"object": "list", "data": [{"id": "fake-model", "object": "model"}]}).encode()) + + def do_POST(self): + body = json.loads(self.rfile.read(int(self.headers.get("Content-Length", 0))) or b"{}") + text = last_user_text(body) + match = re.search(r"slow (\d+(?:\.\d+)?)", text) + time.sleep(float(match.group(1)) if match else 0.2) + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.send_header("Cache-Control", "no-cache") + self.end_headers() + + def chunk(delta, finish=None, usage=None): + data = {"id": "fake", "object": "chat.completion.chunk", "created": int(time.time()), "model": "fake-model", + "choices": [{"index": 0, "delta": delta, "finish_reason": finish}]} + if usage: + data["usage"] = usage + self.wfile.write(f"data: {json.dumps(data)}\n\n".encode()) + self.wfile.flush() + + chunk({"role": "assistant", "content": "Done: "}) + chunk({"content": "fake reply."}) + chunk({}, "stop", {"prompt_tokens": 10, "completion_tokens": 3, "total_tokens": 13}) + self.wfile.write(b"data: [DONE]\n\n") + self.wfile.flush() + + +server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) +print(server.server_address[1], flush=True) +server.serve_forever() diff --git a/tests/pi-live.sh b/tests/pi-live.sh new file mode 100755 index 0000000..de35bc5 --- /dev/null +++ b/tests/pi-live.sh @@ -0,0 +1,135 @@ +#!/bin/bash +# Live check of dingger against a real pi binary, on a tmux server and +# dingger daemon of its own, with a fake local model (tests/fake-model.py): +# no network, no credentials, nothing of the user's pi touched (pi gets its +# own agent directory). +# +# tests/pi-live.sh <pi binary> [dingger binary] +# +# Covers: identification, the extension's state and session reports, a turn +# (working, then done), /reload, an extension's blocked event, screen +# detection for a pi without the extension, restore (`pi --session`), exit. +set -u +PI_BIN=$(cd "$(dirname "$1")" && pwd)/$(basename "$1") +ROOT=$(cd "$(dirname "$0")/.." && pwd) +DINGGER="${2:-$ROOT/target/release/dingger}" +case "$DINGGER" in /*) ;; *) DINGGER="$PWD/$DINGGER" ;; esac +D=$(mktemp -d /tmp/dingger-pi-live.XXXXXX) +R="dingger-pi-live-$$" +PASSES=0; FAILS=0 +pass() { PASSES=$((PASSES + 1)); echo " ok $1"; } +fail() { FAILS=$((FAILS + 1)); echo " FAIL $1"; } +expect() { # description command expected [tries of 0.1s, default 50] + local desc=$1 cmd=$2 want=$3 tries=${4:-50} got + for _ in $(seq "$tries"); do + got=$(eval "$cmd") + [ "$got" = "$want" ] && { pass "$desc"; return; } + sleep 0.1 + done + fail "$desc: want [$want] got [$got]" +} +note() { echo " note $1"; } +t() { tmux -u -L "$R" "$@"; } +cleanup() { + t kill-server 2>/dev/null + [ -n "${DAEMON_PID:-}" ] && kill "$DAEMON_PID" 2>/dev/null + [ -n "${FAKE_PID:-}" ] && kill "$FAKE_PID" 2>/dev/null + rm -rf "$D" +} +trap cleanup EXIT + +echo "pi $("$PI_BIN" --version 2>&1 | head -1), dingger $DINGGER" +python3 "$ROOT/tests/fake-model.py" >"$D/port" 2>"$D/fake.err" & +FAKE_PID=$! +for _ in $(seq 50); do [ -s "$D/port" ] && break; sleep 0.1; done +PORT=$(cat "$D/port") +# Two agent directories: one with dingger's extension (and a test extension +# that blocks the way extensions do), one without (screen detection). +for agent in agent agent-plain; do + mkdir -p "$D/$agent/extensions" + cat >"$D/$agent/models.json" <<EOF +{"providers": {"fake": {"baseUrl": "http://127.0.0.1:$PORT/v1", "api": "openai-completions", "apiKey": "fake", "models": [{"id": "fake-model"}]}}} +EOF + echo '{"defaultProvider": "fake", "defaultModel": "fake-model"}' >"$D/$agent/settings.json" +done +PI_CODING_AGENT_DIR="$D/agent" "$DINGGER" install pi >/dev/null +cat >"$D/agent/extensions/test-blocked.ts" <<'EOF' +export default function (pi) { + pi.registerCommand("block", { description: "test: emit blocked", handler: async () => { pi.events.emit("blocked", { active: true, label: "test block" }); } }); + pi.registerCommand("unblock", { description: "test: clear blocked", handler: async () => { pi.events.emit("blocked", { active: false }); } }); + pi.registerCommand("ask", { description: "test: a confirm dialog", handler: async (_args, ctx) => { await ctx.ui.confirm("Test dialog", "Proceed?"); } }); +} +EOF +mkdir -p "$D/bin" "$D/work" "$D/work2" +ln -s "$PI_BIN" "$D/bin/pi" +# Plain shells: a login shell would read the user's profile, which puts the +# user's own pi first on PATH. +printf 'set -g default-shell /bin/sh\nset -g default-command /bin/sh\n' >"$D/tmux.conf" +export PATH="$D/bin:$PATH" PI_OFFLINE=1 PI_SKIP_VERSION_CHECK=1 PI_TELEMETRY=0 +export DINGGER_TMUX_SOCKET="$R" DINGGER_AGENT_SOCKET="$D/hook.sock" DINGGER_STATE_DIR="$D/state" DINGGER_TMUX_CONFIG="$D/tmux.conf" +t -f "$D/tmux.conf" new-session -d -s main -n agent -x 140 -y 40 -c "$D/work" +t new-window -d -t main: -n plain -c "$D/work2" +"$DINGGER" watch --interval-secs 1 --no-sounds --no-notifications >"$D/daemon.log" 2>&1 & +DAEMON_PID=$! +t send-keys -t main:agent "command -v pi > $D/which-pi" Enter +for _ in $(seq 30); do [ -s "$D/which-pi" ] && break; sleep 0.1; done +if [ "$(cat "$D/which-pi")" != "$D/bin/pi" ]; then + echo "the test panes would run $(cat "$D/which-pi"), not $PI_BIN; stopping"; exit 1 +fi +# --no-mcp (pi 1.0.4+): no MCP servers, whatever the user configured. +NO_MCP=$("$PI_BIN" --help 2>&1 | grep -q -- --no-mcp && echo --no-mcp) +st() { t show -pv -t "main:$1" @agent_state 2>/dev/null; } +type_line() { t send-keys -t "main:$1" -l "$2"; sleep 0.3; t send-keys -t "main:$1" Enter; } + +echo "1. the extension's reports" +t send-keys -t main:agent "clear; PI_CODING_AGENT_DIR=$D/agent pi $NO_MCP" Enter +expect "identified as pi" "t show -pv -t main:agent @agent 2>/dev/null" pi 150 +expect "idle at start" "st agent" idle 100 +expect "the extension reported (state from the hook)" "t show -pv -t main:agent @dingger_hook 2>/dev/null" idle 100 +expect "its session file is on the pane" "t show -pv -t main:agent @agent_session 2>/dev/null | grep -c '^$D/agent/sessions/.*\.jsonl$'" 1 100 +type_line agent "slow 3 hello" +expect "a turn: working" "st agent" working 50 +expect "then done (nobody watching)" "st agent" done 80 +expect "the session file is written" "[ -s \"\$(t show -pv -t main:agent @agent_session)\" ] && echo yes" yes +type_line agent "/reload" +sleep 2 +type_line agent "slow 2 again" +expect "after /reload: working" "st agent" working 50 +expect "and done" "st agent" done 60 +type_line agent "/block" +expect "an extension's blocked event: blocked" "st agent" blocked 50 +type_line agent "/unblock" +expect "cleared: back to done" "st agent" done 50 +type_line agent "/ask" +sleep 1.5 +note "during a pi confirm dialog dingger shows: $(st agent) (pi gives extensions no dialog event)" +t send-keys -t main:agent Escape + +echo "2. screen detection (pi without the extension)" +t send-keys -t main:plain "PI_CODING_AGENT_DIR=$D/agent-plain pi $NO_MCP" Enter +expect "identified as pi" "t show -pv -t main:plain @agent 2>/dev/null" pi 150 +expect "idle at start" "st plain" idle 100 +expect "no hook state" "t show -pv -t main:plain @dingger_hook 2>/dev/null" "" +type_line plain "slow 4 hello" +expect "a turn: working, from the screen" "st plain" working 50 +expect "then done" "st plain" done 80 + +echo "3. restore" +SESSION=$(t show -pv -t main:agent @agent_session) +latest() { ls "$D"/state/snapshots/*.json 2>/dev/null | tail -1; } +expect "the snapshot has the agent's session" "grep -c \"\\\"session\\\": \\\"$SESSION\\\"\" \"\$(latest)\" 2>/dev/null" 1 80 +t kill-server +expect "the layout becomes the restore candidate" "python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))[\"status\"])' $D/state/restore.json 2>/dev/null" offered 80 +"$DINGGER" restore >"$D/restore.out" 2>&1 +expect "restore resumes it by its session file" "grep -c -- \"resume .*pi --session\" $D/restore.out" 1 +expect "resumed 1 of 1 agents" "grep -c '^resumed 1 of 1 agents' $D/restore.out" 1 +expect "it runs again, reporting the same session" "t show -pv -t main:agent @agent_session 2>/dev/null" "$SESSION" 150 +expect "with its conversation" "t capture-pane -p -t main:agent -S - | grep -c 'slow 3 hello'" 1 50 + +echo "4. exit" +t send-keys -t main:agent C-d +expect "the agent is gone from the pane" "t show -pv -t main:agent @agent 2>/dev/null" "" 100 + +echo +echo "$PASSES passed, $FAILS failed" +[ "$FAILS" -eq 0 ] || { echo "--- restore output:"; cat "$D/restore.out" 2>/dev/null; echo "--- daemon log tail:"; tail -15 "$D/daemon.log" | sed 's/\x1b\[[0-9;]*m//g' | cut -c29-160; exit 1; } |
