328 lines
10 KiB
Go
328 lines
10 KiB
Go
package reconcile
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"hugin/cli/internal/bundle"
|
|
"hugin/cli/internal/lock"
|
|
)
|
|
|
|
// Goldene Bundles, die die Server-Tests erzeugen (contract/examples/bundles).
|
|
const examples = "../../../contract/examples/bundles"
|
|
|
|
func load(t *testing.T, name string) *bundle.Bundle {
|
|
t.Helper()
|
|
b, err := bundle.Read(filepath.Join(examples, name))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return b
|
|
}
|
|
|
|
func sync(t *testing.T, repo, name string, opt Options) *Plan {
|
|
t.Helper()
|
|
old, err := lock.Load(repo)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
plan, err := MakePlan(repo, load(t, name), old, opt)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !plan.Open() {
|
|
if err := plan.Run(repo); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
return plan
|
|
}
|
|
|
|
func read(t *testing.T, repo, rel string) string {
|
|
t.Helper()
|
|
raw, err := os.ReadFile(filepath.Join(repo, rel))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return string(raw)
|
|
}
|
|
|
|
func write(t *testing.T, repo, rel, content string) {
|
|
t.Helper()
|
|
abs := filepath.Join(repo, rel)
|
|
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(abs, []byte(content), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func exists(repo, rel string) bool {
|
|
_, err := os.Stat(filepath.Join(repo, rel))
|
|
return err == nil
|
|
}
|
|
|
|
func jsonValue(t *testing.T, repo, rel string) map[string]any {
|
|
t.Helper()
|
|
var value map[string]any
|
|
if err := json.Unmarshal([]byte(read(t, repo, rel)), &value); err != nil {
|
|
t.Fatalf("%s: %v", rel, err)
|
|
}
|
|
return value
|
|
}
|
|
|
|
func TestFirstInstallInEmptyRepo(t *testing.T) {
|
|
repo := t.TempDir()
|
|
plan := sync(t, repo, "first-install", Options{})
|
|
if len(plan.Findings) > 0 {
|
|
t.Fatalf("Befunde: %v", plan.Findings)
|
|
}
|
|
for _, rel := range []string{
|
|
".claude/skills/pub-munin-nutzen/SKILL.md",
|
|
".claude/skills/pub-munin-nutzen/referenz/beispiele.md",
|
|
".hugin/rules/pub-hausregeln.md",
|
|
"CLAUDE.md", ".mcp.json", "opencode.json", "hugin.lock",
|
|
} {
|
|
if !exists(repo, rel) {
|
|
t.Errorf("%s fehlt", rel)
|
|
}
|
|
}
|
|
claudeMd := read(t, repo, "CLAUDE.md")
|
|
if !strings.Contains(claudeMd, "<!-- hugin:begin") || !strings.Contains(claudeMd, "@.hugin/rules/pub-hausregeln.md\n<!-- hugin:end -->") {
|
|
t.Errorf("CLAUDE.md ohne Block:\n%s", claudeMd)
|
|
}
|
|
mcp := jsonValue(t, repo, ".mcp.json")["mcpServers"].(map[string]any)["munin"].(map[string]any)
|
|
if mcp["type"] != "http" {
|
|
t.Errorf(".mcp.json: %v", mcp)
|
|
}
|
|
oc := jsonValue(t, repo, "opencode.json")
|
|
if oc["instructions"].([]any)[0] != ".hugin/rules/*.md" {
|
|
t.Errorf("opencode.json: %v", oc)
|
|
}
|
|
|
|
// Ein zweiter sync mit demselben Bundle ändert nichts
|
|
if plan := sync(t, repo, "first-install", Options{}); plan.Changed() {
|
|
t.Errorf("zweiter sync ändert etwas: schreiben %v, löschen %v", plan.Write, plan.Delete)
|
|
}
|
|
}
|
|
|
|
func TestUpdateCleansUp(t *testing.T) {
|
|
repo := t.TempDir()
|
|
sync(t, repo, "first-install", Options{})
|
|
plan := sync(t, repo, "update", Options{})
|
|
if len(plan.Findings) > 0 {
|
|
t.Fatalf("Befunde: %v", plan.Findings)
|
|
}
|
|
for _, remove := range []string{
|
|
".claude/skills/pub-munin-nutzen/referenz",
|
|
".hugin/rules/pub-hausregeln.md",
|
|
".hugin",
|
|
"CLAUDE.md", // von Hugin angelegt und jetzt leer
|
|
} {
|
|
if exists(repo, remove) {
|
|
t.Errorf("%s sollte entfernt sein", remove)
|
|
}
|
|
}
|
|
if !strings.Contains(read(t, repo, ".claude/skills/pub-munin-nutzen/SKILL.md"), "pub-munin-nutzen 1.5.0") {
|
|
t.Error("SKILL.md nicht aktualisiert")
|
|
}
|
|
oc := jsonValue(t, repo, "opencode.json")
|
|
if _, ok := oc["mcp"]; !ok {
|
|
t.Error("MCP-Eintrag fehlt")
|
|
}
|
|
if _, ok := oc["instructions"]; ok {
|
|
t.Errorf("leere instructions nicht entfernt: %v", oc["instructions"])
|
|
}
|
|
l, _ := lock.Load(repo)
|
|
if _, ok := l.Packages["pub-hausregeln"]; ok {
|
|
t.Error("pub-hausregeln noch in hugin.lock")
|
|
}
|
|
if l.Packages["pub-munin-nutzen"].Version != "1.5.0" {
|
|
t.Errorf("Version in hugin.lock: %v", l.Packages["pub-munin-nutzen"])
|
|
}
|
|
}
|
|
|
|
func TestTeamFilesArePreserved(t *testing.T) {
|
|
repo := t.TempDir()
|
|
write(t, repo, "CLAUDE.md", "# Unser Projekt\r\n\r\nWir nutzen Go.\r\n")
|
|
write(t, repo, "opencode.json", "{\n // unser Modell\n \"model\": \"anthropic/claude-sonnet-5-5\",\n \"instructions\": [\"docs/stil.md\"]\n}\n")
|
|
|
|
sync(t, repo, "first-install", Options{})
|
|
claudeMd := read(t, repo, "CLAUDE.md")
|
|
if !strings.HasPrefix(claudeMd, "# Unser Projekt\r\n\r\nWir nutzen Go.\r\n\r\n<!-- hugin:begin") {
|
|
t.Errorf("CLAUDE.md:\n%q", claudeMd)
|
|
}
|
|
if strings.Contains(strings.ReplaceAll(claudeMd, "\r\n", ""), "\n") {
|
|
t.Error("CLAUDE.md: Zeilenenden gemischt")
|
|
}
|
|
oc := read(t, repo, "opencode.json")
|
|
if !strings.Contains(oc, "// unser Modell") || !strings.Contains(oc, `"docs/stil.md"`) {
|
|
t.Errorf("opencode.json verändert:\n%s", oc)
|
|
}
|
|
|
|
sync(t, repo, "update", Options{})
|
|
if got := read(t, repo, "CLAUDE.md"); got != "# Unser Projekt\r\n\r\nWir nutzen Go.\r\n" {
|
|
t.Errorf("CLAUDE.md nach Abbestellen:\n%q", got)
|
|
}
|
|
oc = read(t, repo, "opencode.json")
|
|
if !strings.Contains(oc, `"docs/stil.md"`) || strings.Contains(oc, ".hugin/rules") {
|
|
t.Errorf("opencode.json nach Abbestellen:\n%s", oc)
|
|
}
|
|
}
|
|
|
|
func TestManualChangeIsDetected(t *testing.T) {
|
|
repo := t.TempDir()
|
|
sync(t, repo, "first-install", Options{})
|
|
skill := ".claude/skills/pub-munin-nutzen/SKILL.md"
|
|
write(t, repo, skill, "von Hand\n")
|
|
|
|
plan := sync(t, repo, "update", Options{})
|
|
if !plan.Open() || len(plan.Findings) != 1 || plan.Findings[0].Kind != ManualChange || plan.Findings[0].Location != skill {
|
|
t.Fatalf("Befunde: %v", plan.Findings)
|
|
}
|
|
if read(t, repo, skill) != "von Hand\n" {
|
|
t.Error("Datei trotz Befund geändert")
|
|
}
|
|
if !exists(repo, ".hugin/rules/pub-hausregeln.md") {
|
|
t.Error("andere Dateien trotz Befund geändert")
|
|
}
|
|
|
|
sync(t, repo, "update", Options{Overwrite: true})
|
|
if !strings.Contains(read(t, repo, skill), "1.5.0") {
|
|
t.Error("--overwrite setzt nicht zurück")
|
|
}
|
|
}
|
|
|
|
func TestManualChangeInBlock(t *testing.T) {
|
|
repo := t.TempDir()
|
|
sync(t, repo, "first-install", Options{})
|
|
text := strings.Replace(read(t, repo, "CLAUDE.md"), "@.hugin", "@extra.md\n@.hugin", 1)
|
|
write(t, repo, "CLAUDE.md", text)
|
|
if plan := sync(t, repo, "first-install", Options{}); len(plan.Findings) != 1 || plan.Findings[0].Location != "CLAUDE.md#hugin" {
|
|
t.Fatalf("Befunde: %v", plan.Findings)
|
|
}
|
|
}
|
|
|
|
func TestForeignMcpServerIsConflict(t *testing.T) {
|
|
repo := t.TempDir()
|
|
write(t, repo, ".mcp.json", `{"mcpServers": {"munin": {"type": "http", "url": "https://anders"}}}`)
|
|
plan := sync(t, repo, "first-install", Options{})
|
|
if len(plan.Findings) != 1 || plan.Findings[0].Kind != Conflict {
|
|
t.Fatalf("Befunde: %v", plan.Findings)
|
|
}
|
|
if exists(repo, "hugin.lock") {
|
|
t.Error("trotz Konflikt geschrieben")
|
|
}
|
|
}
|
|
|
|
func TestForeignFileIsConflict(t *testing.T) {
|
|
repo := t.TempDir()
|
|
write(t, repo, ".claude/skills/pub-munin-nutzen/SKILL.md", "eigener Skill\n")
|
|
plan := sync(t, repo, "first-install", Options{})
|
|
if len(plan.Findings) != 1 || plan.Findings[0].Kind != Conflict {
|
|
t.Fatalf("Befunde: %v", plan.Findings)
|
|
}
|
|
}
|
|
|
|
func TestSymlinkInRepoIsRejected(t *testing.T) {
|
|
repo := t.TempDir()
|
|
outside := t.TempDir()
|
|
if err := os.Symlink(outside, filepath.Join(repo, ".claude")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := MakePlan(repo, load(t, "first-install"), lock.New(), Options{}); err == nil {
|
|
t.Fatal("Symlink nicht erkannt")
|
|
}
|
|
}
|
|
|
|
func TestBlockSet(t *testing.T) {
|
|
tests := []struct{ name, text, content, expected string }{
|
|
{"leere Datei", "", "a\n", beginMarker("hugin") + "\na\n" + endMarker("hugin") + "\n"},
|
|
{"anhängen", "x\n", "a\n", "x\n\n" + beginMarker("hugin") + "\na\n" + endMarker("hugin") + "\n"},
|
|
{"replace", "x\n\n" + beginMarker("hugin") + "\nalt\n" + endMarker("hugin") + "\ny\n", "a\nb\n",
|
|
"x\n\n" + beginMarker("hugin") + "\na\nb\n" + endMarker("hugin") + "\ny\n"},
|
|
{"remove", "x\n\n" + beginMarker("hugin") + "\nalt\n" + endMarker("hugin") + "\n", "", "x\n"},
|
|
{"entfernen ohne Block", "x\n", "", "x\n"},
|
|
{"nur Block entfernen", beginMarker("hugin") + "\nalt\n" + endMarker("hugin") + "\n", "", ""},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := setBlock(tt.text, "hugin", tt.content); got != tt.expected {
|
|
t.Errorf("\n got %q\nwant %q", got, tt.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCommandsAndAgents(t *testing.T) {
|
|
repo := t.TempDir()
|
|
if plan := sync(t, repo, "commands-and-agents", Options{}); len(plan.Findings) > 0 {
|
|
t.Fatalf("Befunde: %v", plan.Findings)
|
|
}
|
|
for _, rel := range []string{
|
|
".claude/commands/pub-release-notiz.md", ".opencode/commands/pub-release-notiz.md",
|
|
".claude/agents/pub-code-review.md", ".opencode/agents/pub-code-review.md",
|
|
} {
|
|
if !exists(repo, rel) {
|
|
t.Errorf("%s fehlt", rel)
|
|
}
|
|
}
|
|
if !strings.Contains(read(t, repo, ".opencode/commands/pub-release-notiz.md"), "Version $1, gedacht für $2") {
|
|
t.Error("Platzhalter für OpenCode nicht übersetzt")
|
|
}
|
|
}
|
|
|
|
func TestEmptyBundleCleansUpEverything(t *testing.T) {
|
|
repo := t.TempDir()
|
|
write(t, repo, "opencode.json", "{\n \"model\": \"x\"\n}\n")
|
|
sync(t, repo, "first-install", Options{})
|
|
old, _ := lock.Load(repo)
|
|
plan, err := MakePlan(repo, bundle.Empty(), old, Options{})
|
|
if err != nil || len(plan.Findings) > 0 {
|
|
t.Fatalf("%v %v", err, plan.Findings)
|
|
}
|
|
if err := plan.Run(repo); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, remove := range []string{".claude", ".hugin", "CLAUDE.md", ".mcp.json"} {
|
|
if exists(repo, remove) {
|
|
t.Errorf("%s ist noch da", remove)
|
|
}
|
|
}
|
|
if got := jsonValue(t, repo, "opencode.json"); len(got) != 1 || got["model"] != "x" {
|
|
t.Errorf("opencode.json: %v", got)
|
|
}
|
|
}
|
|
|
|
func TestNewOpencodeJsonHasSchemaAndIsCleanedUp(t *testing.T) {
|
|
repo := t.TempDir()
|
|
sync(t, repo, "first-install", Options{})
|
|
if got := jsonValue(t, repo, "opencode.json")["$schema"]; got != OpenCodeSchema {
|
|
t.Errorf("$schema in neuer opencode.json: %v", got)
|
|
}
|
|
// Von hugin angelegt und nur noch $schema übrig: wird wieder entfernt
|
|
old, _ := lock.Load(repo)
|
|
plan, err := MakePlan(repo, bundle.Empty(), old, Options{})
|
|
if err != nil || len(plan.Findings) > 0 {
|
|
t.Fatalf("%v %v", err, plan.Findings)
|
|
}
|
|
if err := plan.Run(repo); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if exists(repo, "opencode.json") {
|
|
t.Errorf("opencode.json ist noch da:\n%s", read(t, repo, "opencode.json"))
|
|
}
|
|
}
|
|
|
|
func TestExistingOpencodeJsonGetsNoSchema(t *testing.T) {
|
|
repo := t.TempDir()
|
|
write(t, repo, "opencode.json", "{\n \"model\": \"x\"\n}\n")
|
|
sync(t, repo, "first-install", Options{})
|
|
if _, ok := jsonValue(t, repo, "opencode.json")["$schema"]; ok {
|
|
t.Error("hugin schreibt $schema nur in Dateien, die es selbst anlegt")
|
|
}
|
|
}
|