Go 353 lines
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")
}
}