리뷰에서 확인된 Milestone 식별자와 컴파일 바이너리 상태 검증의 빈틈을 보완하고, 활성 agent-task에서는 dispatcher만 실행 경로로 사용하도록 고정한다.
755 lines
31 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
}
|