package opencode import ( "context" "os" "path/filepath" "testing" "time" ) // fakeOpenCode создаёт shell-скрипт, имитирующий opencode run: // $FAKE_MODE=ok -> мгновенный успех, печатает NDJSON c session_id // $FAKE_MODE=slow-> спит долго (для idle/hard timeout) // $FAKE_MODE=fail-> exit 7 (resume-fallback) func fakeOpenCode(t *testing.T, workdir string) string { t.Helper() bin := filepath.Join(workdir, "opencode") script := `#!/bin/sh mode="${FAKE_MODE:-ok}" case "$mode" in ok) echo '{"type":"text","part":{"text":"done"}}' echo '{"session_id":"sess-123"}' exit 0 ;; slow) sleep 30 ;; fail) echo '{"type":"text","part":{"text":"boom"}}' exit 7 ;; esac ` if err := os.WriteFile(bin, []byte(script), 0o755); err != nil { t.Fatalf("write fake opencode: %v", err) } return bin } func TestRun_Success(t *testing.T) { dir := t.TempDir() bin := fakeOpenCode(t, dir) t.Setenv("FAKE_MODE", "ok") r := &Runner{Bin: bin, PollInterval: 20 * time.Millisecond} res, err := r.Run(context.Background(), "task", dir, "dev", "") if err != nil { t.Fatalf("Run err: %v", err) } if res.RC != 0 { t.Errorf("RC = %d, want 0", res.RC) } if res.SessionID != "sess-123" { t.Errorf("SessionID = %q, want sess-123", res.SessionID) } if !contains(res.Stdout, "done") { t.Errorf("Stdout = %q, want to contain done", res.Stdout) } } func TestRun_IdleTimeout(t *testing.T) { dir := t.TempDir() bin := fakeOpenCode(t, dir) t.Setenv("FAKE_MODE", "slow") r := &Runner{Bin: bin, IdleTimeout: 50 * time.Millisecond, PollInterval: 10 * time.Millisecond} res, err := r.Run(context.Background(), "task", dir, "dev", "") if err != nil { t.Fatalf("Run err: %v", err) } if res.RC != -1 { t.Errorf("RC = %d, want -1 (timeout kill)", res.RC) } } func TestRun_ContextCancel(t *testing.T) { dir := t.TempDir() bin := fakeOpenCode(t, dir) t.Setenv("FAKE_MODE", "slow") ctx, cancel := context.WithCancel(context.Background()) r := &Runner{Bin: bin, HardTimeout: time.Minute, PollInterval: 10 * time.Millisecond} done := make(chan *Result, 1) errCh := make(chan error, 1) go func() { res, err := r.Run(ctx, "task", dir, "dev", "") done <- res errCh <- err }() time.Sleep(30 * time.Millisecond) cancel() res := <-done if err := <-errCh; err != nil { t.Fatalf("Run err: %v", err) } if res.RC != -1 { t.Errorf("RC = %d, want -1", res.RC) } } func TestResumeDev_Fallback(t *testing.T) { dir := t.TempDir() bin := fakeOpenCode(t, dir) t.Setenv("FAKE_MODE", "fail") r := &Runner{Bin: bin, PollInterval: 20 * time.Millisecond} res, timedOut := r.ResumeDev(context.Background(), "task", dir, "lost-session") if timedOut { t.Error("timedOut = true, want false") } // fake fail всегда exit 7, fallback тоже 7 — проверяем что RC от fallback-вызова if res.RC != 7 { t.Errorf("RC = %d, want 7 (fallback повтор с тем же кодом)", res.RC) } } func contains(s, sub string) bool { return len(s) >= len(sub) && (s == sub || len(s) > 0 && indexOf(s, sub) >= 0) } func indexOf(s, sub string) int { for i := 0; i+len(sub) <= len(s); i++ { if s[i:i+len(sub)] == sub { return i } } return -1 }