package provider import ( "context" "errors" "os" "os/exec" "path/filepath" "strings" "testing" ) // fakeBin writes an executable shell script called name into dir that runs // body. This fakes the provider's external processes at the real process // boundary (PATH manipulation, no mock framework). func fakeBin(t *testing.T, dir, name, body string) { t.Helper() script := "#!/bin/sh\n" + body + "\n" path := filepath.Join(dir, name) if err := os.WriteFile(path, []byte(script), 0o755); err != nil { t.Fatal(err) } } // emptyPATH sets PATH to a directory without a single binary: no espeak, // no spd-say. func emptyPATH(t *testing.T) string { t.Helper() dir := t.TempDir() t.Setenv("PATH", dir) return dir } // prependPATH puts dir in front of the existing PATH: binaries in it shadow // system binaries of the same name (fake espeak before a real one) while // helpers such as cat stay reachable. func prependPATH(t *testing.T, dir string) { t.Helper() t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) } // TestOSBuiltinName and TestNewRegistry pin the registry view. func TestOSBuiltinName(t *testing.T) { if got := (OSBuiltin{}).Name(); got != "osbuiltin" { t.Errorf("Name() = %q, want %q", got, "osbuiltin") } } func TestNewRegistry(t *testing.T) { for _, name := range []string{"", "osbuiltin"} { p, err := New(name, ChainConfig{}) if err != nil { t.Fatalf("New(%q): %v", name, err) } if p.Name() != "osbuiltin" { t.Errorf("New(%q).Name() = %q", name, p.Name()) } } // The cloud providers are in the registry. for _, name := range []string{"elevenlabs", "openai", "edge"} { p, err := New(name, ChainConfig{}) if err != nil { t.Fatalf("New(%q): %v", name, err) } if p.Name() != name { t.Errorf("New(%q).Name() = %q", name, p.Name()) } } if _, err := New("doesnotexist", ChainConfig{}); err == nil { t.Error("New(\"doesnotexist\") should fail") } else if !strings.Contains(err.Error(), "osbuiltin") || !strings.Contains(err.Error(), "elevenlabs") { t.Errorf("New error should name the available providers: %v", err) } } // TestAvailableWithoutTTS: empty PATH environment → Available false. func TestAvailableWithoutTTS(t *testing.T) { emptyPATH(t) if (OSBuiltin{}).Available(context.Background()) { t.Error("Available() = true without any TTS binary, want false") } } // TestSynthesizeWithoutTTS: if byte-capable TTS is missing, the error must // be ErrNoByteOutput (no panic, no other error type). func TestSynthesizeWithoutTTS(t *testing.T) { emptyPATH(t) _, err := (OSBuiltin{}).Synthesize(context.Background(), "Hello", "de") if err == nil { t.Fatal("Synthesize without TTS: no error") } if !errors.Is(err, ErrNoByteOutput) { t.Fatalf("Synthesize error = %v, want ErrNoByteOutput", err) } } // TestSynthesizeWithFakeEspeak: a faked espeak returns WAV bytes on stdout; // Synthesize must pass exactly these bytes through and must have received // the voice (-v ) and the text. func TestSynthesizeWithFakeEspeak(t *testing.T) { dir := t.TempDir() wav := filepath.Join(dir, "probe.wav") if err := os.WriteFile(wav, minimalWAV, 0o644); err != nil { t.Fatal(err) } seen := filepath.Join(dir, "seen.txt") fakeBin(t, dir, "espeak", `printf '%s\n' "$*" > "`+seen+`" cat "`+wav+`"`) prependPATH(t, dir) data, err := (OSBuiltin{}).Synthesize(context.Background(), "Hello world", "de") if err != nil { t.Fatalf("Synthesize: %v", err) } if string(data) != string(minimalWAV) { t.Errorf("Synthesize returned %d bytes, want the WAV bytes of the fake espeak (%d)", len(data), len(minimalWAV)) } args, err := os.ReadFile(seen) if err != nil { t.Fatalf("fake espeak was not called: %v", err) } if got := string(args); got != "-v de --stdout Hello world\n" { t.Errorf("fake espeak ARGS = %q, want %q", got, "-v de --stdout Hello world\n") } if !(OSBuiltin{}).Available(context.Background()) { t.Error("Available() = false despite espeak in PATH") } } // TestSynthesizeEmptyText is a clear error. func TestSynthesizeEmptyText(t *testing.T) { if _, err := (OSBuiltin{}).Synthesize(context.Background(), "", "de"); err == nil { t.Error("Synthesize(\"\") should fail") } } // TestSynthesizeFailingEspeak: espeak crashes (exit 1) → clear error, not // ErrNoByteOutput. func TestSynthesizeFailingEspeak(t *testing.T) { dir := emptyPATH(t) fakeBin(t, dir, "espeak", `echo "boom" >&2; exit 1`) _, err := (OSBuiltin{}).Synthesize(context.Background(), "Hello", "de") if err == nil { t.Fatal("Synthesize with crashing espeak: no error") } if errors.Is(err, ErrNoByteOutput) { t.Errorf("crashing espeak must not be ErrNoByteOutput: %v", err) } if !strings.Contains(err.Error(), "boom") { t.Errorf("error should contain the process stderr: %v", err) } } // TestSpeakDirectWithFakeSpdSay: SpeakDirect calls spd-say blocking (-w) // with language (-l ) and text. func TestSpeakDirectWithFakeSpdSay(t *testing.T) { dir := emptyPATH(t) seen := filepath.Join(dir, "seen.txt") fakeBin(t, dir, "spd-say", `printf '%s\n' "$*" > "`+seen+`"`) prependPATH(t, dir) if err := (OSBuiltin{}).SpeakDirect(context.Background(), "Hello world", "de"); err != nil { t.Fatalf("SpeakDirect: %v", err) } args, err := os.ReadFile(seen) if err != nil { t.Fatalf("fake spd-say was not called: %v", err) } if got := string(args); got != "-w -l de Hello world\n" { t.Errorf("fake spd-say ARGS = %q, want %q", got, "-w -l de Hello world\n") } } // TestSpeakDirectWithoutSpdSay is a clear error. func TestSpeakDirectWithoutSpdSay(t *testing.T) { emptyPATH(t) if err := (OSBuiltin{}).SpeakDirect(context.Background(), "Hello", "de"); err == nil { t.Error("SpeakDirect without spd-say should fail") } } // TestSpeakDirectRealSpdSay runs only when TALK_TEST_REAL_SPD=1 is set (it // produces real, audible output through speech-dispatcher; one-off manual // acceptance, standard test runs stay silent). func TestSpeakDirectRealSpdSay(t *testing.T) { if os.Getenv("TALK_TEST_REAL_SPD") != "1" { t.Skip("skipped (audible); run with TALK_TEST_REAL_SPD=1") } if _, err := exec.LookPath("spd-say"); err != nil { t.Skip("spd-say not installed") } if err := (OSBuiltin{}).SpeakDirect(context.Background(), "Speech test via speech dispatcher", "de"); err != nil { t.Fatalf("SpeakDirect against real spd-say: %v", err) } } // minimalWAV is a minimal valid RIFF/WAVE file (PCM 16 bit, mono, 8000 Hz) // with four samples, hand-built per the RIFF specification, independent of // the parser in the audio package. var minimalWAV = []byte{ 'R', 'I', 'F', 'F', 44, 0, 0, 0, // ChunkSize = 4 + (8+16) + (8+8) 'W', 'A', 'V', 'E', 'f', 'm', 't', ' ', 16, 0, 0, 0, 1, 0, // PCM 1, 0, // mono 0x40, 0x1F, 0, 0, // 8000 Hz 0x80, 0x3E, 0, 0, // ByteRate = 16000 2, 0, // BlockAlign 16, 0, // Bits 'd', 'a', 't', 'a', 8, 0, 0, 0, 0, 0, 0x10, 0x7F, 0xF0, 0x80, 0x00, 0x40, // 4 samples }