aboutsummaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
authorIgor Soarez <igor@soarez.org>2026-10-24 14:41:49 +0300
committerIgor Soarez <igor@soarez.org>2026-10-24 14:41:49 +0300
commitdd3462c3cd0a7ff8dbb31c5b4e6d133a04506419 (patch)
treed43b09471d2b740ebca0294f812c4d1c6bf44602 /tests
parente4d3bc242bd3d18aa83a76f25dcbe5cb7a2ced78 (diff)
tests/pi-live.sh: check a real pi release against dingger
runs a real pi binary on a tmux server and dingger daemon of its own, with a fake OpenAI-compatible model (tests/fake-model.py, reply delay set by the prompt) and its own pi agent directory: no network, no credentials, the user's pi untouched. plain shells, and a stop if the panes would not run the pi under test (a login shell's profile puts the user's pi first). it checks 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 by session file, and exit. pi 1.4.0 and 1.0.3: 23 of 23 each. a dingger whose extension listens for a renamed event fails it. in both, a pi confirm dialog shows as not blocked: pi tells terminals (OSC 7501, which tmux does not forward) but not extensions.
Diffstat (limited to 'tests')
-rwxr-xr-xtests/fake-model.py59
-rwxr-xr-xtests/pi-live.sh135
2 files changed, 194 insertions, 0 deletions
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; }