Files
talk/test/autostart_test.go
m3ta-chironandClaude Fable 5.1 85fa64cfe6 refactor: switch the whole project to English
talk is going to be published. Code comments, CLI output, error messages,
daemon log lines, MCP descriptions and results, test names and assertions,
README, spec, skill, build scripts and flake are now English. Only the
spoken announcement texts stay bilingual: the wrap result and the test
sentence are German or English depending on the detected language.

Language fallback is now configurable: `lang = "en" | "de"` (env
TALK_LANG, default en) is used when detection cannot tell (numbers, single
words, ties) and for talk's own announcements. langdetect.Detect takes the
fallback as an argument instead of hard-coding German. `talk setup` asks for
it; `talk doctor` and `talk status` show it.

Vocabulary pinned across packages so the process-boundary tests observe
stable strings: chain log lines ("chain: tier X skipped (not available)",
"falling back to the next tier", "spoken via X"), queue lines ("discarded:
quiet hours ... active", "replaced by toast", "max_age"), CLI notes
("speaking directly", "daemon started at", "no daemon", "nothing to cancel"),
status block with aligned English labels, MCP messages, origin guard 403.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-24 11:15:35 +02:00

216 lines
8.0 KiB
Go

package test
// Process-boundary tests for the single-binary model: talk starts the daemon
// itself on the first speak command (daemon.autostart), parallel calls land
// in ONE queue, `talk daemon start|stop` control it explicitly, and a daemon
// that cannot start (port occupied by a foreign service) leads to the direct
// path instead of silence.
import (
"context"
"net/http"
"net/http/httptest"
"os"
"regexp"
"strconv"
"strings"
"sync"
"testing"
"time"
"code.m3ta.dev/m3tam3re/talk/internal/api"
"code.m3ta.dev/m3tam3re/talk/internal/daemon"
)
var itemID = regexp.MustCompile(`^[0-9a-f]{6}\n$`)
// autostartEnv is daemonEnv with autostart switched on, on a free port — and
// a cleanup that is guaranteed to collect the spawned daemon again (POST
// /shutdown; emergency brake: PID file + kill), so that no test run leaves
// background processes behind.
func autostartEnv(t *testing.T, root, fakeURL string, extra map[string]string) ([]string, *api.Client) {
t.Helper()
port := freePort(t)
set := map[string]string{"TALK_AUTOSTART": "true"}
for k, v := range extra {
set[k] = v
}
env := daemonEnv(root, port, fakeURL, set)
client := api.NewClient(port)
t.Cleanup(func() {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_ = client.Shutdown(ctx)
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if _, err := client.Status(ctx); err != nil {
break
}
time.Sleep(50 * time.Millisecond)
}
if pid := daemon.ReadPID(configDir(root)); pid > 0 {
if p, err := os.FindProcess(pid); err == nil {
_ = p.Kill()
}
}
})
return env, client
}
// TestAutostartStartsDaemon (acceptance criterion single binary): without a
// running daemon `talk say` starts it itself, reports that on stderr,
// returns an item ID on stdout, and the announcement runs through the queue
// and the fake provider. Afterwards the daemon is there (status), writes log
// and PID, and `talk daemon stop` ends it again.
func TestAutostartStartsDaemon(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
env, client := autostartEnv(t, root, fake.srv.URL, nil)
res := runTalk(t, env, "say", "first call starts")
if res.code != 0 {
t.Fatalf("talk say: exit %d, stderr: %s", res.code, res.stderr)
}
if !itemID.MatchString(res.stdout) {
t.Errorf("stdout = %q, want item ID (daemon path, not direct)", res.stdout)
}
if !strings.Contains(res.stderr, "daemon started") {
t.Errorf("stderr = %q, want the autostart note", res.stderr)
}
if hits := fake.waitCompleted(t, 1); hits[0].Text != "first call starts" {
t.Errorf("hit = %q", hits[0].Text)
}
// Second call: the daemon is already running — no second start.
res = runTalk(t, env, "say", "second call")
if res.code != 0 || strings.Contains(res.stderr, "daemon started") {
t.Errorf("second call: exit %d, stderr %q — must not start again", res.code, res.stderr)
}
fake.waitCompleted(t, 2)
// The daemon is visible: status, log and PID file.
res = runTalk(t, env, "status")
if res.code != 0 || !strings.Contains(res.stdout, "daemon: http://127.0.0.1:") {
t.Errorf("talk status: exit %d, stdout %q", res.code, res.stdout)
}
if _, err := os.Stat(configDir(root) + "/" + daemon.LogFileName); err != nil {
t.Errorf("daemon.log missing: %v", err)
}
if pid := daemon.ReadPID(configDir(root)); pid <= 0 {
t.Errorf("daemon.pid missing or empty")
}
// stop shuts it down; afterwards none is running, the PID file is gone.
res = runTalk(t, env, "daemon", "stop")
if res.code != 0 || res.stdout != "daemon: stopped\n" {
t.Fatalf("talk daemon stop: exit %d, stdout %q, stderr %s", res.code, res.stdout, res.stderr)
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if _, err := client.Status(ctx); err == nil {
t.Errorf("daemon still answers after stop")
}
if pid := daemon.ReadPID(configDir(root)); pid != 0 {
t.Errorf("daemon.pid still there after stop (%d)", pid)
}
// stop once more: idempotent.
res = runTalk(t, env, "daemon", "stop")
if res.code != 0 || !strings.Contains(res.stdout, "not running") {
t.Errorf("second stop: exit %d, stdout %q", res.code, res.stdout)
}
}
// TestAutostartSerializesParallel: two simultaneous talk calls without a
// daemon — one starts it, the other finds it (or loses the port race and
// finds it anyway). Both announcements run through ONE queue: one after
// another, not overlapping.
func TestAutostartSerializesParallel(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 250*time.Millisecond)
env, _ := autostartEnv(t, root, fake.srv.URL, nil)
var wg sync.WaitGroup
results := make([]talkResult, 2)
for i, text := range []string{"one", "two"} {
wg.Add(1)
go func(i int, text string) {
defer wg.Done()
results[i] = runTalk(t, env, "say", "--wait", text)
}(i, text)
}
wg.Wait()
for i, res := range results {
if res.code != 0 {
t.Fatalf("talk %d (parallel, autostart): exit %d, stderr: %s", i, res.code, res.stderr)
}
if !itemID.MatchString(res.stdout) {
t.Errorf("talk %d stdout = %q, want item ID (both via the daemon)", i, res.stdout)
}
}
hits := fake.waitHits(t, 2)
const slack = 25 * time.Millisecond
if hits[1].Start.Before(hits[0].End.Add(-slack)) {
t.Errorf("announcements overlapped: start(2)=%v < end(1)=%v — not ONE queue", hits[1].Start, hits[0].End)
}
}
// TestDaemonStartExplicit: `talk daemon start` starts in the background and
// is idempotent; the port is in the message.
func TestDaemonStartExplicit(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
env, client := autostartEnv(t, root, fake.srv.URL, map[string]string{"TALK_AUTOSTART": "false"})
res := runTalk(t, env, "daemon", "start")
if res.code != 0 || res.stdout != "daemon: started at "+client.BaseURL+"\n" {
t.Fatalf("talk daemon start: exit %d, stdout %q, stderr %s", res.code, res.stdout, res.stderr)
}
res = runTalk(t, env, "daemon", "start")
if res.code != 0 || !strings.Contains(res.stdout, "already running") {
t.Errorf("second start: exit %d, stdout %q", res.code, res.stdout)
}
// Autostart off, but the daemon is there: say uses it.
res = runTalk(t, env, "say", "via explicitly started daemon")
if res.code != 0 || !itemID.MatchString(res.stdout) {
t.Errorf("say: exit %d, stdout %q", res.code, res.stdout)
}
fake.waitCompleted(t, 1)
}
// TestAutostartFailsSpeaksDirectly: the port belongs to a foreign service
// (does not answer like talk) — the spawned daemon cannot bind and ends
// immediately; talk notices that from the child's exit (no timeout wait)
// and speaks directly. The user sees the reason and the log path.
func TestAutostartFailsSpeaksDirectly(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
foreign := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
t.Cleanup(foreign.Close)
port, _ := strconv.Atoi(foreign.URL[strings.LastIndex(foreign.URL, ":")+1:])
env := daemonEnv(root, port, fake.srv.URL, map[string]string{"TALK_AUTOSTART": "true"})
start := time.Now()
res := runTalk(t, env, "say", "port is occupied by a foreign service")
if res.code != 0 {
t.Fatalf("talk say: exit %d, stderr: %s", res.code, res.stderr)
}
if d := time.Since(start); d > 4*time.Second {
t.Errorf("duration %v — the start failure should be detected from the child's exit, not via timeout", d)
}
if !strings.Contains(res.stderr, "daemon start failed") || !strings.Contains(res.stderr, "speaking directly") {
t.Errorf("stderr = %q, want the failure reason and the direct-path note", res.stderr)
}
if !strings.Contains(res.stderr, daemon.LogFileName) {
t.Errorf("stderr = %q, want the log path hint", res.stderr)
}
if hits := fake.waitHits(t, 1); hits[0].Text != "port is occupied by a foreign service" {
t.Errorf("hit = %q", hits[0].Text)
}
logBytes, err := os.ReadFile(configDir(root) + "/" + daemon.LogFileName)
if err != nil || !strings.Contains(string(logBytes), "bind") {
t.Errorf("daemon.log = %q (%v), want the bind error", logBytes, err)
}
}