package e2e import ( "encoding/json" "fmt" "os" "path/filepath" "sort" "testing" ) func buildTwentySkillUpstream(t *testing.T, h *harness) { t.Helper() for i := 1; i <= 20; i++ { h.write(t, fmt.Sprintf("skills/skill-%02d/SKILL.md", i), fmt.Sprintf("---\nname: Skill %02d\ndescription: s%d\n---\n# %02d\n", i, i, i)) } h.commitUpstream(t, "twenty skills") } type lockView struct { Version int `json:"version"` Sources map[string]struct { Rev string `json:"rev"` Renames map[string]string `json:"renames"` Selection struct { Mode string `json:"mode"` Exclude []string `json:"exclude"` Include []string `json:"include"` } `json:"selection"` Inventory map[string][]string `json:"inventory"` } `json:"sources"` } func readLock(t *testing.T, workDir string) lockView { t.Helper() data := readFile(t, filepath.Join(workDir, "agent-lib.lock.json")) var v lockView if err := json.Unmarshal([]byte(data), &v); err != nil { t.Fatalf("lockfile not parseable: %v", err) } return v } func externalSkillIDs(t *testing.T, workDir, name string) []string { t.Helper() dir := filepath.Join(workDir, "external", name, "skills") entries, err := os.ReadDir(dir) if err != nil { if os.IsNotExist(err) { return nil } t.Fatal(err) } var out []string for _, e := range entries { if e.IsDir() { out = append(out, e.Name()) } } sort.Strings(out) return out } func snapshotTree(t *testing.T, root string) string { t.Helper() var paths []string filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error { if err == nil { paths = append(paths, p) } return nil }) sort.Strings(paths) out := "" for _, p := range paths { data, err := os.ReadFile(p) if err == nil { out += p + ":" + fmt.Sprint(len(data)) + "\n" } else { out += p + ":dir\n" } } return out } func addWithInclude(t *testing.T, h *harness, firstFive string) { t.Helper() h.mustRun(t, "vendor", "add", "superpowers", h.upstreamDir, "--include", firstFive) } func TestVendorSelectAddGrowsSelection(t *testing.T) { h := newHarness(t) buildTwentySkillUpstream(t, h) addWithInclude(t, h, "skill-01,skill-02,skill-03,skill-04,skill-05") revBefore := readLock(t, h.workDir).Sources["superpowers"].Rev out := h.mustRun(t, "vendor", "select", "superpowers", "--add", "skill-06,skill-07,skill-08") if !contains(out, "skill-06 (added)") || !contains(out, "skill-08 (added)") { t.Errorf("report must list added items:\n%s", out) } got := externalSkillIDs(t, h.workDir, "superpowers") if len(got) != 8 { t.Errorf("external area must hold exactly 8 skills, got %d: %v", len(got), got) } v := readLock(t, h.workDir).Sources["superpowers"] if len(v.Selection.Include) != 8 { t.Errorf("include list must hold 8 ids, got %v", v.Selection.Include) } if v.Rev != revBefore { t.Error("select must never advance the pinned rev") } if out := h.mustRun(t, "validate"); contains(out, "invalid") { t.Errorf("validate must stay green:\n%s", out) } } func TestVendorSelectRemoveInIncludeMode(t *testing.T) { h := newHarness(t) buildTwentySkillUpstream(t, h) addWithInclude(t, h, "skill-01,skill-02,skill-03,skill-04,skill-05") out := h.mustRun(t, "vendor", "select", "superpowers", "--remove", "skill-02,skill-04") if !contains(out, "skill-02 (removed)") || !contains(out, "skill-04 (removed)") { t.Errorf("report must list removed items:\n%s", out) } got := externalSkillIDs(t, h.workDir, "superpowers") want := []string{"skill-01", "skill-03", "skill-05"} if fmt.Sprint(got) != fmt.Sprint(want) { t.Errorf("external skills = %v, want %v", got, want) } v := readLock(t, h.workDir).Sources["superpowers"] if fmt.Sprint(v.Selection.Include) != fmt.Sprint(want) { t.Errorf("include list = %v, want %v", v.Selection.Include, want) } lockPath := filepath.Join(h.workDir, "agent-lib.lock.json") before := readFile(t, lockPath) h.mustRun(t, "vendor", "select", "superpowers", "--add", "skill-01") if after := readFile(t, lockPath); after != before { t.Error("no-op select must leave the lockfile byte-identical") } } func TestVendorSelectRemoveInAllMode(t *testing.T) { h := newHarness(t) buildTwentySkillUpstream(t, h) h.mustRun(t, "vendor", "add", "superpowers", h.upstreamDir) h.mustRun(t, "vendor", "select", "superpowers", "--remove", "skill-03,skill-17") v := readLock(t, h.workDir).Sources["superpowers"] if v.Selection.Mode != "all" { t.Errorf("mode must stay all, got %q", v.Selection.Mode) } if fmt.Sprint(v.Selection.Exclude) != fmt.Sprint([]string{"skill-03", "skill-17"}) { t.Errorf("exclude list = %v", v.Selection.Exclude) } got := externalSkillIDs(t, h.workDir, "superpowers") if len(got) != 18 { t.Errorf("external area must hold 18 skills, got %d", len(got)) } for _, gone := range []string{"skill-03", "skill-17"} { if _, err := os.Stat(filepath.Join(h.workDir, "external", "superpowers", "skills", gone)); !os.IsNotExist(err) { t.Errorf("removed skill %s must be gone from the external area", gone) } } h.mustRun(t, "validate") } func TestVendorSelectModeAllResets(t *testing.T) { h := newHarness(t) buildTwentySkillUpstream(t, h) addWithInclude(t, h, "skill-01,skill-02,skill-03") out := h.mustRun(t, "vendor", "select", "superpowers", "--mode", "all") for _, id := range []string{"skill-04", "skill-20"} { if !contains(out, id+" (added)") { t.Errorf("report must list what arrived (missing %s):\n%s", id, out) } } v := readLock(t, h.workDir).Sources["superpowers"] if v.Selection.Mode != "all" || len(v.Selection.Include) > 0 || len(v.Selection.Exclude) > 0 { t.Errorf("mode all must clear include/exclude: %+v", v.Selection) } if got := externalSkillIDs(t, h.workDir, "superpowers"); len(got) != 20 { t.Errorf("all 20 skills must be materialized, got %d", len(got)) } h.mustRun(t, "validate") } func TestVendorSelectAddTypoIsHardError(t *testing.T) { h := newHarness(t) buildTwentySkillUpstream(t, h) addWithInclude(t, h, "skill-01,skill-02") lockPath := filepath.Join(h.workDir, "agent-lib.lock.json") lockBefore := readFile(t, lockPath) treeBefore := snapshotTree(t, filepath.Join(h.workDir, "external")) out, err := h.run(t, "vendor", "select", "superpowers", "--add", "skill-99") if err == nil { t.Fatalf("unknown id must be a hard error:\n%s", out) } if !contains(out, "skill-99") || !contains(out, "skill-01") { t.Errorf("error must name the missing id and list available ids:\n%s", out) } if readFile(t, lockPath) != lockBefore { t.Error("failed select must not mutate the lockfile") } if snapshotTree(t, filepath.Join(h.workDir, "external")) != treeBefore { t.Error("failed select must not mutate the external area") } } func TestVendorSelectRemoveTypoInIncludeMode(t *testing.T) { h := newHarness(t) buildTwentySkillUpstream(t, h) addWithInclude(t, h, "skill-01,skill-02") out, err := h.run(t, "vendor", "select", "superpowers", "--remove", "skill-09") if err == nil { t.Fatalf("--remove of an unselected id must be a hard error:\n%s", out) } if !contains(out, "not in the current selection") { t.Errorf("error must explain the id is not selected:\n%s", out) } } func TestVendorSelectAddInAllModeIsError(t *testing.T) { h := newHarness(t) buildTwentySkillUpstream(t, h) h.mustRun(t, "vendor", "add", "superpowers", h.upstreamDir) out, err := h.run(t, "vendor", "select", "superpowers", "--add", "skill-04") if err == nil { t.Fatalf("--add in all mode must be a hard error:\n%s", out) } if !contains(out, "already selected in all mode") { t.Errorf("error must explain that everything is selected in all mode:\n%s", out) } } func TestVendorSelectCollisionGuardAndRenameHonored(t *testing.T) { h := newHarness(t) buildTwentySkillUpstream(t, h) addWithInclude(t, h, "skill-01,skill-02") os.MkdirAll(filepath.Join(h.workDir, "skills", "skill-09"), 0o755) os.WriteFile(filepath.Join(h.workDir, "skills", "skill-09", "SKILL.md"), []byte("---\nname: Own Nine\n---\n"), 0o644) out, err := h.run(t, "vendor", "select", "superpowers", "--add", "skill-09") if err == nil { t.Fatalf("collision must abort the select:\n%s", out) } if !contains(out, "collides with own skills") { t.Errorf("error must name both parties:\n%s", out) } lockPath := filepath.Join(h.workDir, "agent-lib.lock.json") var raw map[string]any if err := json.Unmarshal([]byte(readFile(t, lockPath)), &raw); err != nil { t.Fatal(err) } src := raw["sources"].(map[string]any)["superpowers"].(map[string]any) src["renames"] = map[string]string{"skill-09": "nine-renamed"} data, _ := json.Marshal(raw) os.WriteFile(lockPath, data, 0o644) h.mustRun(t, "vendor", "select", "superpowers", "--add", "skill-09") if _, err := os.Stat(filepath.Join(h.workDir, "external", "superpowers", "skills", "nine-renamed", "SKILL.md")); err != nil { t.Error("renamed item must materialize under its deployed name") } v := readLock(t, h.workDir).Sources["superpowers"] if v.Renames["skill-09"] != "nine-renamed" { t.Errorf("rename map must survive select: %v", v.Renames) } if out := h.mustRun(t, "validate"); contains(out, "invalid") { t.Errorf("validate must stay green after rename select:\n%s", out) } } func TestVendorSelectModeAllRejectsCombinedFlags(t *testing.T) { h := newHarness(t) buildTwentySkillUpstream(t, h) addWithInclude(t, h, "skill-01") out, err := h.run(t, "vendor", "select", "superpowers", "--mode", "all", "--add", "skill-02") if err == nil { t.Fatalf("--mode all with --add must be rejected:\n%s", out) } if !contains(out, "cannot be combined") { t.Errorf("error must explain the flag conflict:\n%s", out) } } func TestVendorSelectNoFlagsIsError(t *testing.T) { h := newHarness(t) buildTwentySkillUpstream(t, h) addWithInclude(t, h, "skill-01") out, err := h.run(t, "vendor", "select", "superpowers") if err == nil { t.Fatalf("select without flags must be rejected:\n%s", out) } if !contains(out, "nothing to do") { t.Errorf("error must list the available actions:\n%s", out) } } func TestVendorSelectUnknownSourceAndUpstreamAdvance(t *testing.T) { h := newHarness(t) buildTwentySkillUpstream(t, h) addWithInclude(t, h, "skill-01,skill-02,skill-03,skill-04,skill-05") revPinned := readLock(t, h.workDir).Sources["superpowers"].Rev if out, err := h.run(t, "vendor", "select", "nope"); err == nil { t.Fatalf("unknown source must fail:\n%s", out) } else if !contains(out, "not in the lockfile") { t.Errorf("error must name the lockfile:\n%s", out) } h.write(t, "skills/skill-01/SKILL.md", "---\nname: Skill 01\ndescription: changed upstream\n---\n# changed\n") h.commitUpstream(t, "upstream moves on") h.mustRun(t, "vendor", "select", "superpowers", "--add", "skill-06") v := readLock(t, h.workDir).Sources["superpowers"] if v.Rev != revPinned { t.Error("select must keep the pinned rev even when upstream moved on") } changed, err := os.ReadFile(filepath.Join(h.workDir, "external", "superpowers", "skills", "skill-01", "SKILL.md")) if err != nil { t.Fatal(err) } if contains(string(changed), "changed upstream") { t.Error("select must materialize pinned content, not the ref head") } if out := h.mustRun(t, "validate"); contains(out, "invalid") { t.Errorf("validate must stay green:\n%s", out) } }