Files
wehub-resource-sync 26f897c1ec
release / release-please (push) Failing after 1m49s
docs / build (push) Failing after 6m34s
release / build-and-upload (arm64, linux) (push) Has been cancelled
release / build-and-upload (arm64, windows) (push) Has been cancelled
release / build-darwin (amd64, darwin) (push) Has been cancelled
release / checksums (push) Has been cancelled
release / finalize (push) Has been cancelled
release / build-darwin (arm64, darwin) (push) Has been cancelled
release / build-and-upload (amd64, linux) (push) Has been cancelled
release / build-and-upload (amd64, windows) (push) Has been cancelled
docs / deploy (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:34:16 +08:00

310 lines
11 KiB
Go

//go:build e2e
package e2e
import (
"context"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"github.com/kunchenguid/no-mistakes/internal/db"
"github.com/kunchenguid/no-mistakes/internal/git"
"github.com/kunchenguid/no-mistakes/internal/paths"
)
// TestInitIsIdempotent proves an existing user can re-run `init` to adopt new
// capabilities (the agent skill) without hitting an "already initialized"
// error, and that the second run reports the refresh and refreshes a stale
// user-level skill copy left by an older binary.
func TestInitIsIdempotent(t *testing.T) {
h := NewHarness(t, SetupOpts{Agent: "claude"})
first, err := h.RunInDir(h.WorkDir, "init")
if err != nil {
t.Fatalf("first init: %v\n%s", err, first)
}
if !strings.Contains(first, "Gate initialized") {
t.Errorf("first init should report a fresh gate, got:\n%s", first)
}
if strings.Contains(first, "no longer needed") {
t.Errorf("init in a repo without a vendored skill copy must not print the legacy notice, got:\n%s", first)
}
assertSkillInstalled(t, h)
// Overwrite the installed skill with stale content to prove the re-run
// refreshes the user-level copy.
skillPath := filepath.Join(h.HomeDir, ".claude", "skills", "no-mistakes", "SKILL.md")
if err := os.WriteFile(skillPath, []byte("---\nname: no-mistakes\n---\nstale body\n"), 0o644); err != nil {
t.Fatalf("write stale skill: %v", err)
}
second, err := h.RunInDir(h.WorkDir, "init")
if err != nil {
t.Fatalf("re-init should succeed: %v\n%s", err, second)
}
if !strings.Contains(second, "already initialized") {
t.Errorf("re-init should report an existing gate, got:\n%s", second)
}
if strings.Contains(second, "already initialized for") {
t.Errorf("re-init must not fail with the old error, got:\n%s", second)
}
assertSkillInstalled(t, h)
if data, err := os.ReadFile(skillPath); err != nil {
t.Fatalf("read skill after re-init: %v", err)
} else if strings.Contains(string(data), "stale body") {
t.Errorf("re-init must refresh a stale user-level skill copy")
}
// The no-mistakes remote must still be wired after the refresh.
if out, err := h.runGit(context.Background(), h.WorkDir, "remote", "get-url", "no-mistakes"); err != nil {
t.Fatalf("no-mistakes remote missing after re-init: %v\n%s", err, out)
}
}
// TestInitLegacyNotice proves init in a repo that still carries a vendored
// skill copy from an older no-mistakes version points it out without touching
// it: the copy is the user's to remove, possibly via their VCS.
//
// The test name is deliberately short for the same socket path length reason
// as TestInitRepoRename.
func TestInitLegacyNotice(t *testing.T) {
h := NewHarness(t, SetupOpts{Agent: "claude"})
legacy := filepath.Join(h.WorkDir, ".agents", "skills", "no-mistakes", "SKILL.md")
if err := os.MkdirAll(filepath.Dir(legacy), 0o755); err != nil {
t.Fatal(err)
}
legacyContent := "---\nname: no-mistakes\nmetadata:\n internal: true\n---\nlegacy vendored body\n"
if err := os.WriteFile(legacy, []byte(legacyContent), 0o644); err != nil {
t.Fatal(err)
}
out, err := h.RunInDir(h.WorkDir, "init")
if err != nil {
t.Fatalf("init: %v\n%s", err, out)
}
if !strings.Contains(out, "no longer needed") {
t.Errorf("init should notice the vendored legacy copy, got:\n%s", out)
}
if !strings.Contains(out, filepath.Join(".agents", "skills", "no-mistakes", "SKILL.md")) {
t.Errorf("the notice should name the vendored path, got:\n%s", out)
}
// The vendored copy must be left exactly as it was.
data, err := os.ReadFile(legacy)
if err != nil {
t.Fatalf("legacy copy must survive init: %v", err)
}
if string(data) != legacyContent {
t.Errorf("init must not modify the vendored copy")
}
}
// TestInitRepoRename proves that a user who renames or moves their repo
// directory can re-run `init` from the new location and get their existing
// gate back, instead of the historical failure:
//
// init: add remote: remote "no-mistakes" already exists with url "..."
//
// The test name is deliberately short: it becomes part of t.TempDir(), which
// hosts NM_HOME, and the daemon's Unix socket path under it must stay within
// the OS socket path limit (104 bytes on macOS).
func TestInitRepoRename(t *testing.T) {
h := NewHarness(t, SetupOpts{Agent: "claude"})
ctx := context.Background()
first, err := h.RunInDir(h.WorkDir, "init")
if err != nil {
t.Fatalf("first init: %v\n%s", err, first)
}
gateURLOut, err := h.runGit(ctx, h.WorkDir, "remote", "get-url", "no-mistakes")
if err != nil {
t.Fatalf("get gate url: %v\n%s", err, gateURLOut)
}
gateURL := strings.TrimSpace(string(gateURLOut))
renamed := filepath.Join(filepath.Dir(h.WorkDir), "work-renamed")
if err := os.Rename(h.WorkDir, renamed); err != nil {
t.Fatalf("rename work dir: %v", err)
}
second, err := h.RunInDir(renamed, "init")
if err != nil {
t.Fatalf("init after rename should succeed: %v\n%s", err, second)
}
if !strings.Contains(second, "already initialized") {
t.Errorf("init after rename should report an existing gate, got:\n%s", second)
}
// The repo must be reattached to the same gate, not a new one.
if out, err := h.runGit(ctx, renamed, "remote", "get-url", "no-mistakes"); err != nil {
t.Fatalf("no-mistakes remote missing after rename re-init: %v\n%s", err, out)
} else if url := strings.TrimSpace(string(out)); url != gateURL {
t.Errorf("gate url after rename = %q, want %q", url, gateURL)
}
}
// TestInitInSubmoduleRegistersSubmoduleOwnOrigin reproduces issue #328:
// `no-mistakes init` from inside a Git submodule checkout must register the
// gate against the submodule's own origin, not the superproject's. Before the
// fix, FindMainRepoRoot took the parent of --git-common-dir, which for an
// absorbed submodule is <super>/.git/modules/<name>. The gate then read its
// origin from that wrong root and recorded the superproject's URL as the
// upstream — a "push would land in the wrong repository" data-loss hazard.
func TestInitInSubmoduleRegistersSubmoduleOwnOrigin(t *testing.T) {
h := NewHarness(t, SetupOpts{Agent: "claude"})
ctx := context.Background()
// Second bare repo serving as the submodule's "origin", distinct from
// the superproject's upstream (h.UpstreamDir) so the assertion below can
// tell which one the gate recorded.
subUpstream := filepath.Join(filepath.Dir(h.UpstreamDir), "sub-upstream.git")
if err := os.MkdirAll(subUpstream, 0o755); err != nil {
t.Fatal(err)
}
if out, err := h.runGit(ctx, subUpstream, "init", "--bare", "--initial-branch=main"); err != nil {
t.Fatalf("init sub upstream: %v\n%s", err, out)
}
// Seed the submodule's upstream with a real commit so `submodule add`
// can clone and check out a branch. Without it, the empty-repo clone
// dies with "fatal: You are on a branch yet to be born".
subSeed := filepath.Join(t.TempDir(), "sub-seed")
if err := os.MkdirAll(subSeed, 0o755); err != nil {
t.Fatal(err)
}
if out, err := h.runGit(ctx, subSeed, "init", "--initial-branch=main"); err != nil {
t.Fatalf("init sub seed: %v\n%s", err, out)
}
if err := os.WriteFile(filepath.Join(subSeed, "README.md"), []byte("# submodule upstream\n"), 0o644); err != nil {
t.Fatal(err)
}
if out, err := h.runGit(ctx, subSeed, "add", "."); err != nil {
t.Fatalf("add sub seed: %v\n%s", err, out)
}
if out, err := h.runGit(ctx, subSeed, "commit", "-m", "initial"); err != nil {
t.Fatalf("commit sub seed: %v\n%s", err, out)
}
if out, err := h.runGit(ctx, subSeed, "remote", "add", "origin", subUpstream); err != nil {
t.Fatalf("remote add sub seed: %v\n%s", err, out)
}
if out, err := h.runGit(ctx, subSeed, "push", "-u", "origin", "main"); err != nil {
t.Fatalf("push sub seed: %v\n%s", err, out)
}
// Build the submodule in <work>/sub pointing at subUpstream.
if out, err := h.runGit(ctx, h.WorkDir, "-c", "protocol.file.allow=always", "submodule", "add", subUpstream, "sub"); err != nil {
t.Fatalf("submodule add: %v\n%s", err, out)
}
if out, err := h.runGit(ctx, h.WorkDir, "-c", "protocol.file.allow=always", "submodule", "init", "sub"); err != nil {
t.Fatalf("submodule init: %v\n%s", err, out)
}
if out, err := h.runGit(ctx, h.WorkDir, "-c", "protocol.file.allow=always", "submodule", "update", "sub"); err != nil {
t.Fatalf("submodule update: %v\n%s", err, out)
}
if out, err := h.runGit(ctx, h.WorkDir, "add", ".gitmodules", "sub"); err != nil {
t.Fatalf("git add submodule: %v\n%s", err, out)
}
if out, err := h.runGit(ctx, h.WorkDir, "commit", "-m", "add sub submodule"); err != nil {
t.Fatalf("commit submodule: %v\n%s", err, out)
}
subWorktree := filepath.Join(h.WorkDir, "sub")
// Run init from the submodule checkout. The pre-fix behavior writes the
// superproject's upstream into the gate row; the post-fix behavior must
// write the submodule's own.
if out, err := h.RunInDir(subWorktree, "init"); err != nil {
t.Fatalf("init in submodule: %v\n%s", err, out)
}
p := paths.WithRoot(h.NMHome)
d, err := db.Open(p.DB())
if err != nil {
t.Fatalf("open db: %v", err)
}
defer d.Close()
// Find the gate by its working path (the submodule's own working tree,
// not the superproject's). The pre-fix code stored the superproject path
// and never created a row for the submodule, so the by-path lookup
// returns nil; the post-fix code stores the submodule path and the
// recorded upstream must match the submodule's origin.
resolvedSub, _ := filepath.EvalSymlinks(subWorktree)
repo, err := d.GetRepoByPath(resolvedSub)
if err != nil {
t.Fatalf("get repo by path: %v", err)
}
if repo == nil {
t.Fatalf("no gate registered for submodule working tree %q; init must record the submodule's own path, not the superproject's", resolvedSub)
}
if repo.UpstreamURL != subUpstream {
t.Fatalf("gate upstream = %q, want %q (the submodule's own origin); recording the superproject's origin is the data-loss bug from issue #328", repo.UpstreamURL, subUpstream)
}
}
func TestInitRollsBackWhenDaemonStartFails(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Windows IPC does not use Unix socket path limits")
}
h := NewHarness(t, SetupOpts{Agent: "claude"})
badNMHome := filepath.Join(t.TempDir(), strings.Repeat("a", 160))
env := map[string]string{
"NM_HOME": badNMHome,
"NM_TEST_DAEMON_START_TIMEOUT": "200ms",
"NM_TEST_DAEMON_START_POLL_INTERVAL": "10ms",
}
start := time.Now()
out, err := h.RunInDirWithEnv(h.WorkDir, env, "init")
elapsed := time.Since(start)
if err == nil {
t.Fatal("init should fail when daemon startup fails")
}
if !strings.Contains(out, "start daemon") {
t.Fatalf("init output = %q, want daemon startup failure", out)
}
if strings.Contains(out, "rollback init:") {
t.Fatalf("rollback should succeed cleanly, got wrapped error output: %q", out)
}
if elapsed >= time.Second {
t.Fatalf("init rollback should fail fast in tests, took %v", elapsed)
}
ctx := context.Background()
if out, err := h.runGit(ctx, h.WorkDir, "remote", "get-url", "no-mistakes"); err == nil {
t.Fatalf("no-mistakes remote should be removed after failed init, got %q", out)
}
p := paths.WithRoot(badNMHome)
d, err := db.Open(p.DB())
if err != nil {
t.Fatal(err)
}
defer d.Close()
gitRoot, err := git.FindGitRoot(h.WorkDir)
if err != nil {
t.Fatal(err)
}
repo, err := d.GetRepoByPath(gitRoot)
if err != nil {
t.Fatal(err)
}
if repo != nil {
t.Fatal("repo record should be removed after failed init")
}
entries, err := os.ReadDir(p.ReposDir())
if err != nil {
t.Fatal(err)
}
if len(entries) != 0 {
t.Fatalf("expected no bare repos after failed init, found %d", len(entries))
}
}