Files
agent-lib/internal/e2e/update_test.go
T
m3ta-chiron a755d138da feat: vendor update/diff/remove with per-type change reports
- update advances the pinned rev, rewrites the external area via staging+swap,
  reports added/changed/removed per type; include mode holds its selection,
  vanished include entries abort loudly with the tree untouched
- all mode pulls new upstream items automatically; collision checks re-run on
  every update and honor the rename map
- diff is strictly read-only (temp dir outside the repo) and surfaces new
  upstream items as available-but-not-selected
- remove deletes external area + lockfile entry and prunes empty external root
- update --all reports per source and surfaces every failure
2026-08-22 22:01:06 +02:00

219 lines
8.0 KiB
Go

package e2e
import (
"os"
"path/filepath"
"testing"
"github.com/m3tam3re/agent-lib/internal/lockfile"
)
func bumpUpstream(h *harness, t *testing.T) {
t.Helper()
h.write(t, "skills/fresh-skill/SKILL.md", "---\nname: Fresh\n---\n# Fresh\n")
h.write(t, "skills/good-skill/SKILL.md", "---\nname: Good Skill\ndescription: changed upstream\n---\n# Changed\n")
os.RemoveAll(filepath.Join(h.upstreamDir, "skills/broken-skill"))
os.Remove(filepath.Join(h.upstreamDir, "agents/scout.md"))
h.commitUpstream(t, "fixture: add fresh, change good, remove broken+scout")
}
func TestVendorUpdateHappyPath(t *testing.T) {
h := newHarness(t)
standardFixture(h, t)
h.mustRun(t, "vendor", "add", "superpowers", h.upstreamDir)
oldLock := readFile(t, filepath.Join(h.workDir, lockfile.FileName))
bumpUpstream(h, t)
out := h.mustRun(t, "vendor", "update", "superpowers")
for _, want := range []string{"fresh-skill (added)", "good-skill (changed)", "broken-skill (removed)", "scout (removed)"} {
if !contains(out, want) {
t.Errorf("report missing %q:\n%s", want, out)
}
}
newSkill := filepath.Join(h.workDir, "external/superpowers/skills/fresh-skill/SKILL.md")
if _, err := os.Stat(newSkill); err != nil {
t.Error("added skill must be materialized")
}
if _, err := os.Stat(filepath.Join(h.workDir, "external/superpowers/skills/broken-skill")); err == nil {
t.Error("removed skill must be gone from the external area")
}
if _, err := os.Stat(filepath.Join(h.workDir, "external/superpowers/agents/scout.md")); err == nil {
t.Error("removed agent must be gone from the external area")
}
newLock := readFile(t, filepath.Join(h.workDir, lockfile.FileName))
if newLock == oldLock {
t.Fatal("lockfile must advance its rev")
}
if !contains(newLock, "fresh-skill") || contains(newLock, "broken-skill") {
t.Errorf("inventory must reflect the new selection:\n%s", newLock)
}
if out := h.mustRun(t, "validate"); !contains(out, "valid") {
t.Errorf("updated state must validate:\n%s", out)
}
}
func TestVendorUpdateIncludeModeHoldsSelection(t *testing.T) {
h := newHarness(t)
standardFixture(h, t)
h.mustRun(t, "vendor", "add", "curated", h.upstreamDir, "--include", "good-skill")
bumpUpstream(h, t)
diffOut := h.mustRun(t, "vendor", "diff", "curated")
if !contains(diffOut, "fresh-skill (available upstream, not selected)") {
t.Errorf("diff must surface new upstream items as available:\n%s", diffOut)
}
out := h.mustRun(t, "vendor", "update", "curated")
if contains(out, "fresh-skill (added)") {
t.Errorf("include mode must not pull new upstream items:\n%s", out)
}
if _, err := os.Stat(filepath.Join(h.workDir, "external/curated/skills/fresh-skill")); err == nil {
t.Error("fresh-skill must not be materialized in include mode")
}
if !contains(out, "good-skill (changed)") {
t.Errorf("included item changes must still arrive:\n%s", out)
}
}
func TestVendorUpdateVanishedIncludeFailsLoud(t *testing.T) {
h := newHarness(t)
standardFixture(h, t)
h.mustRun(t, "vendor", "add", "curated", h.upstreamDir, "--include", "good-skill")
lockBefore := readFile(t, filepath.Join(h.workDir, lockfile.FileName))
os.RemoveAll(filepath.Join(h.upstreamDir, "skills/good-skill"))
h.commitUpstream(t, "fixture: remove good-skill")
out, err := h.run(t, "vendor", "update", "curated")
if err == nil {
t.Fatalf("vanished include entry must fail the update:\n%s", out)
}
if !contains(out, "good-skill") || !contains(out, "not found upstream") {
t.Errorf("error must name the vanished entry:\n%s", out)
}
if got := readFile(t, filepath.Join(h.workDir, lockfile.FileName)); got != lockBefore {
t.Error("failed update must leave the lockfile untouched")
}
}
func TestVendorUpdateUpToDateIdempotent(t *testing.T) {
h := newHarness(t)
standardFixture(h, t)
h.mustRun(t, "vendor", "add", "superpowers", h.upstreamDir)
lockBefore := readFile(t, filepath.Join(h.workDir, lockfile.FileName))
out := h.mustRun(t, "vendor", "update", "superpowers")
if !contains(out, "already up to date") {
t.Errorf("unchanged upstream must report up-to-date:\n%s", out)
}
if got := readFile(t, filepath.Join(h.workDir, lockfile.FileName)); got != lockBefore {
t.Error("up-to-date update must not touch the lockfile")
}
}
func TestVendorDiffReadOnly(t *testing.T) {
h := newHarness(t)
standardFixture(h, t)
h.mustRun(t, "vendor", "add", "superpowers", h.upstreamDir)
lockBefore := readFile(t, filepath.Join(h.workDir, lockfile.FileName))
out := h.mustRun(t, "vendor", "diff", "superpowers")
if !contains(out, "up to date") {
t.Errorf("unchanged upstream diff:\n%s", out)
}
bumpUpstream(h, t)
out = h.mustRun(t, "vendor", "diff", "superpowers")
for _, want := range []string{"fresh-skill", "good-skill", "broken-skill"} {
if !contains(out, want) {
t.Errorf("diff must list %q:\n%s", want, out)
}
}
if got := readFile(t, filepath.Join(h.workDir, lockfile.FileName)); got != lockBefore {
t.Error("diff must not modify the lockfile")
}
if _, err := os.Stat(filepath.Join(h.workDir, "external/superpowers/skills/fresh-skill")); err == nil {
t.Error("diff must not materialize anything")
}
if _, err := os.Stat(filepath.Join(h.workDir, "external/superpowers/skills/broken-skill")); err != nil {
t.Error("diff must not remove anything")
}
}
func TestVendorRemoveLeavesNoResidue(t *testing.T) {
h := newHarness(t)
standardFixture(h, t)
h.mustRun(t, "vendor", "add", "superpowers", h.upstreamDir)
out := h.mustRun(t, "vendor", "remove", "superpowers")
if !contains(out, "removed") {
t.Errorf("remove must confirm:\n%s", out)
}
if _, err := os.Stat(filepath.Join(h.workDir, "external/superpowers")); err == nil {
t.Error("external area must be gone")
}
if _, err := os.Stat(filepath.Join(h.workDir, "external")); err == nil {
t.Error("empty external root must be cleaned up")
}
lock := readFile(t, filepath.Join(h.workDir, lockfile.FileName))
if contains(lock, "superpowers") {
t.Errorf("lockfile entry must be gone:\n%s", lock)
}
}
func TestVendorUpdateAll(t *testing.T) {
h := newHarness(t)
standardFixture(h, t)
h.mustRun(t, "vendor", "add", "alpha", h.upstreamDir)
h.mustRun(t, "vendor", "add", "beta", h.upstreamDir, "--include", "good-skill",
"--rename", "good-skill=beta-good")
bumpUpstream(h, t)
out := h.mustRun(t, "vendor", "update", "--all")
if !contains(out, "alpha") || !contains(out, "beta") {
t.Errorf("update --all must report per source:\n%s", out)
}
if _, err := os.Stat(filepath.Join(h.workDir, "external/alpha/skills/fresh-skill/SKILL.md")); err != nil {
t.Error("alpha (all mode) must receive fresh-skill")
}
if _, err := os.Stat(filepath.Join(h.workDir, "external/beta/skills/fresh-skill")); err == nil {
t.Error("beta (include mode) must not receive fresh-skill")
}
if _, err := os.Stat(filepath.Join(h.workDir, "external/beta/skills/beta-good/SKILL.md")); err != nil {
t.Error("beta rename must survive the update")
}
}
func TestVendorUpdateCollisionWithOwn(t *testing.T) {
h := newHarness(t)
standardFixture(h, t)
h.mustRun(t, "vendor", "add", "superpowers", h.upstreamDir)
lockBefore := readFile(t, filepath.Join(h.workDir, lockfile.FileName))
ownDir := filepath.Join(h.workDir, "skills", "fresh-skill")
if err := os.MkdirAll(ownDir, 0o755); err != nil {
t.Fatal(err)
}
os.WriteFile(filepath.Join(ownDir, "SKILL.md"), []byte("---\nname: My Own Fresh\n---\n"), 0o644)
h.write(t, "skills/fresh-skill/SKILL.md", "---\nname: Upstream Fresh\n---\n")
h.commitUpstream(t, "fixture: add fresh-skill that collides")
out, err := h.run(t, "vendor", "update", "superpowers")
if err == nil {
t.Fatalf("upstream collision with own item must fail:\n%s", out)
}
if !contains(out, "collides") || !contains(out, "own") {
t.Errorf("error must name both parties:\n%s", out)
}
if got := readFile(t, filepath.Join(h.workDir, lockfile.FileName)); got != lockBefore {
t.Error("failed update must not touch the lockfile")
}
if _, err := os.Stat(filepath.Join(h.workDir, "external/superpowers/skills/good-skill/SKILL.md")); err != nil {
t.Error("previous external area must survive the failed update")
}
}