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) } }