리뷰에서 확인된 Milestone 식별자와 컴파일 바이너리 상태 검증의 빈틈을 보완하고, 활성 agent-task에서는 dispatcher만 실행 경로로 사용하도록 고정한다.
624 lines
20 KiB
Go
624 lines
20 KiB
Go
package taskloop
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"iop/packages/go/agenttask"
|
|
)
|
|
|
|
func TestWorkflowNormalizesActiveCompletionAndDependencies(t *testing.T) {
|
|
fixture := newRuntimeFixture(t)
|
|
workflow, err := NewWorkflow(fixture.snapshot, fixture.runtime.selections)
|
|
if err != nil {
|
|
t.Fatalf("NewWorkflow: %v", err)
|
|
}
|
|
snapshot, err := workflow.Snapshot(context.Background(), "project-a")
|
|
if err != nil {
|
|
t.Fatalf("Snapshot: %v", err)
|
|
}
|
|
if snapshot.WorkspaceID != WorkspaceIdentity(fixture.projectA) ||
|
|
len(snapshot.Units) != 2 {
|
|
t.Fatalf("workflow snapshot = %#v", snapshot)
|
|
}
|
|
if snapshot.Units[0].ID != "1" || snapshot.Units[0].Completed {
|
|
t.Fatalf("first unit = %#v", snapshot.Units[0])
|
|
}
|
|
if snapshot.Units[1].ID != "2" ||
|
|
len(snapshot.Units[1].ExplicitPredecessors) != 1 ||
|
|
snapshot.Units[1].ExplicitPredecessors[0].Ref != "1" {
|
|
t.Fatalf("dependent unit = %#v", snapshot.Units[1])
|
|
}
|
|
writeTaskFile(
|
|
t,
|
|
filepath.Join(fixture.projectA, "agent-task", "m-m1", "1_first", completeFileName),
|
|
"complete\n",
|
|
)
|
|
snapshot, err = workflow.Snapshot(context.Background(), "project-a")
|
|
if err != nil {
|
|
t.Fatalf("Snapshot after completion: %v", err)
|
|
}
|
|
if !snapshot.Units[0].Completed {
|
|
t.Fatalf("completion was not observed: %#v", snapshot.Units[0])
|
|
}
|
|
}
|
|
|
|
func TestWorkflowMilestonesListsSelectableTaskGroups(t *testing.T) {
|
|
fixture := newRuntimeFixture(t)
|
|
archivedComp := filepath.Join(fixture.projectA, "agent-task", "archive", "2026", "07", "m-m2", "01_done", completeFileName)
|
|
if err := os.MkdirAll(filepath.Dir(archivedComp), 0700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
writeTaskFile(t, archivedComp, "complete\n")
|
|
|
|
milestones, err := fixture.runtime.Milestones(context.Background(), "project-a")
|
|
if err != nil {
|
|
t.Fatalf("Milestones: %v", err)
|
|
}
|
|
if len(milestones) != 2 {
|
|
t.Fatalf("milestones count = %d, want 2", len(milestones))
|
|
}
|
|
if milestones[0].ID != "m1" || !milestones[0].Selected || milestones[0].WorkUnits != 2 || milestones[0].CompletedWorkUnits != 0 {
|
|
t.Fatalf("milestones[0] = %#v", milestones[0])
|
|
}
|
|
if milestones[1].ID != "m2" || milestones[1].Selected || milestones[1].WorkUnits != 1 || milestones[1].CompletedWorkUnits != 1 {
|
|
t.Fatalf("milestones[1] = %#v", milestones[1])
|
|
}
|
|
|
|
for _, m := range milestones {
|
|
if err := fixture.runtime.SelectMilestone(context.Background(), "project-a", m.ID); err != nil {
|
|
t.Fatalf("SelectMilestone(%q): %v", m.ID, err)
|
|
}
|
|
snapshot, err := fixture.runtime.WorkflowSnapshot(context.Background(), "project-a")
|
|
if err != nil {
|
|
t.Fatalf("WorkflowSnapshot for %q: %v", m.ID, err)
|
|
}
|
|
if len(snapshot.Units) != m.WorkUnits {
|
|
t.Fatalf("snapshot.Units length = %d, want %d for milestone %q", len(snapshot.Units), m.WorkUnits, m.ID)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestWorkflowMilestonesFailClosedBoundaryMatrix(t *testing.T) {
|
|
t.Run("empty workspace returns error", func(t *testing.T) {
|
|
fixture := newRuntimeFixture(t)
|
|
if err := os.RemoveAll(filepath.Join(fixture.projectA, "agent-task")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, err := fixture.runtime.Milestones(context.Background(), "project-a")
|
|
if err == nil || !strings.Contains(err.Error(), "no workflow-backed milestones") {
|
|
t.Fatalf("Milestones error = %v, want no workflow-backed milestones", err)
|
|
}
|
|
})
|
|
|
|
t.Run("malformed selected workflow fails closed", func(t *testing.T) {
|
|
fixture := newRuntimeFixture(t)
|
|
malformed := filepath.Join(fixture.projectA, "agent-task", "m-m1", "3_bad")
|
|
if err := os.MkdirAll(malformed, 0700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
writeTaskFile(t, filepath.Join(malformed, "PLAN-test.md"), "# Bad Plan without summary\n")
|
|
writeTaskFile(t, filepath.Join(malformed, "CODE_REVIEW-test.md"), "# Review\n")
|
|
|
|
_, err := fixture.runtime.Milestones(context.Background(), "project-a")
|
|
if err == nil || !strings.Contains(err.Error(), "Modified Files Summary is missing") {
|
|
t.Fatalf("Milestones error = %v, want Modified Files Summary is missing", err)
|
|
}
|
|
})
|
|
|
|
t.Run("invalid active identity in agent-task fails closed", func(t *testing.T) {
|
|
fixture := newRuntimeFixture(t)
|
|
badDir := filepath.Join(fixture.projectA, "agent-task", "m-FOO")
|
|
if err := os.MkdirAll(badDir, 0700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, err := fixture.runtime.Milestones(context.Background(), "project-a")
|
|
if err == nil {
|
|
t.Fatal("expected error for invalid active identity m-FOO")
|
|
}
|
|
})
|
|
|
|
t.Run("invalid archive identity fails closed", func(t *testing.T) {
|
|
fixture := newRuntimeFixture(t)
|
|
badDir := filepath.Join(fixture.projectA, "agent-task", "archive", "2026", "07", "m-*")
|
|
if err := os.MkdirAll(badDir, 0700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, err := fixture.runtime.Milestones(context.Background(), "project-a")
|
|
if err == nil {
|
|
t.Fatal("expected error for invalid archive identity m-*")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestInspectTaskGroupRejectsInvalidIdentifiers(t *testing.T) {
|
|
workspace := canonicalTempDir(t)
|
|
invalid := []string{
|
|
"m-*",
|
|
"m-foo?",
|
|
"m-FOO",
|
|
"m-foo_bar",
|
|
"m-foo bar",
|
|
"m-foo/bar",
|
|
"m-",
|
|
"m-123+456",
|
|
"foo",
|
|
"",
|
|
}
|
|
for _, id := range invalid {
|
|
t.Run(id, func(t *testing.T) {
|
|
_, err := InspectTaskGroup(workspace, id)
|
|
if err == nil {
|
|
t.Fatalf("InspectTaskGroup(%q) expected error, got nil", id)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestWorkflowMilestonesNormalizesArchiveCollisionSuffixes(t *testing.T) {
|
|
workspace := canonicalTempDir(t)
|
|
createSinglePlanPair(t, workspace, "m1")
|
|
|
|
archive1 := filepath.Join(workspace, "agent-task", "archive", "2026", "06", "m-m1_1", "01_done", completeFileName)
|
|
archive2 := filepath.Join(workspace, "agent-task", "archive", "2026", "07", "m-m1_2", "02_done", completeFileName)
|
|
for _, p := range []string{archive1, archive2} {
|
|
if err := os.MkdirAll(filepath.Dir(p), 0700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
writeTaskFile(t, p, "complete\n")
|
|
}
|
|
|
|
views, err := scanMilestones(workspace, "m1")
|
|
if err != nil {
|
|
t.Fatalf("scanMilestones: %v", err)
|
|
}
|
|
if len(views) != 1 {
|
|
t.Fatalf("views count = %d, want 1 (archive collision suffixes normalized)", len(views))
|
|
}
|
|
if views[0].ID != "m1" || views[0].WorkUnits != 3 || views[0].CompletedWorkUnits != 2 {
|
|
t.Fatalf("views[0] = %#v, want ID=m1 WorkUnits=3 CompletedWorkUnits=2", views[0])
|
|
}
|
|
}
|
|
|
|
func TestWorkflowArchiveOnlyMilestoneRemainsSelectable(t *testing.T) {
|
|
fixture := newRuntimeFixture(t)
|
|
if err := os.RemoveAll(filepath.Join(fixture.projectA, "agent-task", "m-m1")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
archivedComp := filepath.Join(fixture.projectA, "agent-task", "archive", "2026", "07", "m-m1", completeFileName)
|
|
if err := os.MkdirAll(filepath.Dir(archivedComp), 0700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
writeTaskFile(t, archivedComp, "complete\n")
|
|
|
|
snapshot, err := fixture.runtime.WorkflowSnapshot(context.Background(), "project-a")
|
|
if err != nil {
|
|
t.Fatalf("WorkflowSnapshot for archive-only milestone: %v", err)
|
|
}
|
|
if len(snapshot.Units) != 1 || !snapshot.Units[0].Completed {
|
|
t.Fatalf("snapshot units = %#v", snapshot.Units)
|
|
}
|
|
}
|
|
|
|
func TestInspectTaskGroupArchiveOnly(t *testing.T) {
|
|
workspace := canonicalTempDir(t)
|
|
completion := filepath.Join(workspace, "agent-task", "archive", "2026", "07", "m-m1", completeFileName)
|
|
if err := os.MkdirAll(filepath.Dir(completion), 0700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
writeTaskFile(t, completion, "complete\n")
|
|
|
|
units, err := InspectTaskGroup(workspace, "m-m1")
|
|
if err != nil {
|
|
t.Fatalf("InspectTaskGroup: %v", err)
|
|
}
|
|
if len(units) != 1 || units[0].ID != "m-m1" || !units[0].Completed {
|
|
t.Fatalf("units = %#v", units)
|
|
}
|
|
}
|
|
|
|
func TestInspectTaskGroupSupportsSingleAndSplitLayouts(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
prepare func(*testing.T, string)
|
|
wantID agenttask.WorkUnitID
|
|
wantPlanPath string
|
|
}{
|
|
{
|
|
name: "single plan at task group root",
|
|
prepare: func(t *testing.T, workspace string) {
|
|
createSinglePlanPair(t, workspace, "m1")
|
|
},
|
|
wantID: "m-m1",
|
|
wantPlanPath: "agent-task/m-m1/PLAN-test.md",
|
|
},
|
|
{
|
|
name: "split plan in indexed child",
|
|
prepare: func(t *testing.T, workspace string) {
|
|
createTaskPair(t, workspace, "m1", "01_first", false)
|
|
},
|
|
wantID: "01",
|
|
wantPlanPath: "agent-task/m-m1/01_first/PLAN-test.md",
|
|
},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
workspace := canonicalTempDir(t)
|
|
test.prepare(t, workspace)
|
|
|
|
units, err := InspectTaskGroup(workspace, "m-m1")
|
|
if err != nil {
|
|
t.Fatalf("InspectTaskGroup: %v", err)
|
|
}
|
|
if len(units) != 1 || units[0].ID != test.wantID || units[0].Completed {
|
|
t.Fatalf("units = %#v", units)
|
|
}
|
|
if units[0].Metadata["plan_path"] != test.wantPlanPath {
|
|
t.Fatalf("plan path = %q, want %q", units[0].Metadata["plan_path"], test.wantPlanPath)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestInspectTaskGroupSupportsArchiveOnlySingleAndSplitLayouts(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
completion string
|
|
wantID agenttask.WorkUnitID
|
|
}{
|
|
{
|
|
name: "single plan archive",
|
|
completion: filepath.Join("agent-task", "archive", "2026", "07", "m-m1", completeFileName),
|
|
wantID: "m-m1",
|
|
},
|
|
{
|
|
name: "split plan archive",
|
|
completion: filepath.Join("agent-task", "archive", "2026", "07", "m-m1", "01_done", completeFileName),
|
|
wantID: "01",
|
|
},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
workspace := canonicalTempDir(t)
|
|
completion := filepath.Join(workspace, test.completion)
|
|
if err := os.MkdirAll(filepath.Dir(completion), 0700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
writeTaskFile(t, completion, "complete\n")
|
|
|
|
units, err := InspectTaskGroup(workspace, "m-m1")
|
|
if err != nil {
|
|
t.Fatalf("InspectTaskGroup: %v", err)
|
|
}
|
|
if len(units) != 1 || units[0].ID != test.wantID || !units[0].Completed {
|
|
t.Fatalf("units = %#v", units)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestInspectTaskGroupCombinesSinglePlanWithArchivedSplitHistory(t *testing.T) {
|
|
workspace := canonicalTempDir(t)
|
|
createSinglePlanPair(t, workspace, "m1")
|
|
completionRoot := filepath.Join(
|
|
workspace,
|
|
"agent-task",
|
|
"archive",
|
|
"2026",
|
|
"07",
|
|
"m-m1",
|
|
"26_done",
|
|
)
|
|
if err := os.MkdirAll(completionRoot, 0700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
writeTaskFile(
|
|
t,
|
|
filepath.Join(completionRoot, completeFileName),
|
|
"complete\n",
|
|
)
|
|
|
|
units, err := InspectTaskGroup(workspace, "m-m1")
|
|
if err != nil {
|
|
t.Fatalf("InspectTaskGroup: %v", err)
|
|
}
|
|
if len(units) != 2 ||
|
|
units[0].ID != "26" || !units[0].Completed ||
|
|
units[1].ID != "m-m1" || units[1].Completed {
|
|
t.Fatalf("units = %#v", units)
|
|
}
|
|
}
|
|
|
|
func TestWorkflowRejectsMixedActiveSingleAndSplitLayouts(t *testing.T) {
|
|
workspace := canonicalTempDir(t)
|
|
createSinglePlanPair(t, workspace, "m1")
|
|
createTaskPair(t, workspace, "m1", "01_split", false)
|
|
|
|
_, err := InspectTaskGroup(workspace, "m-m1")
|
|
if err == nil || !strings.Contains(err.Error(), "mixes a single-plan root pair with split-plan") {
|
|
t.Fatalf("InspectTaskGroup error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestWorkflowRejectsIncompleteSinglePlanPair(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
filename string
|
|
content string
|
|
wantErr string
|
|
}{
|
|
{"missing review", "PLAN-test.md", validPlan(), "exactly one active CODE_REVIEW"},
|
|
{"missing plan", "CODE_REVIEW-test.md", "# Review\n", "exactly one active PLAN"},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
workspace := canonicalTempDir(t)
|
|
taskRoot := filepath.Join(workspace, "agent-task", "m-m1")
|
|
if err := os.MkdirAll(taskRoot, 0700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
writeTaskFile(
|
|
t,
|
|
filepath.Join(taskRoot, test.filename),
|
|
test.content,
|
|
)
|
|
|
|
_, err := InspectTaskGroup(workspace, "m-m1")
|
|
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
|
|
t.Fatalf("InspectTaskGroup error = %v, want %q", err, test.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestWorkflowRejectsMalformedMissingAndAmbiguousArtifacts(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
mutate func(*testing.T, runtimeFixture)
|
|
wantErr string
|
|
}{
|
|
{
|
|
name: "missing review",
|
|
mutate: func(t *testing.T, fixture runtimeFixture) {
|
|
root := filepath.Join(fixture.projectA, "agent-task", "m-m1", "3_missing")
|
|
if err := os.MkdirAll(root, 0700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
writeTaskFile(t, filepath.Join(root, "PLAN-only.md"), validPlan())
|
|
},
|
|
wantErr: "exactly one active CODE_REVIEW",
|
|
},
|
|
{
|
|
name: "malformed plan",
|
|
mutate: func(t *testing.T, fixture runtimeFixture) {
|
|
writeTaskFile(
|
|
t,
|
|
filepath.Join(fixture.projectA, "agent-task", "m-m1", "1_first", "PLAN-test.md"),
|
|
"# Plan without a write set\n",
|
|
)
|
|
},
|
|
wantErr: "Modified Files Summary is missing",
|
|
},
|
|
{
|
|
name: "ambiguous identity",
|
|
mutate: func(t *testing.T, fixture runtimeFixture) {
|
|
createTaskPair(t, fixture.projectA, "m1", "1_duplicate", false)
|
|
},
|
|
wantErr: `work identity "1" is ambiguous`,
|
|
},
|
|
{
|
|
name: "missing explicit dependency",
|
|
mutate: func(t *testing.T, fixture runtimeFixture) {
|
|
createTaskPair(t, fixture.projectA, "m1", "3+missing_dependency", false)
|
|
},
|
|
wantErr: "explicit predecessor",
|
|
},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
fixture := newRuntimeFixture(t)
|
|
test.mutate(t, fixture)
|
|
if test.name == "missing explicit dependency" {
|
|
preview, err := fixture.runtime.Preview(context.Background(), "project-a")
|
|
if err != nil {
|
|
t.Fatalf("Preview: %v", err)
|
|
}
|
|
if preview.NextWork != "1" {
|
|
t.Fatalf("preview chose %q, want first independent work", preview.NextWork)
|
|
}
|
|
// Complete every earlier work so the missing predecessor is the
|
|
// only remaining scheduling explanation.
|
|
for _, task := range []string{"1_first", "2+1_second"} {
|
|
writeTaskFile(
|
|
t,
|
|
filepath.Join(fixture.projectA, "agent-task", "m-m1", task, completeFileName),
|
|
"complete\n",
|
|
)
|
|
}
|
|
preview, err = fixture.runtime.Preview(context.Background(), "project-a")
|
|
if err != nil {
|
|
t.Fatalf("Preview after completion: %v", err)
|
|
}
|
|
if len(preview.Blockers) != 1 ||
|
|
preview.Blockers[0].Code != agenttask.BlockerDependencyMissing ||
|
|
!strings.Contains(preview.Blockers[0].Message, test.wantErr) {
|
|
t.Fatalf("missing-dependency preview = %#v", preview)
|
|
}
|
|
return
|
|
}
|
|
workflow, err := NewWorkflow(fixture.snapshot, fixture.runtime.selections)
|
|
if err != nil {
|
|
t.Fatalf("NewWorkflow: %v", err)
|
|
}
|
|
_, err = workflow.Snapshot(context.Background(), "project-a")
|
|
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
|
|
t.Fatalf("Snapshot error = %v, want substring %q", err, test.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func validPlan() string {
|
|
return `# Plan
|
|
|
|
## Modified Files Summary
|
|
|
|
| File | Purpose |
|
|
|------|---------|
|
|
| ` + "`README.md`" + ` | Exercise workflow parsing. |
|
|
`
|
|
}
|
|
|
|
func createSinglePlanPair(t *testing.T, workspace, milestone string) {
|
|
t.Helper()
|
|
taskRoot := filepath.Join(workspace, "agent-task", "m-"+milestone)
|
|
if err := os.MkdirAll(taskRoot, 0700); err != nil {
|
|
t.Fatalf("create single-plan task group: %v", err)
|
|
}
|
|
writeTaskFile(t, filepath.Join(taskRoot, "PLAN-test.md"), validPlan())
|
|
writeTaskFile(t, filepath.Join(taskRoot, "CODE_REVIEW-test.md"), "# Code Review Reference\n")
|
|
}
|
|
|
|
func TestValidatePlanWriteSetMatrix(t *testing.T) {
|
|
workspace := t.TempDir()
|
|
writeTaskFile(t, filepath.Join(workspace, "existing.md"), "existing\n")
|
|
if err := os.Mkdir(filepath.Join(workspace, "directory"), 0700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
candidate := filepath.Join(t.TempDir(), "candidate.md")
|
|
plan := func(heading, rows string) string {
|
|
return fmt.Sprintf("# Plan\n\n## %s\n\n| File | Purpose |\n|---|---|\n%s\n", heading, rows)
|
|
}
|
|
cases := []struct {
|
|
name string
|
|
contents string
|
|
want []string
|
|
wantErr string
|
|
}{
|
|
{"relative", plan("Modified Files Summary", "| `new.md` | new |"), []string{"new.md"}, ""},
|
|
{"absolute with line suffix", plan("Modified Files Summary", fmt.Sprintf("| `%s:10:2` | existing |", filepath.Join(workspace, "existing.md"))), []string{"existing.md"}, ""},
|
|
{"legacy heading", plan("수정 파일 요약", "| `legacy.md` | legacy |"), []string{"legacy.md"}, ""},
|
|
{"missing section", "# Plan\n", nil, "Modified Files Summary is missing"},
|
|
{"duplicate section", plan("Modified Files Summary", "| `one.md` | one |") + plan("수정 파일 요약", "| `two.md` | two |"), nil, "appears more than once"},
|
|
{"empty claim", plan("Modified Files Summary", "| `` | empty |"), nil, "empty file path claim"},
|
|
{"glob", plan("Modified Files Summary", "| `*.md` | glob |"), nil, "contained literal"},
|
|
{"url", plan("Modified Files Summary", "| `https://example.com/plan.md` | url |"), nil, "URL claim"},
|
|
{"placeholder", plan("Modified Files Summary", "| `TODO.md` | placeholder |"), nil, "contained literal"},
|
|
{"backslash", plan("Modified Files Summary", "| `nested\\file.md` | malformed |"), nil, "contained literal"},
|
|
{"root", plan("Modified Files Summary", fmt.Sprintf("| `%s` | root |", workspace)), nil, "workspace root claim"},
|
|
{"outside", plan("Modified Files Summary", "| `../outside.md` | outside |"), nil, "outside the workspace"},
|
|
{"duplicate", plan("Modified Files Summary", "| `same.md` | one |\n| `same.md` | two |"), nil, "duplicated"},
|
|
{"directory", plan("Modified Files Summary", "| `directory` | directory |"), nil, "directory claim"},
|
|
}
|
|
for _, tt := range cases {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
writeTaskFile(t, candidate, tt.contents)
|
|
got, err := ValidatePlanWriteSet(workspace, candidate)
|
|
if tt.wantErr != "" {
|
|
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
|
t.Fatalf("ValidatePlanWriteSet error = %v, want %q", err, tt.wantErr)
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if strings.Join(got, ",") != strings.Join(tt.want, ",") {
|
|
t.Fatalf("write set = %#v, want %#v", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestValidatePlanWriteSetSymlinkContainment(t *testing.T) {
|
|
workspace := t.TempDir()
|
|
if err := os.MkdirAll(filepath.Join(workspace, "contained-target"), 0700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
external := t.TempDir()
|
|
cases := []struct {
|
|
name string
|
|
prepare func(t *testing.T)
|
|
claim string
|
|
want []string
|
|
wantErr string
|
|
}{
|
|
{
|
|
name: "contained broken leaf",
|
|
prepare: func(t *testing.T) {
|
|
if err := os.Symlink("contained-target/new.md", filepath.Join(workspace, "contained-link.md")); err != nil {
|
|
t.Skipf("symlink unavailable: %v", err)
|
|
}
|
|
},
|
|
claim: "contained-link.md", want: []string{"contained-target/new.md"},
|
|
},
|
|
{
|
|
name: "external broken leaf",
|
|
prepare: func(t *testing.T) {
|
|
if err := os.Symlink(filepath.Join(external, "missing.md"), filepath.Join(workspace, "external-link.md")); err != nil {
|
|
t.Skipf("symlink unavailable: %v", err)
|
|
}
|
|
},
|
|
claim: "external-link.md", wantErr: "outside the workspace",
|
|
},
|
|
{
|
|
name: "symlink loop",
|
|
prepare: func(t *testing.T) {
|
|
if err := os.Symlink("loop-b.md", filepath.Join(workspace, "loop-a.md")); err != nil {
|
|
t.Skipf("symlink unavailable: %v", err)
|
|
}
|
|
if err := os.Symlink("loop-a.md", filepath.Join(workspace, "loop-b.md")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
},
|
|
claim: "loop-a.md", wantErr: "cannot be canonicalized",
|
|
},
|
|
{name: "nested missing file", prepare: func(t *testing.T) {}, claim: "nested/new/file.md", want: []string{"nested/new/file.md"}},
|
|
}
|
|
for _, tt := range cases {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
tt.prepare(t)
|
|
planPath := filepath.Join(t.TempDir(), "candidate.md")
|
|
contents := fmt.Sprintf("# Plan\n\n## Modified Files Summary\n\n| File | Purpose |\n|---|---|\n| `%s` | test |\n", tt.claim)
|
|
writeTaskFile(t, planPath, contents)
|
|
got, err := ValidatePlanWriteSet(workspace, planPath)
|
|
if tt.wantErr != "" {
|
|
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
|
t.Fatalf("ValidatePlanWriteSet error = %v, want %q", err, tt.wantErr)
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if strings.Join(got, ",") != strings.Join(tt.want, ",") {
|
|
t.Fatalf("write set = %#v, want %#v", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestValidatePlanWriteSetDeepNonexistentTarget(t *testing.T) {
|
|
workspace := t.TempDir()
|
|
parts := make([]string, maxCandidateSymlinkResolutions+2)
|
|
for index := range parts[:len(parts)-1] {
|
|
parts[index] = "missing"
|
|
}
|
|
parts[len(parts)-1] = "new.md"
|
|
claim := strings.Join(parts, "/")
|
|
planPath := filepath.Join(t.TempDir(), "candidate.md")
|
|
contents := fmt.Sprintf("# Plan\n\n## Modified Files Summary\n\n| File | Purpose |\n|---|---|\n| `%s` | test |\n", claim)
|
|
writeTaskFile(t, planPath, contents)
|
|
|
|
got, err := ValidatePlanWriteSet(workspace, planPath)
|
|
if err != nil {
|
|
t.Fatalf("ValidatePlanWriteSet: %v", err)
|
|
}
|
|
if strings.Join(got, ",") != claim {
|
|
t.Fatalf("write set = %#v, want %q", got, claim)
|
|
}
|
|
}
|