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) } }) } }