From 6fb7ff7aad119fe4e6acb532742e479d0fad9116 Mon Sep 17 00:00:00 2001 From: Clay Sheaff Date: Sat, 14 Feb 2026 12:21:45 -0800 Subject: [PATCH 1/2] Only run CI on pull requests, not after merge Co-Authored-By: Claude Opus 4.6 --- .github/workflows/ci.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bbd754f..0a85fb5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,8 +1,6 @@ name: CI on: - push: - branches: [main] pull_request: branches: [main] From cfdc54b64df7c499bbc0c2dcedcfb95f5272d8e3 Mon Sep 17 00:00:00 2001 From: Clay Sheaff Date: Sat, 14 Feb 2026 12:24:09 -0800 Subject: [PATCH 2/2] Add tests for error handling, recorder fallback, and server mode New unit tests: - Stale PID file recovery (crashed recording process) - Transcription command failure handling - ffmpeg preferred over pw-record when available - pw-record fallback when ffmpeg is missing New server tests: - Daemon lifecycle (start, respond, SIGTERM cleanup) - Multiple sequential requests - Helpful error when server not running - Stop when not running - Invalid subcommand shows usage Co-Authored-By: Claude Opus 4.6 --- .github/workflows/ci.yml | 6 +-- test/mock-daemon.py | 36 ++++++++++++++ test/mock-transcribe-fail | 4 ++ test/mocks/ffmpeg | 3 +- test/mocks/pw-record | 3 +- test/server.bats | 99 +++++++++++++++++++++++++++++++++++++++ test/talktype.bats | 65 +++++++++++++++++++++++++ 7 files changed, 211 insertions(+), 5 deletions(-) create mode 100644 test/mock-daemon.py create mode 100755 test/mock-transcribe-fail create mode 100644 test/server.bats diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0a85fb5..b44c8c8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,7 +30,7 @@ jobs: run: sudo apt-get update && sudo apt-get install -y bats - name: Install test dependencies - run: sudo apt-get install -y ydotool pipewire libnotify-bin + run: sudo apt-get install -y ydotool pipewire libnotify-bin socat - - name: Run unit tests - run: bats test/talktype.bats + - name: Run tests + run: bats test/talktype.bats test/server.bats diff --git a/test/mock-daemon.py b/test/mock-daemon.py new file mode 100644 index 0000000..47ccdf4 --- /dev/null +++ b/test/mock-daemon.py @@ -0,0 +1,36 @@ +"""Mock transcription daemon for testing server mode.""" +import os +import sys +import socket +import signal + +SOCK_PATH = sys.argv[1] + +def cleanup(*_): + try: + os.unlink(SOCK_PATH) + except OSError: + pass + sys.exit(0) + +signal.signal(signal.SIGTERM, cleanup) +signal.signal(signal.SIGINT, cleanup) + +if os.path.exists(SOCK_PATH): + os.unlink(SOCK_PATH) + +server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) +server.bind(SOCK_PATH) +server.listen(1) + +print("Mock daemon ready.", flush=True) + +while True: + conn, _ = server.accept() + try: + audio_path = conn.recv(4096).decode().strip() + conn.sendall(b"mock transcription result") + except Exception: + conn.sendall(b"") + finally: + conn.close() diff --git a/test/mock-transcribe-fail b/test/mock-transcribe-fail new file mode 100755 index 0000000..f3ad9f2 --- /dev/null +++ b/test/mock-transcribe-fail @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +# Mock transcriber that fails +echo "error: model not found" >&2 +exit 1 diff --git a/test/mocks/ffmpeg b/test/mocks/ffmpeg index 6ca22e7..fdbf1bd 100755 --- a/test/mocks/ffmpeg +++ b/test/mocks/ffmpeg @@ -1,5 +1,6 @@ #!/usr/bin/env bash -# Mock ffmpeg: sleep like a recording process +# Mock ffmpeg: log that it was called, sleep like a recording process +echo "ffmpeg" > "$TALKTYPE_DIR/recorder.log" trap 'exit 0' TERM sleep 300 & wait $! diff --git a/test/mocks/pw-record b/test/mocks/pw-record index fbea49c..c473451 100755 --- a/test/mocks/pw-record +++ b/test/mocks/pw-record @@ -1,5 +1,6 @@ #!/usr/bin/env bash -# Mock pw-record: write PID so tests can clean up, sleep in background +# Mock pw-record: log that it was called, sleep like a recording process +echo "pw-record" > "$TALKTYPE_DIR/recorder.log" trap 'exit 0' TERM sleep 300 & wait $! diff --git a/test/server.bats b/test/server.bats new file mode 100644 index 0000000..9c69d40 --- /dev/null +++ b/test/server.bats @@ -0,0 +1,99 @@ +#!/usr/bin/env bats + +# Tests for the server mode (Unix socket daemon pattern). +# Uses a mock daemon so no real models or venvs are needed. + +REPO_DIR="$BATS_TEST_DIRNAME/.." + +setup() { + export SOCK="$BATS_TEST_TMPDIR/test-server.sock" + export PIDFILE="$BATS_TEST_TMPDIR/test-server.pid" +} + +teardown() { + # Clean up any leftover daemon + if [ -f "$PIDFILE" ]; then + kill "$(cat "$PIDFILE")" 2>/dev/null || true + rm -f "$PIDFILE" + fi + rm -f "$SOCK" +} + +# Helper: start the mock daemon +start_mock_daemon() { + python3 "$BATS_TEST_DIRNAME/mock-daemon.py" "$SOCK" & + local pid=$! + echo "$pid" > "$PIDFILE" + + # Wait for socket to appear + for i in $(seq 1 10); do + [ -S "$SOCK" ] && return 0 + sleep 0.1 + done + return 1 +} + +# ── Daemon lifecycle ── + +@test "mock daemon starts and creates socket" { + start_mock_daemon + + [ -S "$SOCK" ] + [ -f "$PIDFILE" ] + kill -0 "$(cat "$PIDFILE")" +} + +@test "mock daemon responds to transcription requests" { + command -v socat &>/dev/null || skip "socat not installed" + start_mock_daemon + + result=$(echo "/tmp/test.wav" | socat - UNIX-CONNECT:"$SOCK") + [[ "$result" == "mock transcription result" ]] +} + +@test "mock daemon handles multiple requests" { + command -v socat &>/dev/null || skip "socat not installed" + start_mock_daemon + + result1=$(echo "/tmp/a.wav" | socat - UNIX-CONNECT:"$SOCK") + result2=$(echo "/tmp/b.wav" | socat - UNIX-CONNECT:"$SOCK") + [[ "$result1" == "mock transcription result" ]] + [[ "$result2" == "mock transcription result" ]] +} + +@test "daemon cleans up socket on SIGTERM" { + start_mock_daemon + + [ -S "$SOCK" ] + kill "$(cat "$PIDFILE")" + sleep 0.2 + + [ ! -S "$SOCK" ] +} + +# ── Server wrapper logic ── + +@test "transcribe fails with helpful message when server not running" { + # Test each server script's transcribe command without a running server + for server in transcribe-server backends/parakeet-server backends/moonshine-server; do + run "$REPO_DIR/$server" transcribe /tmp/test.wav + [ "$status" -eq 1 ] + [[ "$output" == *"not running"* ]] + done +} + +@test "stop reports not running when no pidfile exists" { + for server in transcribe-server backends/parakeet-server backends/moonshine-server; do + run "$REPO_DIR/$server" stop + [ "$status" -eq 0 ] + [[ "$output" == *"Not running"* ]] + done +} + +@test "invalid subcommand shows usage" { + for server in transcribe-server backends/parakeet-server backends/moonshine-server; do + run "$REPO_DIR/$server" invalid + [ "$status" -eq 1 ] + [[ "$output" == *"Usage"* ]] + done +} diff --git a/test/talktype.bats b/test/talktype.bats index fd23026..3a89d15 100644 --- a/test/talktype.bats +++ b/test/talktype.bats @@ -95,6 +95,71 @@ start_fake_recording() { [ ! -f "$TALKTYPE_DIR/ydotool.log" ] } +# ── Error handling ── + +@test "stale pid file with dead process still transcribes" { + # Simulate a crashed recording: PID file points to a dead process + echo "99999" > "$TALKTYPE_DIR/rec.pid" + echo "audio data" > "$TALKTYPE_DIR/rec.wav" + + run "$TALKTYPE" + [ "$status" -eq 0 ] + + # Should have cleaned up and transcribed + [ ! -f "$TALKTYPE_DIR/rec.pid" ] + [[ "$(cat "$TALKTYPE_DIR/ydotool.log")" == *"hello world"* ]] +} + +@test "transcription command failure is handled" { + start_fake_recording + export TALKTYPE_CMD="$BATS_TEST_DIRNAME/mock-transcribe-fail" + + run "$TALKTYPE" + + # Script should fail (set -e catches the non-zero exit) + [ "$status" -ne 0 ] + + # ydotool should NOT have been called + [ ! -f "$TALKTYPE_DIR/ydotool.log" ] +} + +# ── Recorder selection ── + +@test "ffmpeg is preferred over pw-record when available" { + run "$TALKTYPE" + [ "$status" -eq 0 ] + [ -f "$TALKTYPE_DIR/recorder.log" ] + + [[ "$(cat "$TALKTYPE_DIR/recorder.log")" == "ffmpeg" ]] +} + +@test "pw-record is used when ffmpeg is not available" { + # Remove ffmpeg from PATH by creating a sparse PATH without it + local sparse="$BATS_TEST_TMPDIR/no_ffmpeg" + mkdir -p "$sparse" + + # Copy all mocks except ffmpeg + for mock in "$BATS_TEST_DIRNAME"/mocks/*; do + name=$(basename "$mock") + [ "$name" = "ffmpeg" ] && continue + ln -sf "$mock" "$sparse/$name" + done + + # Add essential system tools + for cmd in bash mkdir cat kill sleep echo rm wait; do + local path + path=$(command -v "$cmd" 2>/dev/null) && ln -sf "$path" "$sparse/$cmd" + done + + PATH="$sparse" + + run "$TALKTYPE" + [ "$status" -eq 0 ] + [ -f "$TALKTYPE_DIR/recorder.log" ] + + [[ "$(cat "$TALKTYPE_DIR/recorder.log")" == "pw-record" ]] +} + # ── Dependency checking ── @test "fails when a required tool is missing" {