Files
Cyrene_v2/internal/runtime/voice/ttslaunch/script_test.go
T
AskaEth 6906d274fc feat(voice): M47 后端启动后探活 emotionTTS,未起则异步拉起一键启动脚本
主人 2026-10-24:「tts启动脚本在这 ...\一键启动.bat,以后启动后端可以顺便检测
一下如果TTS没起就顺带启一下」。本片只做「启动时顺带探一下」,**默认关**(主人
早前说过「开机自启先不急 后面做」),未开开关时连探活都不发。

- 新包 internal/runtime/voice/ttslaunch:探 http://127.0.0.1:9880(毫秒级超时),
  已在跑什么都不做;没起则异步起脚本(cmd.exe /c 传参,不拼命令行字符串)
- 防重复:in-flight 单飞 + 一次尝试后的冷却(失败也算一次,重启风暴不会变成重试风暴)
- 失败只 WARN:探活超时/脚本不存在/起进程失败都只记带 source 的日志,
  组合根那条路径**没有 error 通道**
- 配置走面板文件 $DATA_DIR/config/voice-tts-launch.json(零新 .env 键)
- 接线在 cmd/cyrene 的网关 Listen 之后,整段在后台 goroutine 里,不占启动时长
  (用例以「挂载耗时 < 150ms 而探活超时 800ms」证明)
- 不新开端点、不加帧族;不碰 gateway 包
2026-10-26 18:47:36 +08:00

97 lines
3.3 KiB
Go

package ttslaunch
import (
"context"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
)
// TestLaunchScript_MissingFile 钉住"脚本不存在 ⇒ 返回可读错误"(调用方只 WARN)。
func TestLaunchScript_MissingFile(t *testing.T) {
missing := filepath.Join(t.TempDir(), "一键启动.bat")
err := LaunchScript(context.Background(), missing)
if err == nil {
t.Fatal("脚本不存在却没报错")
}
if !strings.Contains(err.Error(), "一键启动.bat") {
t.Fatalf("错误里没有脚本路径: %v", err)
}
}
// TestLaunchScript_Directory 钉住"路径是目录 ⇒ 报错"(配置写错时别去 cmd /c 一个目录)。
func TestLaunchScript_Directory(t *testing.T) {
if err := LaunchScript(context.Background(), t.TempDir()); err == nil {
t.Fatal("目录被当成脚本起起来了")
}
}
// TestLaunchScript_EmptyPath 钉住空路径不会退化成"起当前目录里的东西"。
func TestLaunchScript_EmptyPath(t *testing.T) {
if err := LaunchScript(context.Background(), " "); err == nil {
t.Fatal("空路径没报错")
}
}
// TestLaunchScript_StartsBatchFile 是本切片唯一真起进程的用例(只在 Windows 上有意义):
// 它同时钉住三件容易踩的事 ——
//
// 1. `.bat` 必须经解释器起得来(裸 CreateProcess 不行);
// 2. 路径含**中文**(主人的 `一键启动.bat` 就是这个形状)时编码/引号不出岔子;
// 3. 工作目录按脚本所在目录解析(脚本里的相对路径是相对**它自己**)。
func TestLaunchScript_StartsBatchFile(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("一键启动.bat 只在 Windows 上有意义")
}
// 不用 t.TempDir:脚本进程的**工作目录**就是脚本所在目录,Windows 在它退出前
// 不允许删那个目录,t.TempDir 的自动清理会误报失败。这里自己建、自己尽力清。
base, err := os.MkdirTemp("", "ttslaunch-test-")
if err != nil {
t.Fatalf("mkdtemp: %v", err)
}
defer cleanupEventually(t, base)
dir := filepath.Join(base, "emotionTTS_语音 服务")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
script := filepath.Join(dir, "一键启动.bat")
const body = "@echo off\r\necho started> \"%~dp0marker.txt\"\r\n"
if err := os.WriteFile(script, []byte(body), 0o600); err != nil {
t.Fatalf("write script: %v", err)
}
if err := LaunchScript(context.Background(), script); err != nil {
t.Fatalf("起脚本失败: %v", err)
}
waitForFile(t, filepath.Join(dir, "marker.txt"), 10*time.Second)
}
// cleanupEventually 尽力删掉临时目录:给脚本进程一点退出时间,删不掉只记日志
// (绝不让清理动作把"脚本确实起来了"这个结论打成失败)。
func cleanupEventually(t *testing.T, dir string) {
t.Helper()
for i := 0; i < 60; i++ {
if err := os.RemoveAll(dir); err == nil {
return
}
time.Sleep(100 * time.Millisecond)
}
t.Logf("临时目录还没删掉(脚本进程可能仍在运行): %s", dir)
}
// waitForFile 等一个文件出现(轮询,带 deadline)。
func waitForFile(t *testing.T, path string, timeout time.Duration) {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if _, err := os.Stat(path); err == nil {
return
}
time.Sleep(50 * time.Millisecond)
}
t.Fatalf("等 %v 也没等到 %s(脚本没跑起来)", timeout, path)
}