Files
multica/server/pkg/agent/agent_test.go
2026-07-21 14:55:08 +08:00

234 lines
7.0 KiB
Go

package agent
import (
"context"
"fmt"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"testing"
"time"
)
func TestNewReturnsClaudeBackend(t *testing.T) {
t.Parallel()
b, err := New("claude", Config{ExecutablePath: "/nonexistent/claude"})
if err != nil {
t.Fatalf("New(claude) error: %v", err)
}
if _, ok := b.(*claudeBackend); !ok {
t.Fatalf("expected *claudeBackend, got %T", b)
}
}
func TestNewReturnsCodexBackend(t *testing.T) {
t.Parallel()
b, err := New("codex", Config{ExecutablePath: "/nonexistent/codex"})
if err != nil {
t.Fatalf("New(codex) error: %v", err)
}
if _, ok := b.(*codexBackend); !ok {
t.Fatalf("expected *codexBackend, got %T", b)
}
}
func TestNewReturnsCodebuddyBackend(t *testing.T) {
t.Parallel()
b, err := New("codebuddy", Config{ExecutablePath: "/nonexistent/codebuddy"})
if err != nil {
t.Fatalf("New(codebuddy) error: %v", err)
}
if _, ok := b.(*codebuddyBackend); !ok {
t.Fatalf("expected *codebuddyBackend, got %T", b)
}
}
func TestNewReturnsCopilotBackend(t *testing.T) {
t.Parallel()
b, err := New("copilot", Config{ExecutablePath: "/nonexistent/copilot"})
if err != nil {
t.Fatalf("New(copilot) error: %v", err)
}
if _, ok := b.(*copilotBackend); !ok {
t.Fatalf("expected *copilotBackend, got %T", b)
}
}
func TestNewReturnsQoderBackend(t *testing.T) {
t.Parallel()
b, err := New("qoder", Config{ExecutablePath: "/nonexistent/qodercli"})
if err != nil {
t.Fatalf("New(qoder) error: %v", err)
}
if _, ok := b.(*qoderBackend); !ok {
t.Fatalf("expected *qoderBackend, got %T", b)
}
}
func TestNewReturnsAntigravityBackend(t *testing.T) {
t.Parallel()
b, err := New("antigravity", Config{ExecutablePath: "/nonexistent/agy"})
if err != nil {
t.Fatalf("New(antigravity) error: %v", err)
}
if _, ok := b.(*antigravityBackend); !ok {
t.Fatalf("expected *antigravityBackend, got %T", b)
}
}
func TestNewRejectsUnknownType(t *testing.T) {
t.Parallel()
_, err := New("gpt", Config{})
if err == nil {
t.Fatal("expected error for unknown agent type")
}
}
func TestNewDefaultsLogger(t *testing.T) {
t.Parallel()
b, _ := New("claude", Config{})
cb := b.(*claudeBackend)
if cb.cfg.Logger == nil {
t.Fatal("expected non-nil logger")
}
}
func TestDetectVersionFailsForMissingBinary(t *testing.T) {
t.Parallel()
_, err := DetectVersion(context.Background(), "/nonexistent/binary")
if err == nil {
t.Fatal("expected error for missing binary")
}
}
// TestDetectVersionTimesOutOnHang guards MUL-3812: a CLI whose `--version`
// never returns (e.g. a brew-installed claude wedged by a bun regression) must
// not stall version detection forever. The daemon detects every runtime's
// version sequentially inside its blocking preflight, so an unbounded probe
// would leave the daemon stuck "starting" and *every* runtime on the host
// disconnected. detectCLIVersion must bound the probe and return an error so
// the registration loop isolates the broken runtime and the rest still
// register. The script also leaves an orphaned child holding the stdout pipe
// open after the parent is killed, exercising the cmd.WaitDelay path.
func TestDetectVersionTimesOutOnHang(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("relies on a /bin/sh hang script")
}
dir := t.TempDir()
script := filepath.Join(dir, "hang.sh")
pidFile := filepath.Join(dir, "child.pid")
// The CLI hangs forever (`wait`) and backgrounds a child that inherits and
// holds our stdout pipe open even after the parent is killed on timeout —
// the exact case cmd.WaitDelay must cover. The child records its PID so we
// can reap it in Cleanup instead of leaking a 60s `sleep` into CI.
body := fmt.Sprintf("#!/bin/sh\nsleep 60 &\necho $! > %q\nwait\n", pidFile)
if err := os.WriteFile(script, []byte(body), 0o755); err != nil {
t.Fatalf("write hang script: %v", err)
}
t.Cleanup(func() {
data, err := os.ReadFile(pidFile)
if err != nil {
return // child never recorded its PID; nothing to reap
}
pid, err := strconv.Atoi(strings.TrimSpace(string(data)))
if err != nil {
return
}
if proc, err := os.FindProcess(pid); err == nil {
_ = proc.Kill()
}
})
orig := detectVersionTimeout
detectVersionTimeout = 200 * time.Millisecond
t.Cleanup(func() { detectVersionTimeout = orig })
done := make(chan error, 1)
start := time.Now()
go func() {
_, err := DetectVersion(context.Background(), script)
done <- err
}()
select {
case err := <-done:
if err == nil {
t.Fatal("expected an error from a hanging --version probe, got nil")
}
if elapsed := time.Since(start); elapsed > 5*time.Second {
t.Fatalf("detection took %v; expected it to be bounded by the timeout", elapsed)
}
case <-time.After(10 * time.Second):
t.Fatal("DetectVersion did not return: version probe is unbounded (regression of MUL-3812)")
}
}
func TestLaunchHeaderCoversAllSupportedBackends(t *testing.T) {
t.Parallel()
// SupportedTypes is the canonical factory/profile whitelist. Iterating it
// directly prevents this coverage check from drifting when a backend is
// added, so the UI preview always has a launch skeleton.
for _, t_ := range SupportedTypes {
if header := LaunchHeader(t_); header == "" {
t.Errorf("LaunchHeader(%q) returned empty string — add it to launchHeaders", t_)
}
}
}
func TestLaunchHeaderAntigravityAvoidsTextOnlyPrintModeLabel(t *testing.T) {
t.Parallel()
header := LaunchHeader("antigravity")
if header != "agy -p (non-interactive)" {
t.Fatalf("unexpected Antigravity launch header: %q", header)
}
if strings.Contains(header, "print mode") {
t.Fatalf("Antigravity launch header must not imply a text-only mode: %q", header)
}
}
func TestLaunchHeaderReturnsEmptyForUnknownType(t *testing.T) {
t.Parallel()
if header := LaunchHeader("made-up-agent"); header != "" {
t.Errorf("expected empty header for unknown type, got %q", header)
}
}
func TestRunContextZeroTimeoutHasNoDeadline(t *testing.T) {
t.Parallel()
// A zero (or negative) timeout must NOT impose a wall-clock deadline:
// liveness is delegated to the daemon's inactivity watchdog so an actively
// streaming long-running session is never killed merely for running long
// (MUL-3064).
for _, d := range []time.Duration{0, -time.Second} {
ctx, cancel := runContext(context.Background(), d)
if _, ok := ctx.Deadline(); ok {
cancel()
t.Fatalf("runContext(%s) imposed a deadline; want none", d)
}
cancel()
if ctx.Err() == nil {
t.Fatalf("runContext(%s): context should be cancelled after cancel()", d)
}
}
}
func TestRunContextPositiveTimeoutHasDeadline(t *testing.T) {
t.Parallel()
// A positive timeout keeps the hard wall-clock deadline (the opt-in
// absolute cap operators can still set via MULTICA_AGENT_TIMEOUT).
ctx, cancel := runContext(context.Background(), time.Hour)
defer cancel()
deadline, ok := ctx.Deadline()
if !ok {
t.Fatal("runContext(1h) should impose a deadline")
}
if remaining := time.Until(deadline); remaining <= 0 || remaining > time.Hour+time.Minute {
t.Fatalf("unexpected deadline remaining: %s", remaining)
}
}