package agents import ( "bytes" "encoding/json" "io" "os" "path/filepath" "testing" ) // TestWriteIfNotExistsCreatesAndSkips covers both the create and // skip branches of the helper plus the DryRun prediction. Golden // fixtures don't exercise DryRun, so we test it explicitly here. func TestWriteIfNotExistsCreatesAndSkips(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "nested", "file.txt") var buf bytes.Buffer // 1. First call creates the file. a, err := WriteIfNotExists(&buf, path, "hello", ApplyOpts{}) if err != nil { t.Fatalf("create: %v", err) } if a.Action != ActionCreate { t.Fatalf("expected create, got %q", a.Action) } got, err := os.ReadFile(path) if err != nil { t.Fatalf("read back: %v", err) } if string(got) != "hello" { t.Fatalf("content: got %q want %q", got, "hello") } // 2. Second call finds the file and skips. a, err = WriteIfNotExists(&buf, path, "different", ApplyOpts{}) if err != nil { t.Fatalf("skip: %v", err) } if a.Action != ActionSkip { t.Fatalf("expected skip, got %q", a.Action) } // Content must be unchanged — skip is never overwrite. got, _ = os.ReadFile(path) if string(got) != "hello" { t.Fatalf("skip must not overwrite: got %q", got) } // 3. DryRun on a missing file reports would-create, doesn't write. missing := filepath.Join(dir, "new.txt") a, err = WriteIfNotExists(&buf, missing, "x", ApplyOpts{DryRun: true}) if err != nil { t.Fatalf("dry-run: %v", err) } if a.Action != ActionWouldCreate { t.Fatalf("expected would-create, got %q", a.Action) } if _, err := os.Stat(missing); !os.IsNotExist(err) { t.Fatalf("dry-run wrote file: %v", err) } } // TestMergeJSONCreatesMergesAndSkipsIdempotent covers the three // transitions the MCP installer relies on: fresh file, merge into // existing, and no-op on re-run. This is the behavioural contract // golden tests will compare against byte-for-byte. // TestMergeJSON_DryRunNeverWritesBackup guards the regression where a // dry-run over a malformed (or empty) existing file still dropped a // .bak sibling on disk — dry-run must touch nothing. func TestMergeJSON_DryRunNeverWritesBackup(t *testing.T) { dir := t.TempDir() add := func(root map[string]any, _ bool) (bool, error) { return UpsertMCPServer(root, "gortex", DefaultGortexMCPEntry(), ApplyOpts{}), nil } // A malformed existing file under dry-run. mal := filepath.Join(dir, "malformed.json") if err := os.WriteFile(mal, []byte("{not json"), 0o644); err != nil { t.Fatal(err) } if _, err := MergeJSON(io.Discard, mal, add, ApplyOpts{DryRun: true}); err != nil { t.Fatal(err) } if _, err := os.Stat(mal + ".bak"); !os.IsNotExist(err) { t.Errorf("dry-run must not write a .bak (err=%v)", err) } // An empty file is treated as an empty object, not malformed: no // backup even on a real write. empty := filepath.Join(dir, "empty.json") if err := os.WriteFile(empty, []byte(""), 0o644); err != nil { t.Fatal(err) } if _, err := MergeJSON(io.Discard, empty, add, ApplyOpts{}); err != nil { t.Fatal(err) } if _, err := os.Stat(empty + ".bak"); !os.IsNotExist(err) { t.Errorf("an empty file must not be treated as malformed / backed up (err=%v)", err) } // A genuinely malformed file on the real write path still gets a .bak. mal2 := filepath.Join(dir, "malformed2.json") if err := os.WriteFile(mal2, []byte("garbage{"), 0o644); err != nil { t.Fatal(err) } if _, err := MergeJSON(io.Discard, mal2, add, ApplyOpts{}); err != nil { t.Fatal(err) } if _, err := os.Stat(mal2 + ".bak"); err != nil { t.Errorf("a real merge over a malformed file should keep a .bak: %v", err) } } func TestMergeJSONCreatesMergesAndSkipsIdempotent(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "mcp.json") var buf bytes.Buffer addGortex := func(root map[string]any, existed bool) (bool, error) { return UpsertMCPServer(root, "gortex", DefaultGortexMCPEntry(), ApplyOpts{}), nil } // 1. Missing file -> create. a, err := MergeJSON(&buf, path, addGortex, ApplyOpts{}) if err != nil { t.Fatalf("create: %v", err) } if a.Action != ActionCreate { t.Fatalf("expected create, got %q", a.Action) } // 2. Re-run -> skip (idempotent). a, err = MergeJSON(&buf, path, addGortex, ApplyOpts{}) if err != nil { t.Fatalf("skip: %v", err) } if a.Action != ActionSkip { t.Fatalf("expected skip, got %q", a.Action) } // 3. Pre-populate with an unrelated MCP server, merge adds ours // without clobbering theirs. existing := map[string]any{ "mcpServers": map[string]any{ "other": map[string]any{"command": "other"}, }, } data, _ := json.MarshalIndent(existing, "", " ") fresh := filepath.Join(dir, "mcp-pre.json") if err := os.WriteFile(fresh, data, 0o644); err != nil { t.Fatal(err) } a, err = MergeJSON(&buf, fresh, addGortex, ApplyOpts{}) if err != nil { t.Fatalf("merge: %v", err) } if a.Action != ActionMerge { t.Fatalf("expected merge, got %q", a.Action) } content, _ := os.ReadFile(fresh) var out map[string]any if err := json.Unmarshal(content, &out); err != nil { t.Fatal(err) } servers := out["mcpServers"].(map[string]any) if _, ok := servers["other"]; !ok { t.Fatalf("merge clobbered existing 'other' server: %v", servers) } if _, ok := servers["gortex"]; !ok { t.Fatalf("merge didn't add 'gortex': %v", servers) } } // TestStripJSONComments covers the JSONC sanitiser: comments and // trailing commas are removed, but `//`, `/* */`, and commas inside // string literals are preserved. func TestStripJSONComments(t *testing.T) { in := `{ // a line comment "url": "https://example.com/path", /* block */ "note": "a, b, c // not a comment", "list": [1, 2, 3,], "obj": { "k": "v", }, }` got := stripJSONComments([]byte(in)) var out map[string]any if err := json.Unmarshal(got, &out); err != nil { t.Fatalf("sanitised JSONC did not parse: %v\n---\n%s", err, got) } if out["url"] != "https://example.com/path" { t.Fatalf("`//` inside a string was stripped: %v", out["url"]) } if out["note"] != "a, b, c // not a comment" { t.Fatalf("comment/comma inside a string was altered: %v", out["note"]) } if list, ok := out["list"].([]any); !ok || len(list) != 3 { t.Fatalf("trailing comma handling broke the array: %v", out["list"]) } } // TestMergeJSON_JSONCMergesInsteadOfClobbering guards the OpenCode // path: merging into an existing `.jsonc` with comments must preserve // the user's data keys (not back the file up as malformed and start // fresh). func TestMergeJSON_JSONCMergesInsteadOfClobbering(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "opencode.jsonc") if err := os.WriteFile(path, []byte(`{ // user config "theme": "dark", }`), 0o644); err != nil { t.Fatal(err) } add := func(root map[string]any, _ bool) (bool, error) { root["added"] = true return true, nil } a, err := MergeJSON(io.Discard, path, add, ApplyOpts{}) if err != nil { t.Fatalf("merge: %v", err) } if a.Action != ActionMerge { t.Fatalf("expected merge, got %q", a.Action) } if _, err := os.Stat(path + ".bak"); err == nil { t.Fatalf("a valid commented .jsonc must not be backed up as malformed") } var out map[string]any data, _ := os.ReadFile(path) if err := json.Unmarshal(data, &out); err != nil { t.Fatalf("re-marshalled output not valid JSON: %v", err) } if out["theme"] != "dark" { t.Fatalf("merge dropped the user's data key: %v", out) } if out["added"] != true { t.Fatalf("merge didn't apply the mutation: %v", out) } } // TestRegistryFilterValidatesNames ensures we hard-error on typos // rather than silently dropping them — a key UX requirement from the // init plan. func TestRegistryFilterValidatesNames(t *testing.T) { r := NewRegistry() r.Register(stubAdapter{name: "alpha"}) r.Register(stubAdapter{name: "beta"}) if _, err := r.Filter("alpha,gamma", ""); err == nil { t.Fatal("expected error on unknown 'gamma', got nil") } got, err := r.Filter("alpha", "beta") if err != nil { t.Fatalf("filter: %v", err) } if len(got) != 1 || got[0].Name() != "alpha" { t.Fatalf("filter returned %v, want [alpha]", names(got)) } // auto + skip should yield everything minus the skipped. got, err = r.Filter("auto", "alpha") if err != nil { t.Fatalf("auto+skip: %v", err) } if len(got) != 1 || got[0].Name() != "beta" { t.Fatalf("auto+skip returned %v, want [beta]", names(got)) } } type stubAdapter struct{ name string } func (s stubAdapter) Name() string { return s.name } func (s stubAdapter) DocsURL() string { return "" } func (s stubAdapter) Detect(Env) (bool, error) { return true, nil } func (s stubAdapter) Plan(Env) (*Plan, error) { return &Plan{}, nil } func (s stubAdapter) Apply(Env, ApplyOpts) (*Result, error) { return &Result{Name: s.name}, nil } func names(as []Adapter) []string { out := make([]string, 0, len(as)) for _, a := range as { out = append(out, a.Name()) } return out } // TestAtomicWriteFileCreatesAndOverwrites guards the core write path every // MCP file tool funnels through (write_file, edit_file, move, ...): a // fresh write lands the content and a second write atomically replaces it, // leaving no stray *.gortex.tmp-* file behind. This is also the // no-contention happy path for renameWithRetry — its retry loop must be // transparent when the rename succeeds first try. func TestAtomicWriteFileCreatesAndOverwrites(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "sub", "file.txt") if err := AtomicWriteFile(path, []byte("first"), 0o644); err != nil { t.Fatalf("create: %v", err) } if got, _ := os.ReadFile(path); string(got) != "first" { t.Fatalf("content: got %q want %q", got, "first") } // Overwrite must replace, not append or corrupt. if err := AtomicWriteFile(path, []byte("second"), 0o644); err != nil { t.Fatalf("overwrite: %v", err) } if got, _ := os.ReadFile(path); string(got) != "second" { t.Fatalf("overwrite content: got %q want %q", got, "second") } // Success must not leave the sibling temp file behind. if leftovers, _ := filepath.Glob(filepath.Join(filepath.Dir(path), "*.gortex.tmp-*")); len(leftovers) != 0 { t.Fatalf("leftover temp files: %v", leftovers) } } // TestRenameWithRetryReturnsNonRetryableErr checks that a rename failure // which isn't a transient sharing violation (here: a missing source) is // surfaced immediately rather than retried — the retry budget is reserved // for the Windows lock race, not for masking genuine errors. On every // platform ERROR_FILE_NOT_FOUND / ENOENT is non-retryable, so this holds // cross-platform. func TestRenameWithRetryReturnsNonRetryableErr(t *testing.T) { dir := t.TempDir() if err := renameWithRetry(filepath.Join(dir, "does-not-exist"), filepath.Join(dir, "dest")); err == nil { t.Fatal("expected an error renaming a missing source, got nil") } }