Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 3 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
name: CI

on:
push:
branches: [main]
pull_request:
branches: [main]

Expand Down Expand Up @@ -32,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
36 changes: 36 additions & 0 deletions test/mock-daemon.py
Original file line number Diff line number Diff line change
@@ -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()
4 changes: 4 additions & 0 deletions test/mock-transcribe-fail
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
# Mock transcriber that fails
echo "error: model not found" >&2
exit 1
3 changes: 2 additions & 1 deletion test/mocks/ffmpeg
Original file line number Diff line number Diff line change
@@ -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 $!
3 changes: 2 additions & 1 deletion test/mocks/pw-record
Original file line number Diff line number Diff line change
@@ -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 $!
99 changes: 99 additions & 0 deletions test/server.bats
Original file line number Diff line number Diff line change
@@ -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
}
65 changes: 65 additions & 0 deletions test/talktype.bats
Original file line number Diff line number Diff line change
Expand Up @@ -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" {
Expand Down