Files
talk/test/wrap_test.go
T
m3ta-chironandClaude Fable 5.1 bc26a73aeb refactor: ein Binary — talk startet den Daemon selbst, talkd und Degraded Mode entfallen
Zwei Binaries erzeugten den halben Komplexitätsballast: Degraded Mode mit
Lock-Datei und Stale-Lock-Race, „läuft talkd?", Autostart-Konfiguration je
OS, doppelte Chain-Konstruktion in cmd/talk und cmd/talkd — und der
Degraded Mode kannte weder Rate-Limit noch Ruhezeiten.

Jetzt:
- internal/speak: der eine Sprech-Kern (Config + Sink + Kette → Ansage);
  Daemon und Direkt-Pfad benutzen ihn, es gibt keine zweite
  Zusammensetzung der Kette mehr.
- internal/daemon: Run (der bisherige talkd-Kern), Spawn (detached per
  Setsid bzw. DETACHED_PROCESS, stdout/stderr → daemon.log), PID-Datei
  daemon.pid, Shutdown-Trigger.
- `talk daemon run|start|stop`: Vordergrund (systemd/launchd), expliziter
  Hintergrund-Start, Stop per POST /shutdown — stop wartet, bis HTTP weg
  und die PID-Datei gelöscht ist (Prozess wirklich beendet).
- Autostart: say/wrap starten den Daemon beim ersten Sprechbefehl selbst
  (daemon.autostart, Default an; Env TALK_AUTOSTART). Verliert ein
  paralleler Aufruf das Port-Rennen, erkennt er das am Kind-Ende und nutzt
  den Daemon des anderen — kein Timeout-Warten.
- Direkt-Pfad statt Degraded Mode: ohne erreichbaren oder startbaren
  Daemon spricht talk direkt (Mute respektiert, kein Lock, Hinweis +
  Grund + Log-Pfad auf stderr). Steuerbefehle starten den Daemon bewusst
  nicht; `talk status` zeigt „läuft nicht" (Exit 1).
- cmd/talkd und cmd/talk/degraded.go entfernt; README-Daemon-Abschnitt
  neu.

Tests: Harness baut nur noch talk und startet `talk daemon run`;
TALK_AUTOSTART=false als Grundausstattung aller Tests (kein Test
hinterlässt Daemons), autostart_test.go schaltet es bewusst an: Autostart
beim ersten say, Idempotenz, paralleler Start in EINE Queue, expliziter
start/stop, Port fremd belegt → Direkt-Pfad ohne Timeout. Cross-Compile
aller sechs Targets mit CGO_ENABLED=0 verifiziert.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-24 09:49:16 +02:00

169 lines
6.2 KiB
Go

