2026-10-24 09:49:16 +02:00
|
|
|
package test
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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.
|
2026-10-24 09:49:16 +02:00
|
|
|
|
|
|
|
|
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$`)
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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.
|
2026-10-24 09:49:16 +02:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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) {
|
2026-10-24 09:49:16 +02:00
|
|
|
root := t.TempDir()
|
|
|
|
|
fake := newFakeProvider(t, 0)
|
|
|
|
|
env, client := autostartEnv(t, root, fake.srv.URL, nil)
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
res := runTalk(t, env, "say", "first call starts")
|
2026-10-24 09:49:16 +02:00
|
|
|
if res.code != 0 {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("talk say: exit %d, stderr: %s", res.code, res.stderr)
|
2026-10-24 09:49:16 +02:00
|
|
|
}
|
|
|
|
|
if !itemID.MatchString(res.stdout) {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("stdout = %q, want item ID (daemon path, not direct)", res.stdout)
|
2026-10-24 09:49:16 +02:00
|
|
|
}
|
2026-10-24 11:15:35 +02:00
|
|
|
if !strings.Contains(res.stderr, "daemon started") {
|
|
|
|
|
t.Errorf("stderr = %q, want the autostart note", res.stderr)
|
2026-10-24 09:49:16 +02:00
|
|
|
}
|
2026-10-24 11:15:35 +02:00
|
|
|
if hits := fake.waitCompleted(t, 1); hits[0].Text != "first call starts" {
|
|
|
|
|
t.Errorf("hit = %q", hits[0].Text)
|
2026-10-24 09:49:16 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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)
|
2026-10-24 09:49:16 +02:00
|
|
|
}
|
|
|
|
|
fake.waitCompleted(t, 2)
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// The daemon is visible: status, log and PID file.
|
2026-10-24 09:49:16 +02:00
|
|
|
res = runTalk(t, env, "status")
|
2026-10-24 11:15:35 +02:00
|
|
|
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)
|
2026-10-24 09:49:16 +02:00
|
|
|
}
|
|
|
|
|
if _, err := os.Stat(configDir(root) + "/" + daemon.LogFileName); err != nil {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("daemon.log missing: %v", err)
|
2026-10-24 09:49:16 +02:00
|
|
|
}
|
|
|
|
|
if pid := daemon.ReadPID(configDir(root)); pid <= 0 {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("daemon.pid missing or empty")
|
2026-10-24 09:49:16 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// stop shuts it down; afterwards none is running, the PID file is gone.
|
2026-10-24 09:49:16 +02:00
|
|
|
res = runTalk(t, env, "daemon", "stop")
|
2026-10-24 11:15:35 +02:00
|
|
|
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)
|
2026-10-24 09:49:16 +02:00
|
|
|
}
|
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
|
|
|
|
defer cancel()
|
|
|
|
|
if _, err := client.Status(ctx); err == nil {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("daemon still answers after stop")
|
2026-10-24 09:49:16 +02:00
|
|
|
}
|
|
|
|
|
if pid := daemon.ReadPID(configDir(root)); pid != 0 {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("daemon.pid still there after stop (%d)", pid)
|
2026-10-24 09:49:16 +02:00
|
|
|
}
|
2026-10-24 11:15:35 +02:00
|
|
|
// stop once more: idempotent.
|
2026-10-24 09:49:16 +02:00
|
|
|
res = runTalk(t, env, "daemon", "stop")
|
2026-10-24 11:15:35 +02:00
|
|
|
if res.code != 0 || !strings.Contains(res.stdout, "not running") {
|
|
|
|
|
t.Errorf("second stop: exit %d, stdout %q", res.code, res.stdout)
|
2026-10-24 09:49:16 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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) {
|
2026-10-24 09:49:16 +02:00
|
|
|
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)
|
2026-10-24 11:15:35 +02:00
|
|
|
for i, text := range []string{"one", "two"} {
|
2026-10-24 09:49:16 +02:00
|
|
|
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 {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("talk %d (parallel, autostart): exit %d, stderr: %s", i, res.code, res.stderr)
|
2026-10-24 09:49:16 +02:00
|
|
|
}
|
|
|
|
|
if !itemID.MatchString(res.stdout) {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("talk %d stdout = %q, want item ID (both via the daemon)", i, res.stdout)
|
2026-10-24 09:49:16 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
hits := fake.waitHits(t, 2)
|
|
|
|
|
const slack = 25 * time.Millisecond
|
|
|
|
|
if hits[1].Start.Before(hits[0].End.Add(-slack)) {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("announcements overlapped: start(2)=%v < end(1)=%v — not ONE queue", hits[1].Start, hits[0].End)
|
2026-10-24 09:49:16 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// TestDaemonStartExplicit: `talk daemon start` starts in the background and
|
|
|
|
|
// is idempotent; the port is in the message.
|
|
|
|
|
func TestDaemonStartExplicit(t *testing.T) {
|
2026-10-24 09:49:16 +02:00
|
|
|
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")
|
2026-10-24 11:15:35 +02:00
|
|
|
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)
|
2026-10-24 09:49:16 +02:00
|
|
|
}
|
|
|
|
|
res = runTalk(t, env, "daemon", "start")
|
2026-10-24 11:15:35 +02:00
|
|
|
if res.code != 0 || !strings.Contains(res.stdout, "already running") {
|
|
|
|
|
t.Errorf("second start: exit %d, stdout %q", res.code, res.stdout)
|
2026-10-24 09:49:16 +02:00
|
|
|
}
|
2026-10-24 11:15:35 +02:00
|
|
|
// Autostart off, but the daemon is there: say uses it.
|
|
|
|
|
res = runTalk(t, env, "say", "via explicitly started daemon")
|
2026-10-24 09:49:16 +02:00
|
|
|
if res.code != 0 || !itemID.MatchString(res.stdout) {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("say: exit %d, stdout %q", res.code, res.stdout)
|
2026-10-24 09:49:16 +02:00
|
|
|
}
|
|
|
|
|
fake.waitCompleted(t, 1)
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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) {
|
2026-10-24 09:49:16 +02:00
|
|
|
root := t.TempDir()
|
|
|
|
|
fake := newFakeProvider(t, 0)
|
2026-10-24 11:15:35 +02:00
|
|
|
foreign := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
2026-10-24 09:49:16 +02:00
|
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
|
|
|
}))
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Cleanup(foreign.Close)
|
|
|
|
|
port, _ := strconv.Atoi(foreign.URL[strings.LastIndex(foreign.URL, ":")+1:])
|
2026-10-24 09:49:16 +02:00
|
|
|
env := daemonEnv(root, port, fake.srv.URL, map[string]string{"TALK_AUTOSTART": "true"})
|
|
|
|
|
|
|
|
|
|
start := time.Now()
|
2026-10-24 11:15:35 +02:00
|
|
|
res := runTalk(t, env, "say", "port is occupied by a foreign service")
|
2026-10-24 09:49:16 +02:00
|
|
|
if res.code != 0 {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("talk say: exit %d, stderr: %s", res.code, res.stderr)
|
2026-10-24 09:49:16 +02:00
|
|
|
}
|
|
|
|
|
if d := time.Since(start); d > 4*time.Second {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("duration %v — the start failure should be detected from the child's exit, not via timeout", d)
|
2026-10-24 09:49:16 +02:00
|
|
|
}
|
2026-10-24 11:15:35 +02:00
|
|
|
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)
|
2026-10-24 09:49:16 +02:00
|
|
|
}
|
|
|
|
|
if !strings.Contains(res.stderr, daemon.LogFileName) {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("stderr = %q, want the log path hint", res.stderr)
|
2026-10-24 09:49:16 +02:00
|
|
|
}
|
2026-10-24 11:15:35 +02:00
|
|
|
if hits := fake.waitHits(t, 1); hits[0].Text != "port is occupied by a foreign service" {
|
|
|
|
|
t.Errorf("hit = %q", hits[0].Text)
|
2026-10-24 09:49:16 +02:00
|
|
|
}
|
|
|
|
|
logBytes, err := os.ReadFile(configDir(root) + "/" + daemon.LogFileName)
|
2026-10-24 11:15:35 +02:00
|
|
|
if err != nil || !strings.Contains(string(logBytes), "bind") {
|
|
|
|
|
t.Errorf("daemon.log = %q (%v), want the bind error", logBytes, err)
|
2026-10-24 09:49:16 +02:00
|
|
|
}
|
|
|
|
|
}
|