// bash_background_test.go -- 后台 Bash 执行的单元测试. // // 覆盖场景: // - 后台执行基础测试 // - 后台任务状态查询 // - 后台任务超时终止 // - 后台任务手动取消 // - BashOutput 线程安全 package builtin import ( "context" "encoding/json" "strings" "testing" "time" "git.flytoex.net/yuanwei/flyto-agent/pkg/execenv" ) // TestBackgroundBash_BasicExecution 测试后台执行基础功能 func TestBackgroundBash_BasicExecution(t *testing.T) { bgStore := NewBackgroundTaskStore() tool := NewBashToolWithStores(t.TempDir(), bgStore, nil, execenv.DefaultExecutor{}) input, _ := json.Marshal(bashInput{ Command: "echo hello_bg", RunInBackground: true, }) result, err := tool.Execute(context.Background(), input, nil) if err != nil { t.Fatalf("后台执行失败: %v", err) } if result.IsError { t.Fatalf("后台执行不应标记为错误: %s", result.Output) } if !strings.Contains(result.Output, "bg_task_") { t.Errorf("应返回 task_id,实际: %q", result.Output) } if !strings.Contains(result.Output, "Background task started") { t.Errorf("应包含启动提示,实际: %q", result.Output) } } // TestBackgroundBash_StatusQuery 测试后台任务状态查询 func TestBackgroundBash_StatusQuery(t *testing.T) { bgStore := NewBackgroundTaskStore() tool := NewBashToolWithStores(t.TempDir(), bgStore, nil, execenv.DefaultExecutor{}) input, _ := json.Marshal(bashInput{ Command: "echo done_status", RunInBackground: true, }) result, err := tool.Execute(context.Background(), input, nil) if err != nil { t.Fatalf("后台执行失败: %v", err) } // 提取 task_id taskID := "" for _, line := range strings.Split(result.Output, "\n") { if strings.HasPrefix(line, "task_id: ") { taskID = strings.TrimPrefix(line, "task_id: ") break } } if taskID == "" { t.Fatalf("无法提取 task_id,输出: %q", result.Output) } // 等待任务完成(最多 5 秒) deadline := time.Now().Add(5 * time.Second) for time.Now().Before(deadline) { task, ok := tool.GetBackgroundTask(taskID) if !ok { t.Fatalf("任务 %s 未找到", taskID) } if task.GetStatus() != "running" { // 任务已结束 if task.GetStatus() != "completed" { t.Errorf("任务状态期望 completed,实际 %q", task.GetStatus()) } task.mu.RLock() exitCode := task.ExitCode task.mu.RUnlock() if exitCode != 0 { t.Errorf("退出码期望 0,实际 %d", exitCode) } return } time.Sleep(100 * time.Millisecond) } t.Error("任务在 5 秒内未完成") } // TestBackgroundBash_Timeout 测试后台任务超时终止 func TestBackgroundBash_Timeout(t *testing.T) { bgStore := NewBackgroundTaskStore() tool := NewBashToolWithStores(t.TempDir(), bgStore, nil, execenv.DefaultExecutor{}) input, _ := json.Marshal(bashInput{ Command: "sleep 30", RunInBackground: true, Timeout: 500, // 500ms 超时 }) result, err := tool.Execute(context.Background(), input, nil) if err != nil { t.Fatalf("后台执行失败: %v", err) } // 提取 task_id taskID := "" for _, line := range strings.Split(result.Output, "\n") { if strings.HasPrefix(line, "task_id: ") { taskID = strings.TrimPrefix(line, "task_id: ") break } } if taskID == "" { t.Fatalf("无法提取 task_id") } // 等待任务被终止(最多 10 秒) deadline := time.Now().Add(10 * time.Second) for time.Now().Before(deadline) { task, ok := tool.GetBackgroundTask(taskID) if !ok { t.Fatalf("任务 %s 未找到", taskID) } if task.GetStatus() == "killed" { // 超时后应被标记为 killed return } if task.GetStatus() != "running" { // 任务已结束但不是 killed task.mu.RLock() t.Logf("任务状态: %s (退出码: %d)", task.Status, task.ExitCode) task.mu.RUnlock() return } time.Sleep(100 * time.Millisecond) } t.Error("任务在 10 秒内未被超时终止") } // TestBackgroundBash_Cancel 测试后台任务手动取消 func TestBackgroundBash_Cancel(t *testing.T) { bgStore := NewBackgroundTaskStore() tool := NewBashToolWithStores(t.TempDir(), bgStore, nil, execenv.DefaultExecutor{}) input, _ := json.Marshal(bashInput{ Command: "sleep 30", RunInBackground: true, }) result, err := tool.Execute(context.Background(), input, nil) if err != nil { t.Fatalf("后台执行失败: %v", err) } // 提取 task_id taskID := "" for _, line := range strings.Split(result.Output, "\n") { if strings.HasPrefix(line, "task_id: ") { taskID = strings.TrimPrefix(line, "task_id: ") break } } if taskID == "" { t.Fatalf("无法提取 task_id") } // 等一下让命令启动 time.Sleep(200 * time.Millisecond) // 手动取消 ok, err := tool.CancelBackgroundTask(taskID) if err != nil { t.Fatalf("取消失败: %v", err) } if !ok { t.Error("取消应返回 true") } // 等待任务被终止(最多 10 秒) deadline := time.Now().Add(10 * time.Second) for time.Now().Before(deadline) { task, _ := tool.GetBackgroundTask(taskID) if task.GetStatus() == "killed" { return } if task.GetStatus() != "running" { return // 已结束 } time.Sleep(100 * time.Millisecond) } t.Error("取消后任务未在 10 秒内终止") } // TestBackgroundBash_NoStore 测试未初始化 bgStore 时的错误处理 func TestBackgroundBash_NoStore(t *testing.T) { tool := NewBashTool(t.TempDir(), execenv.DefaultExecutor{}) // 不传 bgStore input, _ := json.Marshal(bashInput{ Command: "echo test", RunInBackground: true, }) result, err := tool.Execute(context.Background(), input, nil) if err != nil { t.Fatalf("不应返回 Go error: %v", err) } if !result.IsError { t.Error("未初始化 bgStore 时应标记为错误") } if !strings.Contains(result.Output, "not initialized") { t.Errorf("应包含初始化错误提示,实际: %q", result.Output) } } // TestBashOutput_ThreadSafety 测试 BashOutput 的线程安全性 func TestBashOutput_ThreadSafety(t *testing.T) { output := &BashOutput{} // 并发写入 done := make(chan struct{}) for i := 0; i < 100; i++ { go func(n int) { output.WriteStdout("stdout line") output.WriteStderr("stderr line") if n == 99 { close(done) } }(i) } // 等待完成 <-done time.Sleep(100 * time.Millisecond) // 让所有 goroutine 完成 // 验证可以安全读取 stdout := output.Stdout() stderr := output.Stderr() combined := output.CombinedOutput() if stdout == "" { t.Error("stdout 不应为空") } if stderr == "" { t.Error("stderr 不应为空") } if combined == "" { t.Error("combined 不应为空") } } // TestBackgroundTaskStore_NextID 测试 ID 递增 func TestBackgroundTaskStore_NextID(t *testing.T) { store := NewBackgroundTaskStore() id1 := store.NextID() id2 := store.NextID() id3 := store.NextID() if id1 != "bg_task_1" { t.Errorf("第一个 ID 期望 bg_task_1,实际 %q", id1) } if id2 != "bg_task_2" { t.Errorf("第二个 ID 期望 bg_task_2,实际 %q", id2) } if id3 != "bg_task_3" { t.Errorf("第三个 ID 期望 bg_task_3,实际 %q", id3) } } // TestBackgroundTaskStore_List 测试列出所有后台任务 func TestBackgroundTaskStore_List(t *testing.T) { store := NewBackgroundTaskStore() // 添加几个任务 store.Add(&BackgroundBashTask{ID: "bg_task_1", Command: "echo 1", Status: "completed"}) store.Add(&BackgroundBashTask{ID: "bg_task_2", Command: "echo 2", Status: "running"}) tasks := store.List() if len(tasks) != 2 { t.Errorf("期望 2 个任务,实际 %d", len(tasks)) } } // TestBashOutput_CombinedOutput 测试合并输出格式 func TestBashOutput_CombinedOutput(t *testing.T) { output := &BashOutput{} output.WriteStdout("line1") output.WriteStdout("line2") output.WriteStderr("err1") combined := output.CombinedOutput() if !strings.Contains(combined, "line1") { t.Error("合并输出应包含 stdout 内容") } if !strings.Contains(combined, "[stderr] err1") { t.Error("合并输出应包含带前缀的 stderr 内容") } } // TestCancelBackgroundTask_NotRunning 测试取消非运行状态的任务 func TestCancelBackgroundTask_NotRunning(t *testing.T) { bgStore := NewBackgroundTaskStore() tool := NewBashToolWithStores(t.TempDir(), bgStore, nil, execenv.DefaultExecutor{}) bgStore.Add(&BackgroundBashTask{ ID: "bg_task_1", Status: "completed", }) _, err := tool.CancelBackgroundTask("bg_task_1") if err == nil { t.Error("取消已完成任务应返回错误") } } // TestCancelBackgroundTask_NotFound 测试取消不存在的任务 func TestCancelBackgroundTask_NotFound(t *testing.T) { bgStore := NewBackgroundTaskStore() tool := NewBashToolWithStores(t.TempDir(), bgStore, nil, execenv.DefaultExecutor{}) _, err := tool.CancelBackgroundTask("nonexistent") if err == nil { t.Error("取消不存在的任务应返回错误") } } // TestBackgroundTaskStore_SweepOnAdd_RemovesStale 验证 Add 时清理过期已完成任务. // // 升华改进(ELEVATED): 用 50ms 极短保留期 + 60ms 等待,让 GC 行为在测试里 // 可观察.生产默认 10 分钟,测试用 WithRetention 注入. func TestBackgroundTaskStore_SweepOnAdd_RemovesStale(t *testing.T) { bgStore := NewBackgroundTaskStore(WithRetention(50 * time.Millisecond)) // 添加并标记为已完成的任务 old := &BackgroundBashTask{ID: "bg_task_old", Status: "completed", EndTime: time.Now()} if err := bgStore.Add(old); err != nil { t.Fatalf("Add 失败: %v", err) } // 等待超过 retention time.Sleep(60 * time.Millisecond) // 加新任务触发 sweep fresh := &BackgroundBashTask{ID: "bg_task_fresh", Status: "running", StartTime: time.Now()} if err := bgStore.Add(fresh); err != nil { t.Fatalf("Add fresh 失败: %v", err) } // 老任务应该被清理 if _, ok := bgStore.Get("bg_task_old"); ok { t.Error("过期已完成任务应被 sweep-on-Add 清理") } // 新任务应该存在 if _, ok := bgStore.Get("bg_task_fresh"); !ok { t.Error("新加入的任务应存在") } } // TestBackgroundTaskStore_SweepOnAdd_KeepsRunning 验证 sweep 不会清理 running 任务. // // 即使 EndTime 已过期(边界场景:状态被错误标为 running 但 EndTime 已设), // running 状态优先 - 永不回收.这是双层防御. func TestBackgroundTaskStore_SweepOnAdd_KeepsRunning(t *testing.T) { bgStore := NewBackgroundTaskStore(WithRetention(50 * time.Millisecond)) running := &BackgroundBashTask{ ID: "bg_task_running", Status: "running", StartTime: time.Now(), EndTime: time.Now().Add(-1 * time.Hour), // 故意过期,但状态是 running } if err := bgStore.Add(running); err != nil { t.Fatalf("Add running 失败: %v", err) } time.Sleep(60 * time.Millisecond) // 触发 sweep trigger := &BackgroundBashTask{ID: "bg_task_trigger", Status: "running", StartTime: time.Now()} if err := bgStore.Add(trigger); err != nil { t.Fatalf("Add trigger 失败: %v", err) } if _, ok := bgStore.Get("bg_task_running"); !ok { t.Error("running 状态的任务永远不应被回收") } } // TestBackgroundTaskStore_SweepOnAdd_RetentionRespected 验证未到 retention 不被清理. func TestBackgroundTaskStore_SweepOnAdd_RetentionRespected(t *testing.T) { bgStore := NewBackgroundTaskStore(WithRetention(500 * time.Millisecond)) old := &BackgroundBashTask{ID: "bg_task_recent", Status: "completed", EndTime: time.Now()} if err := bgStore.Add(old); err != nil { t.Fatalf("Add 失败: %v", err) } // 只等 50ms(远小于 500ms retention) time.Sleep(50 * time.Millisecond) trigger := &BackgroundBashTask{ID: "bg_task_t2", Status: "running", StartTime: time.Now()} if err := bgStore.Add(trigger); err != nil { t.Fatalf("Add trigger 失败: %v", err) } if _, ok := bgStore.Get("bg_task_recent"); !ok { t.Error("未到 retention 的任务不应被清理") } } // TestBackgroundTaskStore_SweepDisabled 验证 retention=0 时禁用 GC. // // 反向场景:测试或诊断模式可能希望保留所有任务(用于调试), // retention=0 是显式 opt-out 的语义. func TestBackgroundTaskStore_SweepDisabled(t *testing.T) { bgStore := NewBackgroundTaskStore(WithRetention(0)) old := &BackgroundBashTask{ ID: "bg_task_old", Status: "completed", EndTime: time.Now().Add(-24 * time.Hour), // 一天前 } if err := bgStore.Add(old); err != nil { t.Fatalf("Add 失败: %v", err) } trigger := &BackgroundBashTask{ID: "bg_task_t", Status: "running", StartTime: time.Now()} if err := bgStore.Add(trigger); err != nil { t.Fatalf("Add trigger 失败: %v", err) } if _, ok := bgStore.Get("bg_task_old"); !ok { t.Error("retention=0 时不应清理任何任务") } } // TestBackgroundTaskStore_SweepBatch 验证一次 sweep 能清理多个过期任务. func TestBackgroundTaskStore_SweepBatch(t *testing.T) { bgStore := NewBackgroundTaskStore(WithRetention(50 * time.Millisecond)) // 加 5 个已完成任务 for i := 0; i < 5; i++ { task := &BackgroundBashTask{ ID: "bg_task_old_" + string(rune('a'+i)), Status: "completed", EndTime: time.Now(), } if err := bgStore.Add(task); err != nil { t.Fatalf("Add 失败: %v", err) } } time.Sleep(60 * time.Millisecond) // sweep 触发 trigger := &BackgroundBashTask{ID: "bg_trigger", Status: "running", StartTime: time.Now()} if err := bgStore.Add(trigger); err != nil { t.Fatalf("Add trigger 失败: %v", err) } // 5 个老任务应被清理,只剩 trigger tasks := bgStore.List() if len(tasks) != 1 { t.Errorf("应只剩 1 个任务(trigger),实际 %d 个", len(tasks)) } if _, ok := bgStore.Get("bg_trigger"); !ok { t.Error("trigger 任务应存在") } }