iop/apps/agent/internal/command/root_test.go
toki 45d4bd98fd fix(agent): task-loop 검증 경계와 상태 검사를 보강한다
리뷰에서 확인된 Milestone 식별자와 컴파일 바이너리 상태 검증의 빈틈을 보완하고, 활성 agent-task에서는 dispatcher만 실행 경로로 사용하도록 고정한다.
2026-07-31 18:40:40 +09:00

755 lines
31 KiB
Go

package command
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"reflect"
"strings"
"testing"
"github.com/spf13/cobra"
)
// recordingService is a fake CommandService that records every invocation,
// captures exact request DTOs, maintains mutation counters, and returns configurable responses.
type recordingService struct {
calls []string
lastValidateReq ValidateRequest
lastProviderReq ProviderListRequest
lastProjectReq ProjectListRequest
lastMilestoneReq MilestoneListRequest
lastSelectReq MilestoneSelectRequest
lastPreviewReq PreviewRequest
lastServeReq ServeRequest
lastStartReq StartRequest
lastStopReq StopRequest
lastResumeReq ResumeRequest
lastStatusReq StatusRequest
lastTaskLoopReq TaskLoopRequest
lastPlanValidationReq PlanValidationRequest
mutationCount int
selectedMilestones map[string]string
validateResp ValidateResponse
validateErr error
providerResp ProviderListResponse
providerErr error
projectResp ProjectListResponse
projectErr error
milestoneResp MilestoneListResponse
milestoneErr error
selectResp MilestoneSelectResponse
selectErr error
previewResp PreviewResponse
previewErr error
serveErr error
startResp StartResponse
startErr error
stopResp StopResponse
stopErr error
resumeResp ResumeResponse
resumeErr error
statusResp StatusResponse
statusErr error
taskLoopResp TaskLoopResponse
taskLoopErr error
planValidationErr error
}
func (r *recordingService) Validate(_ context.Context, req ValidateRequest) (ValidateResponse, error) {
r.calls = append(r.calls, "Validate")
r.lastValidateReq = req
return r.validateResp, r.validateErr
}
func (r *recordingService) ProviderList(_ context.Context, req ProviderListRequest) (ProviderListResponse, error) {
r.calls = append(r.calls, "ProviderList")
r.lastProviderReq = req
return r.providerResp, r.providerErr
}
func (r *recordingService) ProjectList(_ context.Context, req ProjectListRequest) (ProjectListResponse, error) {
r.calls = append(r.calls, "ProjectList")
r.lastProjectReq = req
return r.projectResp, r.projectErr
}
func (r *recordingService) MilestoneList(_ context.Context, req MilestoneListRequest) (MilestoneListResponse, error) {
r.calls = append(r.calls, "MilestoneList")
r.lastMilestoneReq = req
return r.milestoneResp, r.milestoneErr
}
func (r *recordingService) MilestoneSelect(_ context.Context, req MilestoneSelectRequest) (MilestoneSelectResponse, error) {
r.calls = append(r.calls, "MilestoneSelect")
r.lastSelectReq = req
r.mutationCount++
if r.selectedMilestones == nil {
r.selectedMilestones = make(map[string]string)
}
r.selectedMilestones[req.Project] = req.MilestoneID
return r.selectResp, r.selectErr
}
func (r *recordingService) Preview(_ context.Context, req PreviewRequest) (PreviewResponse, error) {
r.calls = append(r.calls, "Preview")
r.lastPreviewReq = req
return r.previewResp, r.previewErr
}
func (r *recordingService) Serve(_ context.Context, req ServeRequest) error {
r.calls = append(r.calls, "Serve")
r.lastServeReq = req
r.mutationCount++
return r.serveErr
}
func (r *recordingService) Start(_ context.Context, req StartRequest) (StartResponse, error) {
r.calls = append(r.calls, "Start")
r.lastStartReq = req
if r.selectedMilestones == nil || r.selectedMilestones[req.Project] == "" {
return StartResponse{}, fmt.Errorf("start: project %s has no selected milestone", req.Project)
}
r.mutationCount++
return r.startResp, r.startErr
}
func (r *recordingService) Stop(_ context.Context, req StopRequest) (StopResponse, error) {
r.calls = append(r.calls, "Stop")
r.lastStopReq = req
r.mutationCount++
return r.stopResp, r.stopErr
}
func (r *recordingService) Resume(_ context.Context, req ResumeRequest) (ResumeResponse, error) {
r.calls = append(r.calls, "Resume")
r.lastResumeReq = req
r.mutationCount++
return r.resumeResp, r.resumeErr
}
func (r *recordingService) Status(_ context.Context, req StatusRequest) (StatusResponse, error) {
r.calls = append(r.calls, "Status")
r.lastStatusReq = req
return r.statusResp, r.statusErr
}
func (r *recordingService) TaskLoop(_ context.Context, req TaskLoopRequest) (TaskLoopResponse, error) {
r.calls = append(r.calls, "TaskLoop")
r.lastTaskLoopReq = req
return r.taskLoopResp, r.taskLoopErr
}
func (r *recordingService) TaskLoopParity(_ context.Context, output io.Writer) error {
r.calls = append(r.calls, "TaskLoopParity")
_, _ = io.WriteString(output, "parity: ok\n")
return nil
}
func (r *recordingService) ValidatePlan(_ context.Context, req PlanValidationRequest) error {
r.calls = append(r.calls, "ValidatePlan")
r.lastPlanValidationReq = req
return r.planValidationErr
}
type textBuf struct{ bytes.Buffer }
func newTextBuf() *textBuf { return &textBuf{} }
func cmdRunner(t *testing.T, root *cobra.Command, args []string) (string, string, error) {
t.Helper()
stdout := newTextBuf()
stderr := newTextBuf()
root.SetOut(stdout)
root.SetErr(stderr)
root.SetArgs(args)
err := root.Execute()
return stdout.String(), stderr.String(), err
}
func assertExactJSON(t *testing.T, got, want string) {
t.Helper()
if got != want {
t.Fatalf("unexpected JSON output:\ngot: %q\nwant: %q", got, want)
}
var gotValue, wantValue any
if err := json.Unmarshal([]byte(got), &gotValue); err != nil {
t.Fatalf("decode actual JSON: %v", err)
}
if err := json.Unmarshal([]byte(want), &wantValue); err != nil {
t.Fatalf("decode expected JSON: %v", err)
}
if !reflect.DeepEqual(gotValue, wantValue) {
t.Fatalf("decoded JSON mismatch:\ngot: %#v\nwant: %#v", gotValue, wantValue)
}
}
func newTestService() *recordingService {
return &recordingService{
selectedMilestones: map[string]string{"proj1": "m1"},
validateResp: ValidateResponse{Valid: true, Revision: "sha256:abc", Projects: 1, Providers: 2, Profiles: 3},
providerResp: ProviderListResponse{Providers: []ProviderEntry{{ID: "pi", Command: "pi", Capabilities: []string{"chat"}}}},
projectResp: ProjectListResponse{Projects: []ProjectEntry{{ID: "proj1", Workspace: "/ws", Enabled: true, SelectedMilestone: "m1", StartedMilestone: "", AutoResumeInterrupt: true}}},
milestoneResp: MilestoneListResponse{Milestones: []MilestoneEntry{{ID: "m1", Selected: true, WorkUnits: 2, CompletedWorkUnits: 1}}},
selectResp: MilestoneSelectResponse{Project: "proj1", MilestoneID: "m1"},
previewResp: PreviewResponse{Project: "proj1", Selected: true, MilestoneID: "m1", NextWork: "m2"},
startResp: StartResponse{Project: "proj1", Status: "started"},
stopResp: StopResponse{Project: "proj1", Status: "stopped"},
resumeResp: ResumeResponse{Project: "proj1", Status: "resumed"},
statusResp: StatusResponse{
Project: "proj1",
Status: "running",
SelectedMilestone: "m1",
StartedMilestone: "",
Works: []WorkStatusEntry{
{ID: "w1", State: "completed", Overlay: "active", Integration: "integrated", DispatchOrdinal: 1},
{ID: "w2", State: "running", Overlay: "active", Integration: "queued", DispatchOrdinal: 2},
},
},
taskLoopResp: TaskLoopResponse{DryRun: true, Projects: []TaskLoopProject{{Project: "proj1", Group: "m-m1", Status: "ready", NextWork: "w1"}}},
}
}
// TestCommandMatrix verifies that every subcommand (including serve) parses,
// passes exact request parameters, dispatches exactly once to the service,
// and produces exact text command-boundary output.
func TestCommandMatrix(t *testing.T) {
svc := newTestService()
root := NewRoot(svc, newTextBuf(), newTextBuf())
tests := []struct {
name string
args []string
wantCall string
assertReq func(t *testing.T)
wantStdout string
}{
{
name: "validate",
args: []string{"validate", "--repo-config", "/r", "--local-config", "/l", "--provider-catalog", "/c"},
wantCall: "Validate",
assertReq: func(t *testing.T) {
want := ValidateRequest{RepoGlobalPath: "/r", UserLocalPath: "/l", ProviderCatalog: "/c"}
if svc.lastValidateReq != want {
t.Errorf("got validate req %+v, want %+v", svc.lastValidateReq, want)
}
},
wantStdout: "validate: ok\n revision: sha256:abc\n projects: 1\n providers: 2\n profiles: 3\n",
},
{
name: "provider list",
args: []string{"provider", "list", "--provider-catalog", "/c"},
wantCall: "ProviderList",
assertReq: func(t *testing.T) {
want := ProviderListRequest{CatalogPath: "/c"}
if svc.lastProviderReq != want {
t.Errorf("got provider req %+v, want %+v", svc.lastProviderReq, want)
}
},
wantStdout: "providers: 1\n - pi command=pi caps=chat\n",
},
{
name: "project list",
args: []string{"project", "list", "--repo-config", "/r", "--local-config", "/l"},
wantCall: "ProjectList",
assertReq: func(t *testing.T) {
want := ProjectListRequest{Config: RuntimeConfigPaths{RepoGlobalPath: "/r", UserLocalPath: "/l"}}
if svc.lastProjectReq != want {
t.Errorf("got project req %+v, want %+v", svc.lastProjectReq, want)
}
},
wantStdout: "projects: 1\n - proj1 ws=/ws enabled selected=\"m1\" started=\"\" auto_resume=yes\n",
},
{
name: "milestone list",
args: []string{"milestone", "list", "--repo-config", "/r", "--local-config", "/l", "proj1"},
wantCall: "MilestoneList",
assertReq: func(t *testing.T) {
want := MilestoneListRequest{Config: RuntimeConfigPaths{RepoGlobalPath: "/r", UserLocalPath: "/l"}, Project: "proj1"}
if svc.lastMilestoneReq != want {
t.Errorf("got milestone list req %+v, want %+v", svc.lastMilestoneReq, want)
}
},
wantStdout: "milestones: 1\n - m1 selected=yes work_units=2 completed=1\n",
},
{
name: "milestone select",
args: []string{"milestone", "select", "--repo-config", "/r", "--local-config", "/l", "proj1", "m1"},
wantCall: "MilestoneSelect",
assertReq: func(t *testing.T) {
want := MilestoneSelectRequest{Config: RuntimeConfigPaths{RepoGlobalPath: "/r", UserLocalPath: "/l"}, Project: "proj1", MilestoneID: "m1"}
if svc.lastSelectReq != want {
t.Errorf("got milestone select req %+v, want %+v", svc.lastSelectReq, want)
}
},
wantStdout: "milestone select project=proj1 milestone=m1\n",
},
{
name: "preview",
args: []string{"preview", "--repo-config", "/r", "--local-config", "/l", "proj1"},
wantCall: "Preview",
assertReq: func(t *testing.T) {
want := PreviewRequest{Config: RuntimeConfigPaths{RepoGlobalPath: "/r", UserLocalPath: "/l"}, Project: "proj1"}
if svc.lastPreviewReq != want {
t.Errorf("got preview req %+v, want %+v", svc.lastPreviewReq, want)
}
},
wantStdout: "preview project=proj1\n selected: m1\n next: m2\n",
},
{
name: "serve",
args: []string{"serve", "--repo-config", "/r", "--local-config", "/l", "--provider-catalog", "/c"},
wantCall: "Serve",
assertReq: func(t *testing.T) {
want := ServeRequest{RepoGlobalPath: "/r", UserLocalPath: "/l", ProviderCatalog: "/c"}
if svc.lastServeReq != want {
t.Errorf("got serve req %+v, want %+v", svc.lastServeReq, want)
}
},
wantStdout: "",
},
{
name: "start",
args: []string{"start", "--repo-config", "/r", "--local-config", "/l", "proj1"},
wantCall: "Start",
assertReq: func(t *testing.T) {
want := StartRequest{Config: RuntimeConfigPaths{RepoGlobalPath: "/r", UserLocalPath: "/l", ProviderCatalog: "/c"}, Project: "proj1"}
if svc.lastStartReq != want {
t.Errorf("got start req %+v, want %+v", svc.lastStartReq, want)
}
},
wantStdout: "start project=proj1 status=started\n",
},
{
name: "stop",
args: []string{"stop", "--repo-config", "/r", "--local-config", "/l", "proj1"},
wantCall: "Stop",
assertReq: func(t *testing.T) {
want := StopRequest{Config: RuntimeConfigPaths{RepoGlobalPath: "/r", UserLocalPath: "/l", ProviderCatalog: "/c"}, Project: "proj1"}
if svc.lastStopReq != want {
t.Errorf("got stop req %+v, want %+v", svc.lastStopReq, want)
}
},
wantStdout: "stop project=proj1 status=stopped\n",
},
{
name: "resume",
args: []string{"resume", "--repo-config", "/r", "--local-config", "/l", "proj1"},
wantCall: "Resume",
assertReq: func(t *testing.T) {
want := ResumeRequest{Config: RuntimeConfigPaths{RepoGlobalPath: "/r", UserLocalPath: "/l", ProviderCatalog: "/c"}, Project: "proj1"}
if svc.lastResumeReq != want {
t.Errorf("got resume req %+v, want %+v", svc.lastResumeReq, want)
}
},
wantStdout: "resume project=proj1 status=resumed\n",
},
{
name: "status",
args: []string{"status", "--repo-config", "/r", "--local-config", "/l", "proj1"},
wantCall: "Status",
assertReq: func(t *testing.T) {
want := StatusRequest{Config: RuntimeConfigPaths{RepoGlobalPath: "/r", UserLocalPath: "/l"}, Project: "proj1"}
if svc.lastStatusReq != want {
t.Errorf("got status req %+v, want %+v", svc.lastStatusReq, want)
}
},
wantStdout: "status project=proj1\n state: running\n selected_milestone: m1\n started_milestone: \n works: 2\n - w1 state=completed overlay=active integration=integrated ordinal=1\n - w2 state=running overlay=active integration=queued ordinal=2\n",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
svc.calls = nil
stdout, stderr, err := cmdRunner(t, root, tt.args)
if err != nil {
t.Fatalf("unexpected error for %s: %v (stdout=%q stderr=%q)", tt.name, err, stdout, stderr)
}
if len(svc.calls) != 1 {
t.Fatalf("expected exactly 1 call, got %d: %v", len(svc.calls), svc.calls)
}
if svc.calls[0] != tt.wantCall {
t.Errorf("expected call %q, got %q", tt.wantCall, svc.calls[0])
}
tt.assertReq(t)
if stdout != tt.wantStdout {
t.Errorf("unexpected stdout for %s:\ngot: %q\nwant: %q", tt.name, stdout, tt.wantStdout)
}
})
}
}
// TestCommandJSONOutputMatrix verifies that every output-bearing subcommand
// formats exact serialized JSON output and complete decoded payload equality.
func TestCommandJSONOutputMatrix(t *testing.T) {
svc := newTestService()
root := NewRoot(svc, newTextBuf(), newTextBuf())
tests := []struct {
name string
args []string
wantCall string
assertReq func(t *testing.T)
wantJSON string
}{
{
name: "validate json",
args: []string{"validate", "--repo-config", "/r", "--local-config", "/l", "--provider-catalog", "/c", "--output", "json"},
wantCall: "Validate",
assertReq: func(t *testing.T) {
want := ValidateRequest{RepoGlobalPath: "/r", UserLocalPath: "/l", ProviderCatalog: "/c"}
if svc.lastValidateReq != want {
t.Errorf("got validate req %+v, want %+v", svc.lastValidateReq, want)
}
},
wantJSON: "{\n \"valid\": true,\n \"revision\": \"sha256:abc\",\n \"projects\": 1,\n \"providers\": 2,\n \"profiles\": 3,\n \"errors\": []\n}\n",
},
{
name: "provider list json",
args: []string{"provider", "list", "--provider-catalog", "/c", "--output", "json"},
wantCall: "ProviderList",
assertReq: func(t *testing.T) {
want := ProviderListRequest{CatalogPath: "/c"}
if svc.lastProviderReq != want {
t.Errorf("got provider req %+v, want %+v", svc.lastProviderReq, want)
}
},
wantJSON: "{\n \"providers\": [\n {\n \"id\": \"pi\",\n \"command\": \"pi\",\n \"capabilities\": [\n \"chat\"\n ]\n }\n ]\n}\n",
},
{
name: "project list json",
args: []string{"project", "list", "--repo-config", "/r", "--local-config", "/l", "--output", "json"},
wantCall: "ProjectList",
assertReq: func(t *testing.T) {
want := ProjectListRequest{Config: RuntimeConfigPaths{RepoGlobalPath: "/r", UserLocalPath: "/l"}}
if svc.lastProjectReq != want {
t.Errorf("got project req %+v, want %+v", svc.lastProjectReq, want)
}
},
wantJSON: "{\n \"projects\": [\n {\n \"id\": \"proj1\",\n \"workspace\": \"/ws\",\n \"enabled\": true,\n \"selected_milestone\": \"m1\",\n \"started_milestone\": \"\",\n \"auto_resume_interrupted\": true\n }\n ]\n}\n",
},
{
name: "milestone list json",
args: []string{"milestone", "list", "--repo-config", "/r", "--local-config", "/l", "proj1", "--output", "json"},
wantCall: "MilestoneList",
assertReq: func(t *testing.T) {
want := MilestoneListRequest{Config: RuntimeConfigPaths{RepoGlobalPath: "/r", UserLocalPath: "/l"}, Project: "proj1"}
if svc.lastMilestoneReq != want {
t.Errorf("got milestone list req %+v, want %+v", svc.lastMilestoneReq, want)
}
},
wantJSON: "{\n \"milestones\": [\n {\n \"id\": \"m1\",\n \"selected\": true,\n \"work_units\": 2,\n \"completed_work_units\": 1\n }\n ]\n}\n",
},
{
name: "milestone select json",
args: []string{"milestone", "select", "--repo-config", "/r", "--local-config", "/l", "proj1", "m1", "--output", "json"},
wantCall: "MilestoneSelect",
assertReq: func(t *testing.T) {
want := MilestoneSelectRequest{Config: RuntimeConfigPaths{RepoGlobalPath: "/r", UserLocalPath: "/l"}, Project: "proj1", MilestoneID: "m1"}
if svc.lastSelectReq != want {
t.Errorf("got milestone select req %+v, want %+v", svc.lastSelectReq, want)
}
},
wantJSON: "{\"project\":\"proj1\",\"milestone_id\":\"m1\"}\n",
},
{
name: "preview json",
args: []string{"preview", "--repo-config", "/r", "--local-config", "/l", "proj1", "--output", "json"},
wantCall: "Preview",
assertReq: func(t *testing.T) {
want := PreviewRequest{Config: RuntimeConfigPaths{RepoGlobalPath: "/r", UserLocalPath: "/l"}, Project: "proj1"}
if svc.lastPreviewReq != want {
t.Errorf("got preview req %+v, want %+v", svc.lastPreviewReq, want)
}
},
wantJSON: "{\n \"project\": \"proj1\",\n \"selected\": true,\n \"milestone_id\": \"m1\",\n \"next_work\": \"m2\",\n \"blockers\": []\n}\n",
},
{
name: "start json",
args: []string{"start", "--repo-config", "/r", "--local-config", "/l", "proj1", "--output", "json"},
wantCall: "Start",
assertReq: func(t *testing.T) {
want := StartRequest{Config: RuntimeConfigPaths{RepoGlobalPath: "/r", UserLocalPath: "/l", ProviderCatalog: "/c"}, Project: "proj1"}
if svc.lastStartReq != want {
t.Errorf("got start req %+v, want %+v", svc.lastStartReq, want)
}
},
wantJSON: "{\"project\":\"proj1\",\"status\":\"started\"}\n",
},
{
name: "stop json",
args: []string{"stop", "--repo-config", "/r", "--local-config", "/l", "proj1", "--output", "json"},
wantCall: "Stop",
assertReq: func(t *testing.T) {
want := StopRequest{Config: RuntimeConfigPaths{RepoGlobalPath: "/r", UserLocalPath: "/l", ProviderCatalog: "/c"}, Project: "proj1"}
if svc.lastStopReq != want {
t.Errorf("got stop req %+v, want %+v", svc.lastStopReq, want)
}
},
wantJSON: "{\"project\":\"proj1\",\"status\":\"stopped\"}\n",
},
{
name: "resume json",
args: []string{"resume", "--repo-config", "/r", "--local-config", "/l", "proj1", "--output", "json"},
wantCall: "Resume",
assertReq: func(t *testing.T) {
want := ResumeRequest{Config: RuntimeConfigPaths{RepoGlobalPath: "/r", UserLocalPath: "/l", ProviderCatalog: "/c"}, Project: "proj1"}
if svc.lastResumeReq != want {
t.Errorf("got resume req %+v, want %+v", svc.lastResumeReq, want)
}
},
wantJSON: "{\"project\":\"proj1\",\"status\":\"resumed\"}\n",
},
{
name: "status json",
args: []string{"status", "--repo-config", "/r", "--local-config", "/l", "proj1", "--output", "json"},
wantCall: "Status",
assertReq: func(t *testing.T) {
want := StatusRequest{Config: RuntimeConfigPaths{RepoGlobalPath: "/r", UserLocalPath: "/l"}, Project: "proj1"}
if svc.lastStatusReq != want {
t.Errorf("got status req %+v, want %+v", svc.lastStatusReq, want)
}
},
wantJSON: "{\n \"project\": \"proj1\",\n \"status\": \"running\",\n \"selected_milestone\": \"m1\",\n \"started_milestone\": \"\",\n \"works\": [\n {\n \"id\": \"w1\",\n \"state\": \"completed\",\n \"overlay\": \"active\",\n \"integration\": \"integrated\",\n \"dispatch_ordinal\": 1\n },\n {\n \"id\": \"w2\",\n \"state\": \"running\",\n \"overlay\": \"active\",\n \"integration\": \"queued\",\n \"dispatch_ordinal\": 2\n }\n ],\n \"blockers\": []\n}\n",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
svc.calls = nil
stdout, stderr, err := cmdRunner(t, root, tt.args)
if err != nil {
t.Fatalf("unexpected error for %s: %v (stdout=%q stderr=%q)", tt.name, err, stdout, stderr)
}
if len(svc.calls) != 1 {
t.Fatalf("expected exactly 1 call, got %d: %v", len(svc.calls), svc.calls)
}
if svc.calls[0] != tt.wantCall {
t.Errorf("expected call %q, got %q", tt.wantCall, svc.calls[0])
}
tt.assertReq(t)
assertExactJSON(t, stdout, tt.wantJSON)
})
}
}
// TestPreviewIsSideEffectFree verifies that preview passes correct configuration,
// executes Preview, and leaves the mutation counter strictly at zero.
func TestPreviewIsSideEffectFree(t *testing.T) {
svc := &recordingService{
previewResp: PreviewResponse{Project: "proj1", Selected: false},
}
root := NewRoot(svc, newTextBuf(), newTextBuf())
stdout, stderr, err := cmdRunner(t, root, []string{"preview", "--repo-config", "/r", "--local-config", "/l", "proj1"})
if err != nil {
t.Fatalf("preview error: %v (stdout=%q stderr=%q)", err, stdout, stderr)
}
if svc.mutationCount != 0 {
t.Errorf("expected mutationCount to be 0, got %d", svc.mutationCount)
}
if len(svc.calls) != 1 || svc.calls[0] != "Preview" {
t.Errorf("expected single Preview call, got %v", svc.calls)
}
want := PreviewRequest{Config: RuntimeConfigPaths{RepoGlobalPath: "/r", UserLocalPath: "/l"}, Project: "proj1"}
if svc.lastPreviewReq != want {
t.Errorf("unexpected preview request DTO: %+v, want %+v", svc.lastPreviewReq, want)
}
}
// TestStartRequiresSelectedMilestone verifies that start rejects a project without an explicit
// selection, returns an error, and does not record any mutation.
func TestStartRequiresSelectedMilestone(t *testing.T) {
svc := &recordingService{
selectedMilestones: map[string]string{}, // empty selection
}
root := NewRoot(svc, newTextBuf(), newTextBuf())
_, _, err := cmdRunner(t, root, []string{"start", "--repo-config", "/r", "--local-config", "/l", "proj1"})
if err == nil {
t.Fatal("expected start to fail when milestone is not selected")
}
if !strings.Contains(err.Error(), "selected milestone") {
t.Errorf("expected error to mention selected milestone, got: %v", err)
}
if svc.mutationCount != 0 {
t.Errorf("expected mutationCount to remain 0 on rejected start, got %d", svc.mutationCount)
}
}
// TestStatusIncludesOverlayIntegrationAndBlockers verifies status text and exact JSON output.
func TestStatusIncludesOverlayIntegrationAndBlockers(t *testing.T) {
svc := &recordingService{
statusResp: StatusResponse{
Project: "proj1",
Status: "running",
SelectedMilestone: "m1",
StartedMilestone: "m1",
Works: []WorkStatusEntry{
{ID: "w1", State: "running", Overlay: "active-overlay", Integration: "integrating", DispatchOrdinal: 2, Blocker: &BlockerEntry{Code: "provider_capacity", Message: "no capacity", Retryable: true}},
},
Blockers: []BlockerEntry{{Code: "provider_capacity", Message: "no capacity", Retryable: true}},
},
}
root := NewRoot(svc, newTextBuf(), newTextBuf())
stdout, _, err := cmdRunner(t, root, []string{"status", "--repo-config", "/r", "--local-config", "/l", "proj1"})
if err != nil {
t.Fatalf("status should not error: %v", err)
}
wantText := "status project=proj1\n state: running\n selected_milestone: m1\n started_milestone: m1\n works: 1\n - w1 state=running overlay=active-overlay integration=integrating ordinal=2\n blocker: provider_capacity no capacity retryable=yes\n blocker: provider_capacity no capacity retryable=yes\n"
if stdout != wantText {
t.Errorf("unexpected status text:\ngot: %q\nwant: %q", stdout, wantText)
}
jsonOut, _, jsonErr := cmdRunner(t, root, []string{"status", "--repo-config", "/r", "--local-config", "/l", "proj1", "--output", "json"})
if jsonErr != nil {
t.Fatalf("status json error: %v", jsonErr)
}
wantJSON := "{\n \"project\": \"proj1\",\n \"status\": \"running\",\n \"selected_milestone\": \"m1\",\n \"started_milestone\": \"m1\",\n \"works\": [\n {\n \"id\": \"w1\",\n \"state\": \"running\",\n \"overlay\": \"active-overlay\",\n \"integration\": \"integrating\",\n \"dispatch_ordinal\": 2,\n \"blocker\": {\n \"code\": \"provider_capacity\",\n \"message\": \"no capacity\",\n \"retryable\": true\n }\n }\n ],\n \"blockers\": [\n {\n \"code\": \"provider_capacity\",\n \"message\": \"no capacity\",\n \"retryable\": true\n }\n ]\n}\n"
assertExactJSON(t, jsonOut, wantJSON)
}
// TestStableTextAndJSONOutput tests the exact formatting and complete JSON structure of all response types.
func TestStableTextAndJSONOutput(t *testing.T) {
valResp := ValidateResponse{Valid: true, Revision: "rev1", Projects: 1, Providers: 2, Profiles: 3, Errors: []string{}}
wantValText := "validate: ok\n revision: rev1\n projects: 1\n providers: 2\n profiles: 3\n"
if text := valResp.FormatText(); text != wantValText {
t.Errorf("unexpected validate text:\ngot: %q\nwant: %q", text, wantValText)
}
wantValJSON := "{\n \"valid\": true,\n \"revision\": \"rev1\",\n \"projects\": 1,\n \"providers\": 2,\n \"profiles\": 3,\n \"errors\": []\n}"
assertExactJSON(t, valResp.FormatJSON(), wantValJSON)
provResp := ProviderListResponse{Providers: []ProviderEntry{{ID: "p1", Command: "cmd1", Capabilities: []string{"cap1"}}}}
wantProvText := "providers: 1\n - p1 command=cmd1 caps=cap1\n"
if text := provResp.FormatText(); text != wantProvText {
t.Errorf("unexpected provider text:\ngot: %q\nwant: %q", text, wantProvText)
}
wantProvJSON := "{\n \"providers\": [\n {\n \"id\": \"p1\",\n \"command\": \"cmd1\",\n \"capabilities\": [\n \"cap1\"\n ]\n }\n ]\n}"
assertExactJSON(t, provResp.FormatJSON(), wantProvJSON)
projResp := ProjectListResponse{Projects: []ProjectEntry{{ID: "pj1", Workspace: "/ws", Enabled: true, SelectedMilestone: "m1", StartedMilestone: "m1", AutoResumeInterrupt: true}}}
wantProjText := "projects: 1\n - pj1 ws=/ws enabled selected=\"m1\" started=\"m1\" auto_resume=yes\n"
if text := projResp.FormatText(); text != wantProjText {
t.Errorf("unexpected project text:\ngot: %q\nwant: %q", text, wantProjText)
}
wantProjJSON := "{\n \"projects\": [\n {\n \"id\": \"pj1\",\n \"workspace\": \"/ws\",\n \"enabled\": true,\n \"selected_milestone\": \"m1\",\n \"started_milestone\": \"m1\",\n \"auto_resume_interrupted\": true\n }\n ]\n}"
assertExactJSON(t, projResp.FormatJSON(), wantProjJSON)
msResp := MilestoneListResponse{Milestones: []MilestoneEntry{{ID: "m1", Selected: true, WorkUnits: 1, CompletedWorkUnits: 1}}}
wantMsText := "milestones: 1\n - m1 selected=yes work_units=1 completed=1\n"
if text := msResp.FormatText(); text != wantMsText {
t.Errorf("unexpected milestone list text:\ngot: %q\nwant: %q", text, wantMsText)
}
wantMsJSON := "{\n \"milestones\": [\n {\n \"id\": \"m1\",\n \"selected\": true,\n \"work_units\": 1,\n \"completed_work_units\": 1\n }\n ]\n}"
assertExactJSON(t, msResp.FormatJSON(), wantMsJSON)
selResp := MilestoneSelectResponse{Project: "pj1", MilestoneID: "m1"}
wantSelText := "milestone select project=pj1 milestone=m1\n"
if text := selResp.FormatText(); text != wantSelText {
t.Errorf("unexpected select text:\ngot: %q\nwant: %q", text, wantSelText)
}
wantSelJSON := "{\"project\":\"pj1\",\"milestone_id\":\"m1\"}"
assertExactJSON(t, selResp.FormatJSON(), wantSelJSON)
prevResp := PreviewResponse{Project: "pj1", Selected: true, MilestoneID: "m1", NextWork: "m2", Blockers: []BlockerEntry{{Code: "b1", Message: "msg1", Retryable: true}}}
wantPrevText := "preview project=pj1\n selected: m1\n next: m2\n blocker: b1 msg1 retryable=yes\n"
if text := prevResp.FormatText(); text != wantPrevText {
t.Errorf("unexpected preview text:\ngot: %q\nwant: %q", text, wantPrevText)
}
wantPrevJSON := "{\n \"project\": \"pj1\",\n \"selected\": true,\n \"milestone_id\": \"m1\",\n \"next_work\": \"m2\",\n \"blockers\": [\n {\n \"code\": \"b1\",\n \"message\": \"msg1\",\n \"retryable\": true\n }\n ]\n}"
assertExactJSON(t, prevResp.FormatJSON(), wantPrevJSON)
stResp := StartResponse{Project: "pj1", Status: "ok"}
if text := stResp.FormatText(); text != "start project=pj1 status=ok\n" {
t.Errorf("unexpected start text: %q", text)
}
assertExactJSON(t, stResp.FormatJSON(), "{\"project\":\"pj1\",\"status\":\"ok\"}")
spResp := StopResponse{Project: "pj1", Status: "ok"}
if text := spResp.FormatText(); text != "stop project=pj1 status=ok\n" {
t.Errorf("unexpected stop text: %q", text)
}
assertExactJSON(t, spResp.FormatJSON(), "{\"project\":\"pj1\",\"status\":\"ok\"}")
resResp := ResumeResponse{Project: "pj1", Status: "ok"}
if text := resResp.FormatText(); text != "resume project=pj1 status=ok\n" {
t.Errorf("unexpected resume text: %q", text)
}
assertExactJSON(t, resResp.FormatJSON(), "{\"project\":\"pj1\",\"status\":\"ok\"}")
}
// TestUnsupportedOutputFormat verifies that unknown --output values fail before service dispatch
// and produce zero calls, zero mutations, empty stdout, and an exact typed CLI error across read and mutation commands.
func TestUnsupportedOutputFormat(t *testing.T) {
cmdCases := []struct {
name string
args []string
}{
{name: "validate", args: []string{"validate", "--output", "xml"}},
{name: "provider list", args: []string{"provider", "list", "--output", "xml"}},
{name: "project list", args: []string{"project", "list", "--output", "xml"}},
{name: "milestone list", args: []string{"milestone", "list", "proj1", "--output", "xml"}},
{name: "milestone select", args: []string{"milestone", "select", "proj1", "m1", "--output", "xml"}},
{name: "preview", args: []string{"preview", "proj1", "--output", "xml"}},
{name: "serve", args: []string{"serve", "--output", "xml"}},
{name: "start", args: []string{"start", "proj1", "--output", "xml"}},
{name: "stop", args: []string{"stop", "proj1", "--output", "xml"}},
{name: "resume", args: []string{"resume", "proj1", "--output", "xml"}},
{name: "status", args: []string{"status", "proj1", "--output", "xml"}},
}
for _, tc := range cmdCases {
t.Run(tc.name, func(t *testing.T) {
svc := &recordingService{
selectedMilestones: map[string]string{"proj1": "m1"},
validateResp: ValidateResponse{Valid: true},
}
root := NewRoot(svc, newTextBuf(), newTextBuf())
stdout, _, err := cmdRunner(t, root, tc.args)
if err == nil {
t.Fatalf("expected error for unsupported output format 'xml' on %s", tc.name)
}
if got, want := err.Error(), "unsupported output format: xml"; got != want {
t.Errorf("error = %q, want %q", got, want)
}
if len(svc.calls) != 0 {
t.Errorf("expected 0 service calls for rejected format on %s, got %d: %v", tc.name, len(svc.calls), svc.calls)
}
if svc.mutationCount != 0 {
t.Errorf("expected 0 mutations on %s, got %d", tc.name, svc.mutationCount)
}
if stdout != "" {
t.Errorf("expected empty stdout for rejected format on %s, got %q", tc.name, stdout)
}
})
}
}