package proc import ( "context" "fmt" "runtime" "strings" "testing" "time" ) // helperShell 返回执行一段 shell 脚本的跨平台命令前缀(command, args...)。 func helperShell(script string) (string, []string) { if runtime.GOOS == "windows" { return "cmd", []string{"/c", script} } return "sh", []string{"-c", script} } // TestRunOnce_ExitCodeAndCapture 验证捕获 stdout / stderr 与非零退出码。 func TestRunOnce_ExitCodeAndCapture(t *testing.T) { var script string if runtime.GOOS == "windows" { script = "echo out& echo err 1>&2& exit 3" } else { script = "echo out; echo err 1>&2; exit 3" } cmd, args := helperShell(script) res, err := RunOnce(context.Background(), ExecSpec{Command: cmd, Args: args, Timeout: 10 * time.Second}) if err != nil { t.Fatalf("RunOnce: %v", err) } if res.ExitCode != 3 { t.Fatalf("exit code = %d, want 3", res.ExitCode) } if !strings.Contains(res.Stdout, "out") { t.Fatalf("stdout = %q, want contains 'out'", res.Stdout) } if !strings.Contains(res.Stderr, "err") { t.Fatalf("stderr = %q, want contains 'err'", res.Stderr) } if res.TimedOut { t.Fatal("TimedOut should be false") } } // TestRunOnce_Success 验证零退出码路径。 func TestRunOnce_Success(t *testing.T) { cmd, args := helperShell("echo hello") res, err := RunOnce(context.Background(), ExecSpec{Command: cmd, Args: args, Timeout: 10 * time.Second}) if err != nil { t.Fatalf("RunOnce: %v", err) } if res.ExitCode != 0 { t.Fatalf("exit code = %d, want 0", res.ExitCode) } if !strings.Contains(res.Stdout, "hello") { t.Fatalf("stdout = %q", res.Stdout) } } // TestRunOnce_Truncation 验证 stdout 超过 MaxOutputBytes 时截断并置标志。 func TestRunOnce_Truncation(t *testing.T) { // 打印约 1000 字节,但上限设为 100。 var cmd string var args []string if runtime.GOOS == "windows" { // PowerShell 直接调用避免 cmd 转义;-NoProfile 加快启动。 cmd, args = "powershell", []string{"-NoProfile", "-Command", "Write-Output ('a' * 1000)"} } else { cmd, args = "sh", []string{"-c", "for i in $(seq 1 1000); do printf a; done"} } res, err := RunOnce(context.Background(), ExecSpec{Command: cmd, Args: args, MaxOutputBytes: 100, Timeout: 15 * time.Second}) if err != nil { t.Fatalf("RunOnce: %v", err) } if !res.Truncated { t.Fatalf("Truncated should be true; stdout len=%d size=%d", len(res.Stdout), res.StdoutSize) } if len(res.Stdout) > 100 { t.Fatalf("captured stdout len=%d exceeds limit 100", len(res.Stdout)) } } // TestRunOnce_Timeout 验证超时触发整树终止并置 TimedOut。 // // Unix:父 shell trap 掉 TERM 并 sleep,再后台派生一个 grandchild sleep;超时后 // TerminateGroup 必须 SIGKILL 整组,确保父退出(done 被关闭)。 // Windows:直接 timeout 长睡,Job-close 终止。 func TestRunOnce_Timeout(t *testing.T) { var cmd string var args []string if runtime.GOOS == "windows" { // ping -n N localhost 是无需控制台的可靠延时(timeout /t 在无 stdin 下立即返回)。 cmd, args = "cmd", []string{"/c", "ping -n 30 127.0.0.1 >nul"} } else { // trap '' TERM 忽略 SIGTERM;后台派生 grandchild;wait 阻塞存活。 cmd, args = "sh", []string{"-c", "trap '' TERM; sleep 30 & sleep 30; wait"} } start := time.Now() res, err := RunOnce(context.Background(), ExecSpec{ Command: cmd, Args: args, Timeout: 1 * time.Second, KillGrace: 500 * time.Millisecond, }) if err != nil { t.Fatalf("RunOnce: %v", err) } if !res.TimedOut { t.Fatal("TimedOut should be true") } // 必须在远小于 30s 内返回(超时 + grace + kill),证明整树被回收而非等自然退出。 if elapsed := time.Since(start); elapsed > 10*time.Second { t.Fatalf("RunOnce blocked %v, expected prompt tree-kill", elapsed) } } // TestRunOnce_CtxCancel 验证 ctx 取消亦触发树终止并尽快返回。 func TestRunOnce_CtxCancel(t *testing.T) { cmd, args := helperShell(fmt.Sprintf("%s", sleepScript(30))) ctx, cancel := context.WithCancel(context.Background()) go func() { time.Sleep(500 * time.Millisecond) cancel() }() start := time.Now() res, err := RunOnce(ctx, ExecSpec{Command: cmd, Args: args, KillGrace: 500 * time.Millisecond}) if err != nil { t.Fatalf("RunOnce: %v", err) } if elapsed := time.Since(start); elapsed > 10*time.Second { t.Fatalf("RunOnce blocked %v after ctx cancel", elapsed) } _ = res } func sleepScript(sec int) string { if runtime.GOOS == "windows" { return fmt.Sprintf("ping -n %d 127.0.0.1 >nul", sec) } return fmt.Sprintf("sleep %d", sec) }