Files
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

331 lines
11 KiB
Go

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")
}
}