package workflow import ( "os" "path/filepath" "strings" "testing" "github.com/ajhahnde/eeco/internal/config" "github.com/ajhahnde/eeco/internal/queue" ) func queueBody(t *testing.T, cfg *config.Config) string { t.Helper() b, err := os.ReadFile(filepath.Join(cfg.Workspace, "state", queue.Filename)) if err != nil { if os.IsNotExist(err) { return "" } t.Fatalf("queue: %v", err) } return string(b) } func TestEvolve_ManualIsDisabledNoOp(t *testing.T) { cfg := newCfg(t) cfg.Automation = config.AutomationManual res, err := evolve{}.Run(Env{Config: cfg, Gate: gateWith(t, cfg, &stubProvider{text: "x"}, true)}) if err != nil { t.Fatal(err) } if res.Code != CodeClean { t.Errorf("code = %d, want clean", res.Code) } if !strings.Contains(res.Summary, "manual") { t.Errorf("summary = %q, want it to mention manual", res.Summary) } if q := queueBody(t, cfg); q != "" { t.Errorf("manual evolve must not queue anything, got:\n%s", q) } } func TestEvolve_ProposeDefersWithoutConsent(t *testing.T) { cfg := newCfg(t) cfg.Automation = config.AutomationPropose // Consent false: the Gate parks the prompt and queues ai-parked; // evolve reports AI-deferred (contract code 3), like bug-sweep. res, err := evolve{}.Run(Env{Config: cfg, Gate: gateWith(t, cfg, &stubProvider{text: "x"}, false)}) if err != nil { t.Fatal(err) } if res.Code != CodeAIDeferred { t.Fatalf("code = %d, want %d (AI deferred)", res.Code, CodeAIDeferred) } if !strings.Contains(queueBody(t, cfg), "ai-parked") { t.Error("expected the Gate to have queued an ai-parked item") } } func TestEvolve_ProposeQueuesProposalNoScaffold(t *testing.T) { cfg := newCfg(t) cfg.Automation = config.AutomationPropose p := &stubProvider{text: "Repeated release bumps.\nWORKFLOW: release-bump — automate it\n"} res, err := evolve{}.Run(Env{Config: cfg, Gate: gateWith(t, cfg, p, true)}) if err != nil { t.Fatal(err) } if res.Code != CodeClean { t.Fatalf("code = %d, want clean", res.Code) } q := queueBody(t, cfg) if !strings.Contains(q, "**evolve**") || !strings.Contains(q, "proposal ready") { t.Errorf("queue missing evolve proposal:\n%s", q) } // propose level must NOT write a workflow. if _, err := os.Stat(filepath.Join(cfg.Workspace, "workflows", "release-bump")); !os.IsNotExist(err) { t.Errorf("propose level scaffolded a workflow (err=%v)", err) } } func TestEvolve_ScaffoldWritesInactiveAndQueues(t *testing.T) { cfg := newCfg(t) cfg.Automation = config.AutomationScaffold p := &stubProvider{text: "prose\nWORKFLOW: dep-audit — audit deps\nWORKFLOW: dep-audit — duplicate\nWORKFLOW: BAD NAME — skip\n"} res, err := evolve{}.Run(Env{Config: cfg, Gate: gateWith(t, cfg, p, true)}) if err != nil { t.Fatal(err) } if res.Code != CodeClean { t.Fatalf("code = %d, want clean", res.Code) } dir := filepath.Join(cfg.Workspace, "workflows", "dep-audit") if _, err := os.Stat(filepath.Join(dir, "run")); err != nil { t.Fatalf("scaffolded entry missing: %v", err) } if _, err := os.Stat(filepath.Join(dir, "README.md")); err != nil { t.Fatalf("scaffolded README missing: %v", err) } q := queueBody(t, cfg) if !strings.Contains(q, "ready to activate") || !strings.Contains(q, "dep-audit") { t.Errorf("queue missing 'ready to activate' for dep-audit:\n%s", q) } // The duplicate name must be scaffolded only once (idempotent set). if strings.Count(q, "ready to activate") != 1 { t.Errorf("dep-audit scaffolded/queued more than once:\n%s", q) } } func TestEvolve_ScaffoldCollisionIsNotFatal(t *testing.T) { cfg := newCfg(t) cfg.Automation = config.AutomationScaffold // Pre-existing workflow of the same name: Scaffold refuses to // overwrite; evolve must record the collision and keep going. clash := filepath.Join(cfg.Workspace, "workflows", "taken") if err := os.MkdirAll(clash, 0o755); err != nil { t.Fatal(err) } p := &stubProvider{text: "WORKFLOW: taken — collides\nWORKFLOW: fresh-one — ok\n"} res, err := evolve{}.Run(Env{Config: cfg, Gate: gateWith(t, cfg, p, true)}) if err != nil { t.Fatalf("collision must not be fatal: %v", err) } if res.Code != CodeClean { t.Fatalf("code = %d, want clean", res.Code) } if _, err := os.Stat(filepath.Join(cfg.Workspace, "workflows", "fresh-one", "run")); err != nil { t.Errorf("the non-colliding candidate was not scaffolded: %v", err) } q := queueBody(t, cfg) if !strings.Contains(q, "could not be scaffolded: taken") { t.Errorf("collision not surfaced in queue:\n%s", q) } } func TestEvolve_NilGateDefers(t *testing.T) { cfg := newCfg(t) cfg.Automation = config.AutomationPropose res, err := evolve{}.Run(Env{Config: cfg}) if err != nil { t.Fatal(err) } if res.Code != CodeAIDeferred { t.Errorf("code = %d, want %d (AI deferred) with nil gate", res.Code, CodeAIDeferred) } } func TestEvolve_NoConsent_DetCandidates_ReturnsClean(t *testing.T) { cfg := newCfg(t) cfg.Automation = config.AutomationPropose root := cfg.RepoRoot writeRepoFile(t, root, "a.txt", "1") gitInit(t, root) runGit(t, root, "commit", "-q", "-m", "feat: one") runGit(t, root, "commit", "-q", "--allow-empty", "-m", "feat: two") runGit(t, root, "commit", "-q", "--allow-empty", "-m", "feat: three") res, err := evolve{}.Run(Env{Config: cfg, Gate: gateWith(t, cfg, &stubProvider{text: "x"}, false)}) if err != nil { t.Fatal(err) } if res.Code != CodeClean { t.Fatalf("code = %d, want %d (clean) — no-consent + det≥1 must exit 0", res.Code, CodeClean) } if !strings.Contains(res.Summary, "deterministic candidate") { t.Errorf("summary = %q, want it to mention deterministic candidate(s)", res.Summary) } q := queueBody(t, cfg) if !strings.Contains(q, "Workflow candidate: feat-workflow") { t.Errorf("queue missing feat-workflow candidate:\n%s", q) } if !strings.Contains(q, "ai-parked") { t.Errorf("queue missing ai-parked item (gate did not park on no-consent):\n%s", q) } } func TestEvolve_NoConsent_NoDetCandidates_DefersExit3(t *testing.T) { cfg := newCfg(t) cfg.Automation = config.AutomationPropose root := cfg.RepoRoot writeRepoFile(t, root, "a.txt", "1") gitInit(t, root) runGit(t, root, "commit", "-q", "-m", "plain message one") runGit(t, root, "commit", "-q", "--allow-empty", "-m", "plain message two") runGit(t, root, "commit", "-q", "--allow-empty", "-m", "plain message three") res, err := evolve{}.Run(Env{Config: cfg, Gate: gateWith(t, cfg, &stubProvider{text: "x"}, false)}) if err != nil { t.Fatal(err) } if res.Code != CodeAIDeferred { t.Fatalf("code = %d, want %d (AI deferred) — no-consent + det=0 must preserve exit 3", res.Code, CodeAIDeferred) } } func TestEvolve_LedgerWrittenOnFirstRun(t *testing.T) { cfg := newCfg(t) cfg.Automation = config.AutomationPropose root := cfg.RepoRoot writeRepoFile(t, root, "a.txt", "1") gitInit(t, root) runGit(t, root, "commit", "-q", "-m", "fix: one") runGit(t, root, "commit", "-q", "--allow-empty", "-m", "fix: two") runGit(t, root, "commit", "-q", "--allow-empty", "-m", "fix: three") env := Env{Config: cfg, Gate: gateWith(t, cfg, &stubProvider{text: "x"}, false)} if _, err := (evolve{}).Run(env); err != nil { t.Fatal(err) } stateDir := filepath.Join(cfg.Workspace, "state") h, err := LoadHistory(stateDir) if err != nil { t.Fatal(err) } if len(h.Records) != 1 { t.Fatalf("ledger records: got %d, want 1", len(h.Records)) } r := h.Records[0] if r.SignalKind != SignalCommitType || r.SignalKey != "fix" { t.Errorf("ledger signal: got %s/%s, want %s/fix", r.SignalKind, r.SignalKey, SignalCommitType) } if r.CountAtProposal != 3 { t.Errorf("CountAtProposal: got %d, want 3", r.CountAtProposal) } if r.QueueTitle != "Workflow candidate: fix-workflow" { t.Errorf("QueueTitle: got %q", r.QueueTitle) } if r.Resolved { t.Errorf("fresh record must not be resolved") } } func TestEvolve_LedgerSuppressesRepeatRun(t *testing.T) { cfg := newCfg(t) cfg.Automation = config.AutomationPropose root := cfg.RepoRoot writeRepoFile(t, root, "a.txt", "1") gitInit(t, root) runGit(t, root, "commit", "-q", "-m", "fix: one") runGit(t, root, "commit", "-q", "--allow-empty", "-m", "fix: two") runGit(t, root, "commit", "-q", "--allow-empty", "-m", "fix: three") // First run files the candidate + ledger record. env := Env{Config: cfg, Gate: gateWith(t, cfg, &stubProvider{text: "x"}, false)} if _, err := (evolve{}).Run(env); err != nil { t.Fatal(err) } q1 := queueBody(t, cfg) count1 := strings.Count(q1, "Workflow candidate: fix-workflow") if count1 != 1 { t.Fatalf("first run candidate count: got %d, want 1", count1) } // Second run with identical git log: ledger must suppress the // candidate; the queue row count stays at 1 (no duplicate). env = Env{Config: cfg, Gate: gateWith(t, cfg, &stubProvider{text: "x"}, false)} if _, err := (evolve{}).Run(env); err != nil { t.Fatal(err) } q2 := queueBody(t, cfg) count2 := strings.Count(q2, "Workflow candidate: fix-workflow") if count2 != 1 { t.Errorf("ledger suppression failed: candidate appeared %d times, want 1", count2) } // Ledger must still hold exactly one record (no append on repeat). h, err := LoadHistory(filepath.Join(cfg.Workspace, "state")) if err != nil { t.Fatal(err) } if len(h.Records) != 1 { t.Errorf("ledger records after repeat run: got %d, want 1", len(h.Records)) } } func TestEvolve_LedgerResolvedRecordStillSuppresses(t *testing.T) { cfg := newCfg(t) cfg.Automation = config.AutomationPropose root := cfg.RepoRoot stateDir := filepath.Join(cfg.Workspace, "state") writeRepoFile(t, root, "a.txt", "1") gitInit(t, root) runGit(t, root, "commit", "-q", "-m", "fix: one") runGit(t, root, "commit", "-q", "--allow-empty", "-m", "fix: two") runGit(t, root, "commit", "-q", "--allow-empty", "-m", "fix: three") // First run. env := Env{Config: cfg, Gate: gateWith(t, cfg, &stubProvider{text: "x"}, false)} if _, err := (evolve{}).Run(env); err != nil { t.Fatal(err) } // Operator resolves the queue item by ticking the checkbox. body, err := os.ReadFile(filepath.Join(stateDir, queue.Filename)) if err != nil { t.Fatal(err) } rewritten := strings.Replace(string(body), "- [ ] **evolve**", "- [x] **evolve**", 1) if err := os.WriteFile(filepath.Join(stateDir, queue.Filename), []byte(rewritten), 0o644); err != nil { t.Fatal(err) } // Second run: reconciliation flips Resolved; suppression still holds // (resolved records suppress in v2.2.0 — re-propose-on-recurrence is // a follow-on slice). env = Env{Config: cfg, Gate: gateWith(t, cfg, &stubProvider{text: "x"}, false)} if _, err := (evolve{}).Run(env); err != nil { t.Fatal(err) } h, err := LoadHistory(stateDir) if err != nil { t.Fatal(err) } if len(h.Records) != 1 { t.Fatalf("records after run-resolve-run: got %d, want 1", len(h.Records)) } if !h.Records[0].Resolved { t.Errorf("reconciliation must flip Resolved → true") } } func TestParseCandidates(t *testing.T) { in := strings.Join([]string{ "some prose first", "WORKFLOW: good-name — does a thing", "WORKFLOW: spaced-name\twith tab", "WORKFLOW: Bad_Name — rejected", "WORKFLOW: good-name — duplicate dropped", "not a candidate line", }, "\n") got := parseCandidates(in) want := []string{"good-name", "spaced-name"} if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] { t.Errorf("parseCandidates = %v, want %v", got, want) } } func TestEvolve_RegisteredInDefaultRegistry(t *testing.T) { if _, ok := DefaultRegistry().Get("evolve"); !ok { t.Fatal("evolve not registered in DefaultRegistry") } }