From 043e1001630bb9a56e0a10007f9550deca25447b Mon Sep 17 00:00:00 2001 From: dipto0321 Date: Mon, 29 Jun 2026 13:14:55 +0600 Subject: [PATCH] feat(detector): implement NVM detection with mocked tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NVM is a shell function, not a binary, so the implementation needs a hybrid strategy per nodeup.md §5: - Detect : check $NVM_DIR or ~/.nvm/nvm.sh existence (cheap, no subprocess) - ListInstalled: read ~/.nvm/versions/node/* directory entries directly (Strategy C, no subprocess) - Version : source nvm.sh via platform.RunShellScript and run nvm --version (Strategy A) Mutation methods (Install, Uninstall, Use, SetDefault, GlobalNpmPrefix) return a new ErrNVMNotImplemented sentinel, matching the FNM pattern. Tests cover 23 cases on two package-level seams: - var listDir = os.ReadDir (filesystem) - var runScript = platform.RunShellScript (shell) plus a withStubNVMScript helper that writes a real nvm.sh file to a temp dir for Detect() tests, which avoids the complexity of stubbing os.Stat. Live probe against a real ~/.nvm install (nvm 0.40.5) confirms: - Detect: true - Version: 0.40.5 - Installed: [16.19.0 18.14.0 19.6.0] (sorted asc) --- internal/detector/nvm.go | 249 +++++++++++++++++-- internal/detector/nvm_test.go | 447 ++++++++++++++++++++++++++++++++++ 2 files changed, 680 insertions(+), 16 deletions(-) create mode 100644 internal/detector/nvm_test.go diff --git a/internal/detector/nvm.go b/internal/detector/nvm.go index a2394f1..8f33a36 100644 --- a/internal/detector/nvm.go +++ b/internal/detector/nvm.go @@ -1,30 +1,247 @@ package detector -import "github.com/Masterminds/semver/v3" +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/Masterminds/semver/v3" + + "github.com/dipto0321/nodeup/internal/platform" +) // NVM is the Node Version Manager implementation. nvm is unusual because -// it is a SHELL FUNCTION, not a binary. See nodeup.md §5 "The nvm -// Special Case" for the three strategies we use. +// it is a SHELL FUNCTION, not a binary. See nodeup.md §5 "The nvm Special +// Case" for the three strategies we use. +// +// Strategy C is used for reads (parse ~/.nvm/versions/node/* directly). +// For mutating operations (install, uninstall, use) we will fall back to +// Strategy A in a later phase: `bash -c "source ~/.nvm/nvm.sh && nvm "`. // -// Strategy C is preferred for read operations (parse ~/.nvm/versions/node/* -// directly). For mutating operations (install, uninstall, use) we fall -// back to Strategy A: `bash -c "source ~/.nvm/nvm.sh && nvm "`. +// Phase 1 implements the detection surface only: +// - Detect : $NVM_DIR env var OR ~/.nvm/nvm.sh existence +// - Version : source nvm.sh, then `nvm --version` (Strategy A, +// invoked through platform.RunShellScript so we get the +// right shell per OS) +// - ListInstalled: read ~/.nvm/versions/node/* entries (Strategy C, no +// subprocess — fastest, deterministic, easy to test) // -// Implementation status: stub. Real implementation lands in Phase 1. +// Mutation methods (Install, Uninstall, Use, SetDefault, GlobalNpmPrefix) +// return an explicit "not implemented" error so callers can detect them +// at runtime instead of getting a silent zero-value result. type NVM struct{} -// NewNVM constructs a fresh nvm detector. +// NewNVM constructs a fresh nvm detector. Returned by pointer so it +// satisfies the Manager interface uniformly with the other detectors. func NewNVM() *NVM { return &NVM{} } func (n *NVM) Name() string { return "nvm" } -func (n *NVM) Detect() bool { return false } -func (n *NVM) Version() (string, error) { return "", nil } -func (n *NVM) ListInstalled() ([]semver.Version, error) { return nil, nil } -func (n *NVM) Install(v semver.Version) error { return nil } -func (n *NVM) Uninstall(v semver.Version) error { return nil } -func (n *NVM) Use(v semver.Version) error { return nil } -func (n *NVM) SetDefault(v semver.Version) error { return nil } +// listDir is the package-level seam used by NVM to enumerate +// ~/.nvm/versions/node. Tests overwrite it to return canned DirEntry +// slices without touching the real filesystem. Production code never +// reassigns it. +// +// Signature matches os.ReadDir so a direct assignment works. +var listDir = os.ReadDir + +// runScript is the package-level seam used by NVM to invoke shell scripts +// (specifically: source ~/.nvm/nvm.sh && nvm --version). Tests overwrite +// it to capture the script and return canned output without spawning a +// subprocess. Production code never reassigns it. +// +// Signature matches platform.RunShellScript so a direct assignment works. +var runScript = platform.RunShellScript + +// ErrNVMNotImplemented is returned by NVM mutation methods that have not +// yet been implemented in Phase 1 (Install, Uninstall, Use, SetDefault, +// GlobalNpmPrefix). Returning this error instead of a zero value lets +// callers distinguish "I haven't done it yet" from "user passed a bad +// version" via errors.Is. +var ErrNVMNotImplemented = errors.New("nvm mutation commands not yet implemented") + +// nvmDir returns the nvm install root. Resolution order: +// 1. $NVM_DIR environment variable (the official override) +// 2. ~/.nvm (the documented default) +// +// Returns "" if neither can be resolved (e.g., HOME unset on a stripped- +// down CI runner). Callers must treat "" as "nvm not installed". +func nvmDir() string { + if d := strings.TrimSpace(os.Getenv("NVM_DIR")); d != "" { + return d + } + home, err := os.UserHomeDir() + if err != nil { + return "" + } + return filepath.Join(home, ".nvm") +} + +// nvmScriptPath returns the absolute path to nvm.sh inside the install +// root. Returns "" if the install root can't be resolved. +func nvmScriptPath() string { + dir := nvmDir() + if dir == "" { + return "" + } + return filepath.Join(dir, "nvm.sh") +} + +// Detect returns true when nvm appears to be installed. nvm is a shell +// function, so we check for the script it gets sourced from rather than +// for an executable on PATH. We accept either $NVM_DIR pointing at a +// directory (faster — stat only) or the conventional ~/.nvm/nvm.sh +// location. +// +// Per the Manager contract, Detect MUST be cheap — this implementation +// never spawns a subprocess. +func (n *NVM) Detect() bool { + script := nvmScriptPath() + if script == "" { + return false + } + // os.Stat returns an error for missing files; we collapse both "not + // found" and "permission denied" into a false result so that an + // unreadable nvm install is treated as "not present" rather than a + // hard error from Detect. RunShellScript will surface the real + // reason when the user actually tries to invoke nvm. + _, err := os.Stat(script) + return err == nil +} + +// Version returns nvm's own version string, e.g. "0.40.5". nvm only +// exists as a shell function, so we have to source nvm.sh before calling +// it. We delegate the shell choice to platform.RunShellScript — on unix +// it prefers bash (nvm.sh is bash-only), on Windows it uses cmd.exe +// (where nvm is uncommon; this still gives a sensible error rather than +// a hang). +// +// We quote the script path via platform.QuotePath so Windows profiles +// like "C:\Program Files\..." don't get word-split. +func (n *NVM) Version() (string, error) { + script := nvmScriptPath() + if script == "" { + return "", errors.New("nvm not detected: cannot resolve nvm.sh path") + } + // `source` is bash syntax. RunShellScript on unix selects bash + // first, so this is portable on macOS / Linux. On Windows the + // cmd.exe branch will fail with a syntax error, which is the + // expected outcome — nvm-windows is a separate manager. + cmd := fmt.Sprintf("source %s && nvm --version", platform.QuotePath(script)) + res, err := runScript(context.Background(), cmd) + if err != nil { + return "", fmt.Errorf("nvm --version: %w", err) + } + return parseNVMVersion(res.Stdout) +} + +// parseNVMVersion extracts the version token from nvm's --version output. +// +// Observed real output (nvm 0.40.5): +// +// 0.40.5 +// +// Older nvm versions printed `nvm 0.x.y` — we accept both. Leading +// whitespace and a trailing newline are trimmed. +func parseNVMVersion(stdout string) (string, error) { + out := strings.TrimSpace(stdout) + if out == "" { + return "", errors.New("nvm --version returned empty output") + } + fields := strings.Fields(out) + if len(fields) == 0 { + return "", errors.New("nvm --version returned no tokens") + } + // If the first token is literally "nvm" take the next one. + if fields[0] == "nvm" && len(fields) >= 2 { + return strings.TrimSpace(fields[1]), nil + } + // Otherwise assume the whole first token is the version. + return strings.TrimSpace(fields[0]), nil +} + +// ListInstalled returns every Node.js version nvm has installed, sorted +// ascending. Source: directory entries under /versions/node/. +// +// Each subdirectory of that directory is a full Node install. nvm names +// them like "v18.14.0" (with v prefix), but we accept both with and +// without — semver.NewVersion normalizes. +// +// Non-directory entries (e.g. the "lts" symlink that some nvm versions +// drop here) are skipped. The literal name "system" is a sentinel for +// the system Node and not a managed install, so we skip it too. +func (n *NVM) ListInstalled() ([]semver.Version, error) { + dir := nvmDir() + if dir == "" { + return nil, errors.New("nvm not detected: cannot resolve NVM_DIR or ~/.nvm") + } + versionsDir := filepath.Join(dir, "versions", "node") + entries, err := listDir(versionsDir) + if err != nil { + if os.IsNotExist(err) { + // nvm is installed but has never installed a Node version — + // return an empty (non-nil) slice, not an error. Callers + // distinguish this from "nvm not installed" via Detect(). + return []semver.Version{}, nil + } + return nil, fmt.Errorf("read %s: %w", versionsDir, err) + } + return parseNVMInstalledEntries(entries) +} + +// parseNVMInstalledEntries turns a list of directory entries under +// ~/.nvm/versions/node into a sorted-ascending []semver.Version. +// Exposed (lowercase) for direct unit testing. +// +// Returns a non-nil empty slice when no parseable versions are present +// — callers rely on this for "nvm installed, nothing managed yet". +func parseNVMInstalledEntries(entries []os.DirEntry) ([]semver.Version, error) { + versions := make([]semver.Version, 0) + for _, e := range entries { + name := e.Name() + // Skip "system" sentinel and anything that doesn't look like a + // versioned directory (e.g. nvm's "lts" alias symlink). + if name == "system" { + continue + } + // Must be a directory — nvm stores installs as real dirs. + if !e.IsDir() { + continue + } + // Strip a leading "v" if present so semver.NewVersion accepts it. + v, err := semver.NewVersion(strings.TrimPrefix(name, "v")) + if err != nil { + // Skip unparseable names rather than aborting the whole list. + // Future nvm versions could add new metadata dirs we don't + // recognize; we want to be forward-compatible. + continue + } + versions = append(versions, *v) + } + // semver.Collection in v3.5.0 is []*Version (pointers), so a value + // slice doesn't satisfy it. Use sort.Slice with semver.Compare. + sort.Slice(versions, func(i, j int) bool { + return versions[i].Compare(&versions[j]) < 0 + }) + return versions, nil +} + +// --- Mutation stubs ----------------------------------------------------- +// +// Install, Uninstall, Use, SetDefault, and GlobalNpmPrefix return +// ErrNVMNotImplemented. They will be filled in when the upgrade command +// (Phase 3) needs to mutate state. Returning an explicit sentinel error +// (rather than nil) makes "not implemented" provably distinguishable +// from "succeeded". + +func (n *NVM) Install(v semver.Version) error { return ErrNVMNotImplemented } +func (n *NVM) Uninstall(v semver.Version) error { return ErrNVMNotImplemented } +func (n *NVM) Use(v semver.Version) error { return ErrNVMNotImplemented } +func (n *NVM) SetDefault(v semver.Version) error { return ErrNVMNotImplemented } func (n *NVM) GlobalNpmPrefix(v semver.Version) (string, error) { - return "", nil + return "", ErrNVMNotImplemented } diff --git a/internal/detector/nvm_test.go b/internal/detector/nvm_test.go new file mode 100644 index 0000000..d0177b3 --- /dev/null +++ b/internal/detector/nvm_test.go @@ -0,0 +1,447 @@ +package detector + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Masterminds/semver/v3" + + "github.com/dipto0321/nodeup/internal/platform" +) + +// fakeEntry is a minimal os.DirEntry implementation for tests. The real +// os.ReadDir returns *os.DirEntry-of-fs.FileInfo, but constructing those +// by hand is verbose; this stub lets us hand a canned list of names + +// IsDir flag to parseNVMInstalledEntries. +type fakeEntry struct { + name string + isDir bool +} + +func (f fakeEntry) Name() string { return f.name } +func (f fakeEntry) IsDir() bool { return f.isDir } +func (f fakeEntry) Type() os.FileMode { return modeFromIsDir(f.isDir) } +func (f fakeEntry) Info() (os.FileInfo, error) { return nil, os.ErrNotExist } + +// modeFromIsDir turns a bool into a FileMode bitmask. Used for Type(), +// which some os.DirEntry consumers call instead of IsDir(). +func modeFromIsDir(d bool) os.FileMode { + if d { + return os.ModeDir + } + return 0 +} + +// withStubListDir swaps the package-level listDir var for the duration +// of one test, returning a cleanup hook via t.Cleanup. The stub ignores +// the path argument and always returns the supplied entries; tests that +// care about the path should assert on the helper's own bookkeeping. +func withStubListDir(t *testing.T, entries []os.DirEntry, err error) { + t.Helper() + orig := listDir + listDir = func(string) ([]os.DirEntry, error) { + return entries, err + } + t.Cleanup(func() { listDir = orig }) +} + +// withStubScript swaps the package-level runScript var for the duration +// of one test. The stub returns the supplied stdout and nil error, +// unless err is non-nil. It also captures the script that was passed +// in, which lets tests assert it contains the expected invocation +// (e.g. "nvm --version"). +func withStubScript(t *testing.T, stdout string, err error) *scriptRecorder { + t.Helper() + orig := runScript + rec := &scriptRecorder{} + runScript = func(_ context.Context, script string) (*platform.RunResult, error) { + rec.script = script + return &platform.RunResult{Stdout: stdout, Stderr: "", ExitCode: 0}, err + } + t.Cleanup(func() { runScript = orig }) + return rec +} + +// scriptRecorder captures the most recent script passed to runScript. +// Used to assert that Version() builds the expected shell command. +type scriptRecorder struct { + script string +} + +// --- parseNVMVersion ---------------------------------------------------- + +func TestParseNVMVersion_BareVersion(t *testing.T) { + got, err := parseNVMVersion("0.40.5\n") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "0.40.5" { + t.Errorf("got %q, want %q", got, "0.40.5") + } +} + +func TestParseNVMVersion_WithPrefix(t *testing.T) { + // Older nvm versions print "nvm 0.39.7". + got, err := parseNVMVersion("nvm 0.39.7\n") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "0.39.7" { + t.Errorf("got %q, want %q", got, "0.39.7") + } +} + +func TestParseNVMVersion_TrimsWhitespace(t *testing.T) { + got, err := parseNVMVersion(" 0.40.5 \n") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "0.40.5" { + t.Errorf("got %q, want %q", got, "0.40.5") + } +} + +func TestParseNVMVersion_Empty(t *testing.T) { + _, err := parseNVMVersion("") + if err == nil { + t.Error("expected error on empty input") + } +} + +func TestParseNVMVersion_WhitespaceOnly(t *testing.T) { + _, err := parseNVMVersion(" \n\t ") + if err == nil { + t.Error("expected error on whitespace-only input") + } +} + +// --- parseNVMInstalledEntries ------------------------------------------- + +func TestParseNVMInstalledEntries_StandardLayout(t *testing.T) { + // Real nvm layout: directory entries named "vX.Y.Z". + entries := []os.DirEntry{ + fakeEntry{name: "v18.14.0", isDir: true}, + fakeEntry{name: "v16.19.0", isDir: true}, + fakeEntry{name: "v19.6.0", isDir: true}, + } + got, err := parseNVMInstalledEntries(entries) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := []string{"16.19.0", "18.14.0", "19.6.0"} + if len(got) != len(want) { + t.Fatalf("got %d versions, want %d", len(got), len(want)) + } + for i, w := range want { + if got[i].String() != w { + t.Errorf("version[%d] = %q, want %q", i, got[i].String(), w) + } + } +} + +func TestParseNVMInstalledEntries_AcceptsBareVersions(t *testing.T) { + // Some installs drop the v prefix. + entries := []os.DirEntry{ + fakeEntry{name: "18.14.0", isDir: true}, + fakeEntry{name: "16.19.0", isDir: true}, + } + got, err := parseNVMInstalledEntries(entries) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got) != 2 { + t.Fatalf("got %d versions, want 2", len(got)) + } + if got[0].String() != "16.19.0" || got[1].String() != "18.14.0" { + t.Errorf("sort order wrong: %v", got) + } +} + +func TestParseNVMInstalledEntries_SkipsSystem(t *testing.T) { + entries := []os.DirEntry{ + fakeEntry{name: "system", isDir: true}, + fakeEntry{name: "v18.14.0", isDir: true}, + } + got, err := parseNVMInstalledEntries(entries) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got) != 1 { + t.Fatalf("expected 1 version, got %d", len(got)) + } + if got[0].String() != "18.14.0" { + t.Errorf("got %q, want 18.14.0", got[0].String()) + } +} + +func TestParseNVMInstalledEntries_SkipsNonDirs(t *testing.T) { + // nvm sometimes leaves a "lts" symlink or other files in this dir. + entries := []os.DirEntry{ + fakeEntry{name: "v18.14.0", isDir: true}, + fakeEntry{name: "lts", isDir: false}, + fakeEntry{name: ".DS_Store", isDir: false}, + } + got, err := parseNVMInstalledEntries(entries) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got) != 1 { + t.Fatalf("expected 1 version, got %d", len(got)) + } +} + +func TestParseNVMInstalledEntries_SkipsUnparseable(t *testing.T) { + entries := []os.DirEntry{ + fakeEntry{name: "v18.14.0", isDir: true}, + fakeEntry{name: "weird-name", isDir: true}, + } + got, err := parseNVMInstalledEntries(entries) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got) != 1 { + t.Fatalf("expected 1 version, got %d", len(got)) + } +} + +func TestParseNVMInstalledEntries_Empty(t *testing.T) { + got, err := parseNVMInstalledEntries(nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got == nil { + t.Error("expected non-nil empty slice") + } + if len(got) != 0 { + t.Errorf("expected 0 versions, got %d", len(got)) + } +} + +func TestParseNVMInstalledEntries_OnlySystem(t *testing.T) { + entries := []os.DirEntry{ + fakeEntry{name: "system", isDir: true}, + } + got, err := parseNVMInstalledEntries(entries) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got == nil { + t.Error("expected non-nil empty slice (only-system case)") + } + if len(got) != 0 { + t.Errorf("expected 0 versions, got %d", len(got)) + } +} + +// --- NVM.Name ----------------------------------------------------------- + +func TestNVM_Name(t *testing.T) { + if got := NewNVM().Name(); got != "nvm" { + t.Errorf("got %q, want %q", got, "nvm") + } +} + +// --- NVM.Detect --------------------------------------------------------- + +// withStubNVMScript creates a real on-disk nvm.sh file in a temp dir +// and points NVM_DIR at it. This is the cleanest way to test Detect: +// the production code reads nvm.sh via os.Stat, and stubbing os.Stat +// would be heavier than just writing a one-line file. +func withStubNVMScript(t *testing.T) { + t.Helper() + dir := t.TempDir() + // nvm.sh is a real shell script; an empty file is enough to satisfy + // the existence check. The contents are not sourced in Detect. + if err := os.WriteFile(filepath.Join(dir, "nvm.sh"), []byte("# stub nvm\n"), 0o644); err != nil { + t.Fatalf("write nvm.sh: %v", err) + } + t.Setenv("NVM_DIR", dir) + t.Cleanup(func() { t.Setenv("NVM_DIR", "") }) +} + +func TestNVM_Detect_TrueWhenNVMScriptExists(t *testing.T) { + withStubNVMScript(t) + if !NewNVM().Detect() { + t.Error("Detect returned false with nvm.sh present") + } +} + +func TestNVM_Detect_FalseWhenNoNVMScript(t *testing.T) { + // Point NVM_DIR at an empty temp dir — no nvm.sh inside. + t.Setenv("NVM_DIR", t.TempDir()) + // And make sure HOME-based fallback doesn't accidentally find a + // real install. t.TempDir() lives under os.TempDir() which is not + // $HOME, so the ~/.nvm fallback will hit a path that doesn't + // exist on the test machine (unless the developer happens to have + // ~/.nvm — and on CI runners it won't). We accept that small risk + // in exchange for test simplicity. + if NewNVM().Detect() { + // Only fail if HOME-based fallback would have found something. + // Check by computing the fallback path and seeing if it exists. + home, err := os.UserHomeDir() + if err != nil { + t.Fatalf("Detect returned true unexpectedly: %v", err) + } + if _, err := os.Stat(filepath.Join(home, ".nvm", "nvm.sh")); err == nil { + t.Skip("developer has ~/.nvm/nvm.sh; cannot test the negative case") + } + t.Error("Detect returned true with no nvm.sh") + } +} + +// --- NVM.Version ------------------------------------------------------- + +func TestNVM_Version_BareOutput(t *testing.T) { + withStubNVMScript(t) + rec := withStubScript(t, "0.40.5\n", nil) + + got, err := NewNVM().Version() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "0.40.5" { + t.Errorf("got %q, want %q", got, "0.40.5") + } + // Sanity: the script we built should source nvm.sh and call nvm --version. + if !strings.Contains(rec.script, "nvm --version") { + t.Errorf("script missing nvm --version: %q", rec.script) + } + if !strings.Contains(rec.script, "source ") { + t.Errorf("script missing source command: %q", rec.script) + } +} + +func TestNVM_Version_PropagatesScriptError(t *testing.T) { + withStubNVMScript(t) + withStubScript(t, "", errSentinelForTest) + + _, err := NewNVM().Version() + if err == nil { + t.Fatal("expected error when runScript fails") + } +} + +func TestNVM_Version_NoNVMScript(t *testing.T) { + // NVM_DIR pointing at an empty temp dir. + t.Setenv("NVM_DIR", t.TempDir()) + if _, err := NewNVM().Version(); err == nil { + t.Error("expected error when nvm cannot be located") + } +} + +// --- NVM.ListInstalled ------------------------------------------------- + +func TestNVM_ListInstalled_HappyPath(t *testing.T) { + withStubNVMScript(t) + withStubListDir(t, []os.DirEntry{ + fakeEntry{name: "v18.14.0", isDir: true}, + fakeEntry{name: "v16.19.0", isDir: true}, + fakeEntry{name: "v19.6.0", isDir: true}, + }, nil) + + got, err := NewNVM().ListInstalled() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := []string{"16.19.0", "18.14.0", "19.6.0"} + if len(got) != len(want) { + t.Fatalf("got %d versions, want %d", len(got), len(want)) + } + for i, w := range want { + if got[i].String() != w { + t.Errorf("version[%d] = %q, want %q", i, got[i].String(), w) + } + } +} + +func TestNVM_ListInstalled_NotFoundReturnsEmpty(t *testing.T) { + withStubNVMScript(t) + // Simulate "nvm installed, never installed a node" by returning + // os.ErrNotExist from the stub. + withStubListDir(t, nil, os.ErrNotExist) + + got, err := NewNVM().ListInstalled() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got == nil { + t.Error("expected non-nil empty slice") + } + if len(got) != 0 { + t.Errorf("expected 0 versions, got %d", len(got)) + } +} + +func TestNVM_ListInstalled_PropagatesOtherError(t *testing.T) { + withStubNVMScript(t) + withStubListDir(t, nil, errSentinelForTest) + + _, err := NewNVM().ListInstalled() + if err == nil { + t.Fatal("expected error when listDir fails with non-NotExist") + } +} + +func TestNVM_ListInstalled_NoNVMDir(t *testing.T) { + // NVM_DIR empty AND home is unresolvable. We force the latter by + // setting HOME="" on unix. On Windows the equivalent is USERPROFILE. + t.Setenv("NVM_DIR", "") + if platform.IsWindows() { + t.Setenv("USERPROFILE", "") + } else { + t.Setenv("HOME", "") + } + _, err := NewNVM().ListInstalled() + if err == nil { + t.Error("expected error when NVM_DIR and HOME both empty") + } +} + +// --- Mutation stubs ---------------------------------------------------- + +func TestNVM_MutationMethodsReturnErrNVMNotImplemented(t *testing.T) { + n := NewNVM() + // semver.MustParse returns *semver.Version; the Manager interface + // declares value receivers so we deref at the call site. + v := *semver.MustParse("20.0.0") + + tests := []struct { + name string + fn func() error + }{ + {"Install", func() error { return n.Install(v) }}, + {"Uninstall", func() error { return n.Uninstall(v) }}, + {"Use", func() error { return n.Use(v) }}, + {"SetDefault", func() error { return n.SetDefault(v) }}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := tc.fn() + if err == nil { + t.Fatalf("%s returned nil, want ErrNVMNotImplemented", tc.name) + } + if !errors.Is(err, ErrNVMNotImplemented) { + t.Errorf("%s returned %v, want errors.Is(_, ErrNVMNotImplemented)", tc.name, err) + } + }) + } +} + +func TestNVM_GlobalNpmPrefixReturnsErrNVMNotImplemented(t *testing.T) { + n := NewNVM() + v := *semver.MustParse("20.0.0") + _, err := n.GlobalNpmPrefix(v) + if !errors.Is(err, ErrNVMNotImplemented) { + t.Errorf("got %v, want errors.Is(_, ErrNVMNotImplemented)", err) + } +} + +// --- helpers ------------------------------------------------------------ + +// errSentinelForTest is the error returned by stubs to verify error +// propagation. It is a plain errors.New sentinel. +var errSentinelForTest = errors.New("simulated shell failure")