// wrap-Prozessgrenzen-Tests (Task 07): talk läuft als echter Prozess,
// talkd als echter Daemon, Fake-Provider + sink null wie im Harness —
// beobachtet wird nur extern: Exit-Codes, Befehls-stdout, Ansage-Text am
// Fake. Die gewrappten Befehle sind echte Binaries (sleep/sh/true), die
// Dauern bewusst klein.
package test
import (
"regexp"
"strings"
"testing"
"time"
)
// TestWrapErfolgMitDauer (Abnahmekriterium): `talk wrap -- sleep 1`
// annonciert Erfolg inklusive Dauer und exitet 0.
func TestWrapErfolgMitDauer(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
d := startDaemon(t, root, fake.srv.URL, nil)
env := daemonEnv(root, d.port, fake.srv.URL, nil)
res := runTalk(t, env, "wrap", "--", "sleep", "1")
if res.code != 0 {
t.Fatalf("talk wrap -- sleep 1: Exit %d, stderr: %s", res.code, res.stderr)
}
hits := fake.waitCompleted(t, 1)
// sleep 1 misst ~1 s (unter Last gern mehr) — Zahl offen, Form nicht.
if !regexp.MustCompile(`^sleep erfolgreich\. Dauer: \d+ Sekunden?\.$`).MatchString(hits[0].Text) {
t.Errorf("Ansage = %q, will „sleep erfolgreich. Dauer: N Sekunde(n).“", hits[0].Text)
}
waitStatusIdle(t, d, 3*time.Second)
}
// TestWrapFehlschlagCodeDurchgereicht (Abnahmekriterium): scheitert der
// Befehl mit Code 2, exitet wrap mit 2 und sagt den Misserfolg mit Code —
// ohne Dauer, wortgenau nach Brief.
func TestWrapFehlschlagCodeDurchgereicht(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
d := startDaemon(t, root, fake.srv.URL, nil)
env := daemonEnv(root, d.port, fake.srv.URL, nil)
res := runTalk(t, env, "wrap", "--", "sh", "-c", "exit 2")
if res.code != 2 {
t.Fatalf("talk wrap -- sh -c 'exit 2': Exit %d, want 2 (Durchreichung)", res.code)
}
hits := fake.waitCompleted(t, 1)
if hits[0].Text != "sh fehlgeschlagen, Code 2." {
t.Errorf("Ansage = %q, want %q", hits[0].Text, "sh fehlgeschlagen, Code 2.")
}
waitStatusIdle(t, d, 3*time.Second)
}
// TestWrapFlagShielding (Regression, Task-07-Kern): `talk wrap -- true
// --bar` reicht --bar an den Befehl weiter — talk darf --bar NICHT als
// eigenes Flag parsen (wäre Exit 2 mit Flag-Fehler). GNU true ignoriert
// Operanden, exitet also 0, und die Erfolg-Ansage läuft.
func TestWrapFlagShielding(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
d := startDaemon(t, root, fake.srv.URL, nil)
env := daemonEnv(root, d.port, fake.srv.URL, nil)
res := runTalk(t, env, "wrap", "--", "true", "--bar")
if res.code != 0 {
t.Fatalf("talk wrap -- true --bar: Exit %d, want 0 (--bar gehört dem Befehl) — stderr: %s", res.code, res.stderr)
}
if strings.Contains(res.stderr, "flag provided but not defined") {
t.Errorf("talk hat --bar als eigenes Flag geparsed — Shielding kaputt: %s", res.stderr)
}
hits := fake.waitCompleted(t, 1)
if !strings.HasPrefix(hits[0].Text, "true erfolgreich.") {
t.Errorf("Ansage = %q, will Erfolg für true", hits[0].Text)
}
waitStatusIdle(t, d, 3*time.Second)
}
// TestWrapLabelOverride: TALK_WRAP_LABEL ersetzt den Basisnamen in der
// Ansage (Fleet-Muster: Env statt Config-Datei).
func TestWrapLabelOverride(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
d := startDaemon(t, root, fake.srv.URL, nil)
env := daemonEnv(root, d.port, fake.srv.URL, map[string]string{"TALK_WRAP_LABEL": "Deployment"})
res := runTalk(t, env, "wrap", "--", "false")
if res.code != 1 {
t.Fatalf("talk wrap -- false: Exit %d, want 1", res.code)
}
hits := fake.waitCompleted(t, 1)
if hits[0].Text != "Deployment fehlgeschlagen, Code 1." {
t.Errorf("Ansage = %q, want %q", hits[0].Text, "Deployment fehlgeschlagen, Code 1.")
}
waitStatusIdle(t, d, 3*time.Second)
}
// TestWrapNonBlocking: wrap kehrt nach dem enqueue zurück, bevor die
// Ansage zu Ende gespielt ist (Ansage dauert 600 ms am Fake) — stdout
// gehört dabei allein dem Befehl, die Item-ID geht auf stderr.
func TestWrapNonBlocking(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 600*time.Millisecond)
d := startDaemon(t, root, fake.srv.URL, nil)
env := daemonEnv(root, d.port, fake.srv.URL, nil)
start := time.Now()
res := runTalk(t, env, "wrap", "--", "sh", "-c", "echo bauausgabe")
elapsed := time.Since(start)
if res.code != 0 {
t.Fatalf("talk wrap: Exit %d, stderr: %s", res.code, res.stderr)
}
if elapsed >= 500*time.Millisecond {
t.Fatalf("talk wrap war blockierend (%s; Ansage dauert 600 ms) — Default muss non-blocking sein", elapsed)
}
if res.stdout != "bauausgabe\n" {
t.Errorf("stdout = %q, will ausschließlich die Befehlsausgabe (Item-ID gehört auf stderr)", res.stdout)
}
if strings.TrimSpace(res.stderr) == "" {
t.Errorf("stderr = %q, will mindestens die Item-ID der Ansage", res.stderr)
}
// Die Ansage läuft im Daemon trotzdem zu Ende.
fake.waitCompleted(t, 1)
waitStatusIdle(t, d, 3*time.Second)
}
// TestWrapWait: `talk wrap --wait -- …` kehrt erst nach Ende der Ansage
// zurück (geerbtes say-Flag, hier: 400 ms Synthese).
func TestWrapWait(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 400*time.Millisecond)
d := startDaemon(t, root, fake.srv.URL, nil)
env := daemonEnv(root, d.port, fake.srv.URL, nil)
start := time.Now()
res := runTalk(t, env, "wrap", "--wait", "--", "true")
elapsed := time.Since(start)
if res.code != 0 {
t.Fatalf("talk wrap --wait: Exit %d, stderr: %s", res.code, res.stderr)
}
if elapsed < 350*time.Millisecond {
t.Fatalf("talk wrap --wait kam zu früh zurück (%s; Ansage dauert 400 ms)", elapsed)
}
waitStatusIdle(t, d, 3*time.Second)
}
// TestWrapOhneDaemon: Daemon tot, Autostart aus → Ansage direkt gegen den
// Fake, und der Exit-Code des Befehls bleibt trotzdem.
func TestWrapOhneDaemon(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
env := daemonEnv(root, 1, fake.srv.URL, nil) // Port 1: Daemon garantiert tot
res := runTalk(t, env, "wrap", "--", "sh", "-c", "exit 3")
if res.code != 3 {
t.Fatalf("talk wrap (direkt): Exit %d, want 3 (Befehls-Code gewinnt)", res.code)
}
hits := fake.waitCompleted(t, 1)
if hits[0].Text != "sh fehlgeschlagen, Code 3." {
t.Errorf("Ansage = %q, want %q", hits[0].Text, "sh fehlgeschlagen, Code 3.")
}
if !strings.Contains(res.stderr, "spreche direkt") {
t.Errorf("stderr = %q, will Direkt-Hinweis (nichts bricht still)", res.stderr)
}
}