Files
2026-06-22 08:55:57 +08:00

144 lines
4.5 KiB
Go

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)
}