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>
This commit is contained in:
1 parent
4d30bd95ad
commit
85fa64cfe6
95 files changed
+4707
-4854
No files matched your search
+76
-76
@@ -1,10 +1,10 @@
|
||||
package test
|
||||
|
||||
// Prozessgrenzen-Tests für das Ein-Binary-Modell: talk startet den Daemon
|
||||
// beim ersten Sprechbefehl selbst (daemon.autostart), parallele Aufrufe
|
||||
// landen in EINER Queue, `talk daemon start|stop` steuern ihn explizit, und
|
||||
// ein nicht startbarer Daemon (Port von einem fremden Dienst belegt) führt
|
||||
// in den Direkt-Pfad statt ins Schweigen.
|
||||
// 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"
|
||||
@@ -24,10 +24,10 @@ import (
|
||||
|
||||
var itemID = regexp.MustCompile(`^[0-9a-f]{6}\n$`)
|
||||
|
||||
// autostartEnv ist daemonEnv mit eingeschaltetem Autostart auf einem
|
||||
// freien Port — und einem Cleanup, das den gespawnten Daemon garantiert
|
||||
// wieder einsammelt (POST /shutdown; Notbremse: PID-Datei + Kill), damit
|
||||
// kein Testlauf Hintergrundprozesse hinterlässt.
|
||||
// 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)
|
||||
@@ -57,81 +57,81 @@ func autostartEnv(t *testing.T, root, fakeURL string, extra map[string]string) (
|
||||
return env, client
|
||||
}
|
||||
|
||||
// TestAutostartStartetDaemon (Abnahmekriterium Ein-Binary): ohne laufenden
|
||||
// Daemon startet `talk say` ihn selbst, meldet das auf stderr, liefert eine
|
||||
// Item-ID auf stdout, und die Ansage läuft durch Queue und Fake-Provider.
|
||||
// Danach ist der Daemon da (status), schreibt Log und PID, und `talk daemon
|
||||
// stop` beendet ihn wieder.
|
||||
func TestAutostartStartetDaemon(t *testing.T) {
|
||||
// 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", "erster aufruf startet")
|
||||
res := runTalk(t, env, "say", "first call starts")
|
||||
if res.code != 0 {
|
||||
t.Fatalf("talk say: Exit %d, stderr: %s", res.code, res.stderr)
|
||||
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-Pfad, nicht direkt)", res.stdout)
|
||||
t.Errorf("stdout = %q, want item ID (daemon path, not direct)", res.stdout)
|
||||
}
|
||||
if !strings.Contains(res.stderr, "Daemon gestartet") {
|
||||
t.Errorf("stderr = %q, will Autostart-Hinweis", res.stderr)
|
||||
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 != "erster aufruf startet" {
|
||||
t.Errorf("Treffer = %q", hits[0].Text)
|
||||
if hits := fake.waitCompleted(t, 1); hits[0].Text != "first call starts" {
|
||||
t.Errorf("hit = %q", hits[0].Text)
|
||||
}
|
||||
|
||||
// Zweiter Aufruf: Daemon läuft schon — kein zweiter Start.
|
||||
res = runTalk(t, env, "say", "zweiter aufruf")
|
||||
if res.code != 0 || strings.Contains(res.stderr, "Daemon gestartet") {
|
||||
t.Errorf("zweiter Aufruf: Exit %d, stderr %q — darf nicht erneut starten", res.code, res.stderr)
|
||||
// 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)
|
||||
|
||||
// Der Daemon ist sichtbar: status, Log- und PID-Datei.
|
||||
// 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 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 fehlt: %v", err)
|
||||
t.Errorf("daemon.log missing: %v", err)
|
||||
}
|
||||
if pid := daemon.ReadPID(configDir(root)); pid <= 0 {
|
||||
t.Errorf("daemon.pid fehlt oder leer")
|
||||
t.Errorf("daemon.pid missing or empty")
|
||||
}
|
||||
|
||||
// stop fährt ihn herunter; danach läuft keiner mehr, PID-Datei weg.
|
||||
// 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: gestoppt\n" {
|
||||
t.Fatalf("talk daemon stop: Exit %d, stdout %q, stderr %s", res.code, res.stdout, res.stderr)
|
||||
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 antwortet nach stop noch")
|
||||
t.Errorf("daemon still answers after stop")
|
||||
}
|
||||
if pid := daemon.ReadPID(configDir(root)); pid != 0 {
|
||||
t.Errorf("daemon.pid nach stop noch da (%d)", pid)
|
||||
t.Errorf("daemon.pid still there after stop (%d)", pid)
|
||||
}
|
||||
// Nochmal stop: idempotent.
|
||||
// stop once more: idempotent.
|
||||
res = runTalk(t, env, "daemon", "stop")
|
||||
if res.code != 0 || !strings.Contains(res.stdout, "läuft nicht") {
|
||||
t.Errorf("zweites stop: Exit %d, stdout %q", res.code, res.stdout)
|
||||
if res.code != 0 || !strings.Contains(res.stdout, "not running") {
|
||||
t.Errorf("second stop: exit %d, stdout %q", res.code, res.stdout)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAutostartSerialisiertParallele: zwei gleichzeitige talk-Aufrufe ohne
|
||||
// Daemon — einer startet ihn, der andere findet ihn (oder verliert das
|
||||
// Port-Rennen und findet ihn trotzdem). Beide Ansagen laufen durch EINE
|
||||
// Queue: nacheinander, nicht überlappend.
|
||||
func TestAutostartSerialisiertParallele(t *testing.T) {
|
||||
// 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{"eins", "zwei"} {
|
||||
for i, text := range []string{"one", "two"} {
|
||||
wg.Add(1)
|
||||
go func(i int, text string) {
|
||||
defer wg.Done()
|
||||
@@ -141,75 +141,75 @@ func TestAutostartSerialisiertParallele(t *testing.T) {
|
||||
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)
|
||||
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 (beide über den Daemon)", i, 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("Ansagen überlappten: Start(2)=%v < Ende(1)=%v — nicht EINE Queue", hits[1].Start, hits[0].End)
|
||||
t.Errorf("announcements overlapped: start(2)=%v < end(1)=%v — not ONE queue", hits[1].Start, hits[0].End)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDaemonStartExplizit: `talk daemon start` startet im Hintergrund und
|
||||
// ist idempotent; der Port steht in der Meldung.
|
||||
func TestDaemonStartExplizit(t *testing.T) {
|
||||
// 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: gestartet unter "+client.BaseURL+"\n" {
|
||||
t.Fatalf("talk daemon start: Exit %d, stdout %q, stderr %s", res.code, res.stdout, res.stderr)
|
||||
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, "läuft bereits") {
|
||||
t.Errorf("zweites start: Exit %d, stdout %q", res.code, res.stdout)
|
||||
if res.code != 0 || !strings.Contains(res.stdout, "already running") {
|
||||
t.Errorf("second start: exit %d, stdout %q", res.code, res.stdout)
|
||||
}
|
||||
// Autostart aus, aber Daemon da: say nutzt ihn.
|
||||
res = runTalk(t, env, "say", "über explizit gestarteten daemon")
|
||||
// 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)
|
||||
t.Errorf("say: exit %d, stdout %q", res.code, res.stdout)
|
||||
}
|
||||
fake.waitCompleted(t, 1)
|
||||
}
|
||||
|
||||
// TestAutostartScheitertSprichtDirekt: Der Port gehört einem fremden Dienst
|
||||
// (antwortet nicht wie talk) — der gespawnte Daemon kann nicht binden und
|
||||
// endet sofort; talk erkennt das am Kind-Ende (kein Timeout-Warten) und
|
||||
// spricht direkt. Der Benutzer sieht den Grund und den Log-Pfad.
|
||||
func TestAutostartScheitertSprichtDirekt(t *testing.T) {
|
||||
// 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)
|
||||
fremd := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
foreign := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
t.Cleanup(fremd.Close)
|
||||
port, _ := strconv.Atoi(fremd.URL[strings.LastIndex(fremd.URL, ":")+1:])
|
||||
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 ist fremd belegt")
|
||||
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)
|
||||
t.Fatalf("talk say: exit %d, stderr: %s", res.code, res.stderr)
|
||||
}
|
||||
if d := time.Since(start); d > 4*time.Second {
|
||||
t.Errorf("Dauer %v — Start-Fehlschlag soll am Kind-Ende erkannt werden, nicht per Timeout", d)
|
||||
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 fehlgeschlagen") || !strings.Contains(res.stderr, "spreche direkt") {
|
||||
t.Errorf("stderr = %q, will Fehlschlag-Grund und Direkt-Hinweis", res.stderr)
|
||||
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, will Log-Pfad-Hinweis", res.stderr)
|
||||
t.Errorf("stderr = %q, want the log path hint", res.stderr)
|
||||
}
|
||||
if hits := fake.waitHits(t, 1); hits[0].Text != "port ist fremd belegt" {
|
||||
t.Errorf("Treffer = %q", hits[0].Text)
|
||||
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), "Port belegen") {
|
||||
t.Errorf("daemon.log = %q (%v), will Port-belegt-Fehler", logBytes, err)
|
||||
if err != nil || !strings.Contains(string(logBytes), "bind") {
|
||||
t.Errorf("daemon.log = %q (%v), want the bind error", logBytes, err)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user