Files
Cyrene_v2/internal/runtime/task/task_test.go
T
AskaEth 29aa60d0eb test(platform): 修 Windows 上平台脆弱的断言(不削弱测试意图)
- trace/task: 亚 tick 耗时合法读到 0(本机时钟步长约 0.44ms),改为断言非负且 Duration == EndedAt-StartedAt
- user/runtimeconfig: Windows 无法表示普通文件的 POSIX 权限位(Stat 恒 0666),0600 断言仅在非 Windows 生效,注释写明实现确实请求 0600
- exec: MSYS pwd 把同一目录渲染成 /tmp/...;改用「标记文件相对名解析」+「与显式 root 的 pwd 输出逐字相等」两条跨平台断言 + 变异验证
- 只读排查全仓同类脆弱断言,结果记入 DevDocs/工作小结
2026-10-03 16:08:52 +08:00

289 lines
8.7 KiB
Go

package task
import (
"context"
"errors"
"io"
"log/slog"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/exec"
)
// mockProvider is a configurable exec.Provider. It mocks the interface, not a
// concrete type, per the test conventions.
type mockProvider struct {
execute func(ctx context.Context, req exec.ExecRequest) (*exec.ExecResult, error)
}
func (m mockProvider) Execute(ctx context.Context, req exec.ExecRequest) (*exec.ExecResult, error) {
if m.execute == nil {
return &exec.ExecResult{}, nil
}
return m.execute(ctx, req)
}
// mockRun returns a provider that sleeps (unless sleep==0) and then returns the
// given output or error. It honors ctx cancellation so Abort releases it.
func mockRun(out string, err error, sleep time.Duration) mockProvider {
return mockProvider{execute: func(ctx context.Context, req exec.ExecRequest) (*exec.ExecResult, error) {
if sleep > 0 {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(sleep):
}
}
if err != nil {
return nil, err
}
return &exec.ExecResult{Stdout: out, ExitCode: 0}, nil
}}
}
func discardLogger() *slog.Logger {
return logger.New(logger.Options{Level: "error", Out: io.Discard})
}
func newTestManager(t *testing.T, p exec.Provider) *Manager {
t.Helper()
m, err := New(&config.Config{}, p, discardLogger())
if err != nil {
t.Fatalf("New: %v", err)
}
t.Cleanup(func() { _ = m.Close() })
return m
}
func subscribeDone(t *testing.T, m *Manager) (<-chan DoneEvent, func()) {
t.Helper()
ch := make(chan DoneEvent, 16)
unsub, err := m.Bus().Subscribe(context.Background(), "task.done", func(_ context.Context, e bus.Event) error {
if de, ok := e.Payload.(DoneEvent); ok {
ch <- de
}
return nil
})
if err != nil {
t.Fatalf("subscribe task.done: %v", err)
}
return ch, unsub
}
// waitTerminal polls a task until it reaches a terminal status or the timeout.
func waitTerminal(tk *Task, timeout time.Duration) (Snapshot, bool) {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if s := tk.Snapshot(); s.Status.isTerminal() {
return s, true
}
time.Sleep(5 * time.Millisecond)
}
return tk.Snapshot(), false
}
func waitActive(t *testing.T, m *Manager, want int, timeout time.Duration) {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if m.Active() == want {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("Active()=%d, want %d", m.Active(), want)
}
// TestManager_New_Validates checks the constructor's nil guards.
func TestManager_New_Validates(t *testing.T) {
t.Parallel()
if _, err := New(&config.Config{}, nil, discardLogger()); !errors.Is(err, ErrNilProvider) {
t.Errorf("nil provider: err=%v, want ErrNilProvider", err)
}
if _, err := New(&config.Config{}, mockProvider{}, nil); !errors.Is(err, ErrNilLogger) {
t.Errorf("nil logger: err=%v, want ErrNilLogger", err)
}
}
// TestManager_BackgroundTask_RealTimeOutput verifies a background task returns
// immediately, that Output() is readable (empty while running) during the run
// and populated after, and that ReadOutput returns the final output.
func TestManager_BackgroundTask_RealTimeOutput(t *testing.T) {
t.Parallel()
m := newTestManager(t, mockRun("hello world", nil, 400*time.Millisecond))
tk, err := m.Start(context.Background(), TaskSpec{Command: []string{"echo", "hi"}, Background: true})
if err != nil {
t.Fatalf("Start: %v", err)
}
if tk.Kind != KindBackground {
t.Errorf("Kind=%s, want background", tk.Kind)
}
if got := tk.Output().Stdout; got != "" {
t.Errorf("output during run=%q, want empty (still running)", got)
}
out, err := tk.ReadOutput(context.Background())
if err != nil {
t.Fatalf("ReadOutput: %v", err)
}
if out.Stdout != "hello world" {
t.Errorf("Stdout=%q, want hello world", out.Stdout)
}
if snap := tk.Snapshot(); snap.Status != StatusDone {
t.Errorf("Status=%s, want done", snap.Status)
}
}
// TestManager_TaskOutputAndDoneEvents verifies both bus subjects fire, in order,
// with the expected payloads.
func TestManager_TaskOutputAndDoneEvents(t *testing.T) {
t.Parallel()
m := newTestManager(t, mockRun("hi out", nil, 0))
ch := make(chan bus.Event, 16)
subCtx, cancel := context.WithCancel(context.Background())
defer cancel()
unsub, err := m.Bus().Subscribe(subCtx, "task.>", func(_ context.Context, e bus.Event) error {
ch <- e
return nil
})
if err != nil {
t.Fatalf("subscribe task.>: %v", err)
}
defer unsub()
tk, err := m.Start(context.Background(), TaskSpec{Command: []string{"echo"}, Background: true})
if err != nil {
t.Fatalf("Start: %v", err)
}
var gotOutput, gotDone bool
timeout := time.After(2 * time.Second)
for !gotDone {
select {
case e := <-ch:
switch e.Subject {
case "task.output":
gotOutput = true
if oe := e.Payload.(OutputEvent); oe.Output.Stdout != "hi out" {
t.Errorf("output event Stdout=%q", oe.Output.Stdout)
}
case "task.done":
gotDone = true
de := e.Payload.(DoneEvent)
if de.ID != tk.ID {
t.Errorf("done ID=%q, want %q", de.ID, tk.ID)
}
if de.Status != StatusDone {
t.Errorf("done Status=%s, want done", de.Status)
}
if de.Output.Stdout != "hi out" {
t.Errorf("done Stdout=%q, want hi out", de.Output.Stdout)
}
}
case <-timeout:
t.Fatal("timeout waiting for bus events")
}
}
if !gotOutput {
t.Errorf("task.output event not received")
}
}
// TestManager_ForegroundTask_FastCompletes verifies a foreground task that
// finishes inside the threshold is returned already done.
func TestManager_ForegroundTask_FastCompletes(t *testing.T) {
t.Parallel()
m := newTestManager(t, mockRun("fast out", nil, 0))
start := time.Now()
tk, err := m.Start(context.Background(), TaskSpec{Command: []string{"echo", "fast"}, Background: false})
if err != nil {
t.Fatalf("Start: %v", err)
}
if elapsed := time.Since(start); elapsed > 1500*time.Millisecond {
t.Errorf("Start took %v, want < 1.5s", elapsed)
}
if tk.Kind != KindForeground {
t.Errorf("Kind=%s, want foreground", tk.Kind)
}
snap := tk.Snapshot()
if snap.Status != StatusDone {
t.Errorf("Status=%s, want done", snap.Status)
}
if snap.Output.Stdout != "fast out" {
t.Errorf("Stdout=%q, want fast out", snap.Output.Stdout)
}
// The monotonic clock may be too coarse to tick between StartedAt and
// FinishedAt, so a task that finishes in less than one tick legitimately
// reads the same stamp for both. Assert the platform-independent invariant
// instead: the finish time is never before the start time.
if snap.FinishedAt.Before(snap.StartedAt) {
t.Errorf("FinishedAt=%v should not be before StartedAt=%v", snap.FinishedAt, snap.StartedAt)
}
}
// TestManager_ForegroundTask_PromotedAfterThreshold verifies a foreground task
// that outlives the threshold is promoted: Start returns early with Kind=background
// and Status=running, and the task completes in the background.
func TestManager_ForegroundTask_PromotedAfterThreshold(t *testing.T) {
t.Parallel()
m := newTestManager(t, mockRun("slow out", nil, 3*time.Second))
start := time.Now()
tk, err := m.Start(context.Background(), TaskSpec{Command: []string{"echo", "slow"}, Background: false})
if err != nil {
t.Fatalf("Start: %v", err)
}
if elapsed := time.Since(start); elapsed >= 3*time.Second {
t.Errorf("Start waited %v; should promote before completion", elapsed)
}
if tk.Kind != KindBackground {
t.Errorf("Kind=%s, want background after promotion", tk.Kind)
}
if tk.Status() != StatusRunning {
t.Errorf("Status=%s, want running", tk.Status())
}
snap, ok := waitTerminal(tk, 4*time.Second)
if !ok {
t.Fatalf("task did not finish after promotion, status=%s", tk.Status())
}
if snap.Status != StatusDone {
t.Errorf("Status=%s, want done", snap.Status)
}
if snap.Output.Stdout != "slow out" {
t.Errorf("Stdout=%q, want slow out", snap.Output.Stdout)
}
}
// TestManager_Foreground_ParentContextCancelAborts verifies a foreground Start is
// aborted with the parent context's error when that context is canceled.
func TestManager_Foreground_ParentContextCancelAborts(t *testing.T) {
t.Parallel()
m := newTestManager(t, mockRun("hang", nil, 5*time.Second))
ctx, cancel := context.WithCancel(context.Background())
go func() {
time.Sleep(100 * time.Millisecond)
cancel()
}()
start := time.Now()
_, err := m.Start(ctx, TaskSpec{Command: []string{"hang"}, Background: false})
if err == nil {
t.Fatal("Start: want error on parent context cancel")
}
if !errors.Is(err, context.Canceled) {
t.Errorf("err=%v, want context.Canceled", err)
}
if elapsed := time.Since(start); elapsed > 2*time.Second {
t.Errorf("Start took %v, want < 2s", elapsed)
}
}