diff --git a/internal/detector/volta.go b/internal/detector/volta.go index 0324e6b..7fc42cf 100644 --- a/internal/detector/volta.go +++ b/internal/detector/volta.go @@ -1,11 +1,54 @@ package detector -import "github.com/Masterminds/semver/v3" +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" -// Volta is the Volta implementation (https://volta.sh). Volta is a -// binary at ~/.volta/bin/volta with VOLTA_HOME env var. + "github.com/Masterminds/semver/v3" + + "github.com/dipto0321/nodeup/internal/platform" +) + +// Volta is the Volta implementation (https://volta.sh). Volta is a true +// binary (unlike NVM, which is a shell function), but it stores Node +// installs in a fixed on-disk layout under $VOLTA_HOME rather than +// advertising them through a CLI query. See nodeup.md §5 for the full +// detection strategy. +// +// Volta's on-disk layout (v2.x, "v4" internal layout): +// +// $VOLTA_HOME/ +// ├── bin/ # shim_dir — volta lives here +// ├── tools/ +// │ ├── inventory/ # package-level inventory +// │ │ ├── node/ +// │ │ ├── npm/ +// │ │ ├── pnpm/ +// │ │ └── yarn/ +// │ └── image/ # fully resolved Node installs +// │ └── node/ +// │ ├── v20.10.0/ +// │ └── v22.5.0/ +// +// As of late 2024 Volta is in maintenance mode — the README itself +// recommends migrating to mise. nodeup still supports it because many +// users haven't migrated yet, but new installations should prefer mise. // -// Implementation status: stub. Real implementation lands in Phase 5. +// Phase 1 implements the detection surface only: +// - Detect : PATH lookup (platform.LookupManagerBinary) OR +// $VOLTA_HOME/bin/volta existence on disk +// - Version : `volta --version`, parsed to drop the leading +// "volta " if present +// - ListInstalled: read /tools/image/node/* entries +// +// 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 Volta struct{} // NewVolta constructs a fresh Volta detector. @@ -13,13 +56,211 @@ func NewVolta() *Volta { return &Volta{} } func (v *Volta) Name() string { return "volta" } -func (v *Volta) Detect() bool { return false } -func (v *Volta) Version() (string, error) { return "", nil } -func (v *Volta) ListInstalled() ([]semver.Version, error) { return nil, nil } -func (v *Volta) Install(ver semver.Version) error { return nil } -func (v *Volta) Uninstall(ver semver.Version) error { return nil } -func (v *Volta) Use(ver semver.Version) error { return nil } -func (v *Volta) SetDefault(ver semver.Version) error { return nil } +// runShell (declared in fnm.go) is the package-level seam used by +// Volta to invoke the `volta` binary. Both FNM and Volta wrap a +// binary on PATH for the --version call. Tests overwrite it to +// capture arguments and return canned output without spawning a +// subprocess. Production code never reassigns it. +// +// listDir (declared in nvm.go) is the package-level seam used by +// Volta to enumerate $VOLTA_HOME/tools/image/node. Both NVM and +// Volta read a known directory structure rather than parsing CLI +// output for the installed list. Tests overwrite it to return +// canned DirEntry slices without touching the real filesystem. +// Production code never reassigns it. + +// ErrVoltaNotImplemented is returned by Volta 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 ErrVoltaNotImplemented = errors.New("volta mutation commands not yet implemented") + +// homeDir is the package-level seam used by Volta to resolve the user +// home directory. Tests overwrite it to isolate the test from the +// developer's actual $HOME / %USERPROFILE% — which is critical on +// Windows, where os.UserHomeDir reads %USERPROFILE% and ignores +// $HOME, so t.Setenv("HOME", ...) has no effect. Production code +// never reassigns it. +// +// Signature matches os.UserHomeDir so a direct assignment works. +var homeDir = os.UserHomeDir + +// voltaHome returns the Volta install root. Resolution order: +// 1. $VOLTA_HOME environment variable (the official override) +// 2. ~/.volta (the documented default) +// +// Returns "" if neither can be resolved (e.g., HOME unset on a +// stripped-down CI runner). Callers must treat "" as "volta not +// installed". +func voltaHome() string { + if d := strings.TrimSpace(os.Getenv("VOLTA_HOME")); d != "" { + return d + } + home, err := homeDir() + if err != nil { + return "" + } + return filepath.Join(home, ".volta") +} + +// voltaBinaryPath returns the absolute path to the volta binary +// inside the install root. Returns "" if the install root can't be +// resolved. We resolve via /bin/volta to support the case +// where `volta` is on PATH but PATH is not what `exec.LookPath` sees +// (e.g. inside a shell snapshot, a freshly-extracted env, etc.). +func voltaBinaryPath() string { + home := voltaHome() + if home == "" { + return "" + } + return filepath.Join(home, "bin", "volta") +} + +// Detect returns true when Volta appears to be installed. Volta is a +// real binary (unlike NVM), so we accept either: +// 1. the binary is on PATH (via platform.LookupManagerBinary), OR +// 2. the conventional /bin/volta exists on disk +// +// Per the Manager contract, Detect MUST be cheap — neither branch +// spawns a subprocess. +func (v *Volta) Detect() bool { + if platform.LookupManagerBinary("volta") != "" { + return true + } + bin := voltaBinaryPath() + if bin == "" { + return false + } + // Same reasoning as NVM's Detect: collapse "not found" and + // "permission denied" into a false result so that an unreadable + // Volta install is treated as "not present" rather than a hard + // error from Detect. + _, err := os.Stat(bin) + return err == nil +} + +// Version returns Volta's own version string, e.g. "2.0.2". The +// binary emits something like "volta 2.0.2\n" (older releases) or +// just "2.0.2\n" (some patched builds); we accept both. +// +// We invoke the binary through runShell so the production binary +// lookup goes through platform.LookupManagerBinary / exec.LookPath +// rather than relying on the absolute /bin/volta path +// (which only exists if Volta was installed via its installer rather +// than Homebrew). +func (v *Volta) Version() (string, error) { + res, err := runShell(context.Background(), "volta", "--version") + if err != nil { + return "", fmt.Errorf("volta --version: %w", err) + } + return parseVoltaVersion(res.Stdout) +} + +// parseVoltaVersion extracts the version token from +// `volta --version` output. +// +// Real observed output (volta 2.0.2): +// +// volta 2.0.2 +// +// We accept either "volta X.Y.Z" or bare "X.Y.Z" (defensive — the +// exact format has shifted across releases and patches). Leading +// whitespace and a trailing newline are trimmed. +func parseVoltaVersion(stdout string) (string, error) { + out := strings.TrimSpace(stdout) + if out == "" { + return "", errors.New("volta --version returned empty output") + } + fields := strings.Fields(out) + if len(fields) == 0 { + return "", errors.New("volta --version returned no tokens") + } + // If the first token is literally "volta" take the next one. + if fields[0] == "volta" && 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 Volta has installed, +// sorted ascending. Source: directory entries under +// /tools/image/node/. +// +// Each subdirectory of that directory is a full Node install. Volta +// names them like "v20.10.0" (with v prefix), but we accept both +// with and without — semver.NewVersion normalizes. +// +// Non-directory entries (which Volta doesn't currently emit, but we +// guard against) are skipped. Volta does NOT have an nvm-style +// "system" sentinel, so no special-case is needed. +func (v *Volta) ListInstalled() ([]semver.Version, error) { + home := voltaHome() + if home == "" { + return nil, errors.New("volta not detected: cannot resolve VOLTA_HOME or ~/.volta") + } + versionsDir := filepath.Join(home, "tools", "image", "node") + entries, err := listDir(versionsDir) + if err != nil { + if os.IsNotExist(err) { + // volta is installed but has never installed a Node version + // — return an empty (non-nil) slice, not an error. Callers + // distinguish this from "volta not installed" via Detect(). + return []semver.Version{}, nil + } + return nil, fmt.Errorf("read %s: %w", versionsDir, err) + } + return parseVoltaInstalledEntries(entries) +} + +// parseVoltaInstalledEntries turns a list of directory entries under +// /tools/image/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 "volta installed, nothing +// managed yet". +func parseVoltaInstalledEntries(entries []os.DirEntry) ([]semver.Version, error) { + versions := make([]semver.Version, 0) + for _, e := range entries { + // Volta doesn't have a "system" sentinel — skip the check + // that NVM needs. + // Must be a directory — Volta stores installs as real dirs. + if !e.IsDir() { + continue + } + name := e.Name() + // 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 Volta 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 +// ErrVoltaNotImplemented. They will be filled in when the upgrade +// command (Phase 4) needs to mutate state. Returning an explicit +// sentinel error (rather than nil) makes "not implemented" provably +// distinguishable from "succeeded". + +func (v *Volta) Install(ver semver.Version) error { return ErrVoltaNotImplemented } +func (v *Volta) Uninstall(ver semver.Version) error { return ErrVoltaNotImplemented } +func (v *Volta) Use(ver semver.Version) error { return ErrVoltaNotImplemented } +func (v *Volta) SetDefault(ver semver.Version) error { return ErrVoltaNotImplemented } func (v *Volta) GlobalNpmPrefix(ver semver.Version) (string, error) { - return "", nil + return "", ErrVoltaNotImplemented } diff --git a/internal/detector/volta_test.go b/internal/detector/volta_test.go new file mode 100644 index 0000000..c216aa8 --- /dev/null +++ b/internal/detector/volta_test.go @@ -0,0 +1,548 @@ +package detector + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Masterminds/semver/v3" + + "github.com/dipto0321/nodeup/internal/platform" +) + +// --- parseVoltaVersion -------------------------------------------------- + +func TestParseVoltaVersion_StandardOutput(t *testing.T) { + // Observed on volta 2.0.2: + // $ volta --version + // volta 2.0.2 + got, err := parseVoltaVersion("volta 2.0.2\n") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "2.0.2" { + t.Errorf("got %q, want %q", got, "2.0.2") + } +} + +func TestParseVoltaVersion_BareVersion(t *testing.T) { + // Some Volta builds (or patched forks) emit just the version. + got, err := parseVoltaVersion("2.0.2\n") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "2.0.2" { + t.Errorf("got %q, want %q", got, "2.0.2") + } +} + +func TestParseVoltaVersion_TrailingWhitespace(t *testing.T) { + got, err := parseVoltaVersion(" volta 1.1.1 \n") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "1.1.1" { + t.Errorf("got %q, want %q", got, "1.1.1") + } +} + +func TestParseVoltaVersion_Empty(t *testing.T) { + _, err := parseVoltaVersion("") + if err == nil { + t.Error("expected error for empty input") + } +} + +func TestParseVoltaVersion_WhitespaceOnly(t *testing.T) { + // Whitespace-only output: TrimSpace produces "", we must error. + _, err := parseVoltaVersion("\n \n") + if err == nil { + t.Error("expected error for whitespace-only input") + } +} + +// --- parseVoltaInstalledEntries ----------------------------------------- + +func TestParseVoltaInstalledEntries_HappyPath(t *testing.T) { + // Volta stores installs as dirs under + // /tools/image/node/. We don't care about the + // "inventory" entries for Node (they reference the resolved + // version, not individual installs). + entries := []os.DirEntry{ + fakeEntry{name: "v20.10.0", isDir: true}, + fakeEntry{name: "v22.5.0", isDir: true}, + fakeEntry{name: "v18.19.0", isDir: true}, + } + got, err := parseVoltaInstalledEntries(entries) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := []string{"18.19.0", "20.10.0", "22.5.0"} // sorted asc + if len(got) != len(want) { + t.Fatalf("got %d versions, want %d (%v)", len(got), len(want), got) + } + for i, w := range want { + if got[i].String() != w { + t.Errorf("got[%d] = %s, want %s", i, got[i], w) + } + } +} + +func TestParseVoltaInstalledEntries_NoVPrefix(t *testing.T) { + // Defensive: some Volta forks or migrated layouts might drop + // the "v" prefix on directory names. semver.NewVersion handles + // both forms. + entries := []os.DirEntry{ + fakeEntry{name: "20.10.0", isDir: true}, + fakeEntry{name: "v22.5.0", isDir: true}, + } + got, err := parseVoltaInstalledEntries(entries) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := []string{"20.10.0", "22.5.0"} + if len(got) != len(want) { + t.Fatalf("got %d versions, want %d (%v)", len(got), len(want), got) + } + for i, w := range want { + if got[i].String() != w { + t.Errorf("got[%d] = %s, want %s", i, got[i], w) + } + } +} + +func TestParseVoltaInstalledEntries_SkipsNonDirs(t *testing.T) { + // Volta shouldn't emit non-dirs in this directory, but if it + // ever does (e.g. a stray README, a future inventory pointer), + // we must skip them silently. + entries := []os.DirEntry{ + fakeEntry{name: "v20.10.0", isDir: true}, + fakeEntry{name: "README.md", isDir: false}, + fakeEntry{name: "v22.5.0", isDir: true}, + } + got, err := parseVoltaInstalledEntries(entries) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := []string{"20.10.0", "22.5.0"} + if len(got) != len(want) { + t.Fatalf("got %d versions, want %d (%v)", len(got), len(want), got) + } +} + +func TestParseVoltaInstalledEntries_SkipsUnparseable(t *testing.T) { + // Stray directories we don't understand must not abort the list. + entries := []os.DirEntry{ + fakeEntry{name: "v20.10.0", isDir: true}, + fakeEntry{name: "not-a-version", isDir: true}, + fakeEntry{name: "lts-hydrogen", isDir: true}, // plausible Volta alias dir + fakeEntry{name: "v22.5.0", isDir: true}, + } + got, err := parseVoltaInstalledEntries(entries) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := []string{"20.10.0", "22.5.0"} + if len(got) != len(want) { + t.Fatalf("got %d versions, want %d (%v)", len(got), len(want), got) + } +} + +func TestParseVoltaInstalledEntries_Empty(t *testing.T) { + got, err := parseVoltaInstalledEntries(nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got == nil { + t.Error("expected non-nil slice, got nil") + } + if len(got) != 0 { + t.Errorf("expected empty slice, got %v", got) + } +} + +// --- Volta method tests ------------------------------------------------- + +func TestVolta_Name(t *testing.T) { + if got := NewVolta().Name(); got != "volta" { + t.Errorf("Name() = %q, want %q", got, "volta") + } +} + +func TestVolta_Version_Success(t *testing.T) { + var captured []string + withStubShell(t, + func(name string, a []string) { captured = append(captured, name); captured = append(captured, a...) }, + func(req string) (*platform.RunResult, error) { + if req != "volta --version" { + t.Errorf("unexpected runShell call: %q", req) + } + return &platform.RunResult{Stdout: "volta 2.0.2\n"}, nil + }, + ) + + got, err := NewVolta().Version() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "2.0.2" { + t.Errorf("got %q, want %q", got, "2.0.2") + } + if len(captured) < 2 || captured[0] != "volta" || captured[1] != "--version" { + t.Errorf("expected `volta --version` invocation, got %v", captured) + } +} + +func TestVolta_Version_BareVersion(t *testing.T) { + // Defensive parser coverage: bare version output (no "volta " prefix). + withStubShell(t, nil, func(req string) (*platform.RunResult, error) { + return &platform.RunResult{Stdout: "2.0.2\n"}, nil + }) + + got, err := NewVolta().Version() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "2.0.2" { + t.Errorf("got %q, want %q", got, "2.0.2") + } +} + +func TestVolta_Version_RunShellError(t *testing.T) { + wantErr := errors.New("simulated subprocess failure") + withStubShell(t, nil, func(req string) (*platform.RunResult, error) { + return nil, wantErr + }) + + _, err := NewVolta().Version() + if err == nil { + t.Fatal("expected error, got nil") + } + if !errors.Is(err, wantErr) { + t.Errorf("error %v should wrap %v", err, wantErr) + } +} + +func TestVolta_Version_ParsingError(t *testing.T) { + // runShell succeeded but the body is unparseable. + withStubShell(t, nil, func(req string) (*platform.RunResult, error) { + return &platform.RunResult{Stdout: ""}, nil + }) + + _, err := NewVolta().Version() + if err == nil { + t.Error("expected parsing error from blank output, got nil") + } +} + +func TestVolta_Detect_NeitherPathNorHome(t *testing.T) { + // Both branches false: stub LookupManagerBinary to return empty + // (via the package var used by Detect), unset VOLTA_HOME and stub + // homeDir to return "" so voltaHome() returns "". Detect() must + // return false without spawning runShell. + t.Setenv("VOLTA_HOME", "") + withStubHomeDir(t, "", errSentinelForTest) + + // Force runShell to fail loudly if Detect() touches it. + orig := runShell + runShell = func(ctx context.Context, name string, a ...string) (*platform.RunResult, error) { + t.Fatalf("Detect() must not invoke runShell (was called with %s %v)", name, a) + return nil, nil + } + t.Cleanup(func() { runShell = orig }) + + if NewVolta().Detect() { + t.Error("Detect() = true with no PATH and no $VOLTA_HOME, want false") + } +} + +func TestVolta_Detect_FindsBinaryOnDisk(t *testing.T) { + // Stub LookupManagerBinary to return "" (PATH miss), but place a + // real file at /.volta/bin/volta. We use + // the homeDir seam rather than t.Setenv("HOME", ...) because on + // Windows os.UserHomeDir reads %USERPROFILE% and ignores $HOME, + // so the env-var approach alone wouldn't redirect on Windows. + tmp := t.TempDir() + withStubHomeDir(t, tmp, nil) + + t.Setenv("VOLTA_HOME", "") + + // Force runShell to fail loudly if Detect() touches it. + orig := runShell + runShell = func(ctx context.Context, name string, a ...string) (*platform.RunResult, error) { + t.Fatalf("Detect() must not invoke runShell (was called with %s %v)", name, a) + return nil, nil + } + t.Cleanup(func() { runShell = orig }) + + voltaRoot := filepath.Join(tmp, ".volta") + if err := os.MkdirAll(filepath.Join(voltaRoot, "bin"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(voltaRoot, "bin", "volta"), []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + + if !NewVolta().Detect() { + t.Error("Detect() = false with /.volta/bin/volta present, want true") + } +} + +func TestVolta_Detect_HonorsVOLTA_HOME(t *testing.T) { + // VOLTA_HOME overrides the default. Place a file there. + tmp := t.TempDir() + t.Setenv("VOLTA_HOME", tmp) + + orig := runShell + runShell = func(ctx context.Context, name string, a ...string) (*platform.RunResult, error) { + t.Fatalf("Detect() must not invoke runShell (was called with %s %v)", name, a) + return nil, nil + } + t.Cleanup(func() { runShell = orig }) + + if err := os.MkdirAll(filepath.Join(tmp, "bin"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(tmp, "bin", "volta"), []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + + if !NewVolta().Detect() { + t.Error("Detect() = false with $VOLTA_HOME/bin/volta present, want true") + } +} + +func TestVolta_ListInstalled_Success(t *testing.T) { + // Stub listDir to return canned Volta-style entries. We don't + // need a real $VOLTA_HOME because we override voltaHome() resolution + // via t.Setenv so ListInstalled can compute the image path. + home := t.TempDir() + t.Setenv("VOLTA_HOME", home) + + // Image dir is /tools/image/node — it doesn't need + // to exist; listDir is stubbed to ignore the path argument. + entries := []os.DirEntry{ + fakeEntry{name: "v22.5.0", isDir: true}, + fakeEntry{name: "v20.10.0", isDir: true}, + } + withStubListDir(t, entries, nil) + + got, err := NewVolta().ListInstalled() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := []string{"20.10.0", "22.5.0"} + if len(got) != len(want) { + t.Fatalf("got %d versions, want %d (%v)", len(got), len(want), got) + } + for i, w := range want { + if got[i].String() != w { + t.Errorf("got[%d] = %s, want %s", i, got[i], w) + } + } +} + +func TestVolta_ListInstalled_NoImageDir(t *testing.T) { + // Volta is installed but has never installed a Node version — + // tools/image/node doesn't exist. Must return an empty (non-nil) + // slice, not an error. + home := t.TempDir() + t.Setenv("VOLTA_HOME", home) + + // Stub listDir to simulate ENOENT (real path doesn't exist). + withStubListDir(t, nil, os.ErrNotExist) + + got, err := NewVolta().ListInstalled() + if err != nil { + t.Fatalf("expected nil error for missing image dir, got %v", err) + } + if got == nil { + t.Error("expected non-nil empty slice, got nil") + } + if len(got) != 0 { + t.Errorf("expected empty slice, got %v", got) + } +} + +func TestVolta_ListInstalled_ReadsExpectedPath(t *testing.T) { + // Verify ListInstalled constructs the canonical image path: + // /tools/image/node. + home := t.TempDir() + t.Setenv("VOLTA_HOME", home) + + var captured string + orig := listDir + listDir = func(p string) ([]os.DirEntry, error) { + captured = p + return []os.DirEntry{ + fakeEntry{name: "v20.10.0", isDir: true}, + }, nil + } + t.Cleanup(func() { listDir = orig }) + + if _, err := NewVolta().ListInstalled(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := filepath.Join(home, "tools", "image", "node") + if captured != want { + t.Errorf("listDir called with %q, want %q", captured, want) + } +} + +func TestVolta_ListInstalled_ListDirError(t *testing.T) { + // Anything other than ENOENT must surface as a real error so the + // user can debug permissions, corruption, etc. + home := t.TempDir() + t.Setenv("VOLTA_HOME", home) + + wantErr := errors.New("simulated readdir failure") + withStubListDir(t, nil, wantErr) + + _, err := NewVolta().ListInstalled() + if err == nil { + t.Fatal("expected error from listDir failure, got nil") + } + if !errors.Is(err, wantErr) { + t.Errorf("error %v should wrap %v", err, wantErr) + } +} + +func TestVolta_MutationMethodsReturnErrVoltaNotImplemented(t *testing.T) { + // Phase 1 only implements the detection surface. Mutation methods + // must return ErrVoltaNotImplemented so callers can distinguish + // "not yet implemented" from "succeeded with nil error". + v := NewVolta() + ver, err := semver.NewVersion("22.5.0") + if err != nil { + t.Fatal(err) + } + + if err := v.Install(*ver); !errors.Is(err, ErrVoltaNotImplemented) { + t.Errorf("Install: got %v, want ErrVoltaNotImplemented", err) + } + if err := v.Uninstall(*ver); !errors.Is(err, ErrVoltaNotImplemented) { + t.Errorf("Uninstall: got %v, want ErrVoltaNotImplemented", err) + } + if err := v.Use(*ver); !errors.Is(err, ErrVoltaNotImplemented) { + t.Errorf("Use: got %v, want ErrVoltaNotImplemented", err) + } + if err := v.SetDefault(*ver); !errors.Is(err, ErrVoltaNotImplemented) { + t.Errorf("SetDefault: got %v, want ErrVoltaNotImplemented", err) + } + if _, err := v.GlobalNpmPrefix(*ver); !errors.Is(err, ErrVoltaNotImplemented) { + t.Errorf("GlobalNpmPrefix: got %v, want ErrVoltaNotImplemented", err) + } +} + +func TestVolta_DetectDoesNotInvokeRunShell(t *testing.T) { + // Belt-and-suspenders: even if both PATH lookup and on-disk + // detection would normally return false, Detect() must never + // spawn runShell. We force runShell to fail the test if called. + orig := runShell + runShell = func(ctx context.Context, name string, a ...string) (*platform.RunResult, error) { + t.Fatalf("Detect() must not invoke runShell (was called with %s %v)", name, a) + return nil, nil + } + t.Cleanup(func() { runShell = orig }) + + _ = NewVolta().Detect() +} + +// --- voltaHome / voltaBinaryPath ---------------------------------------- + +// withStubHomeDir swaps the package-level homeDir var for the +// duration of one test, returning a cleanup hook via t.Cleanup. +// The stub ignores the env/lookup context and returns the supplied +// (path, error). This is the home-resolution twin of withStubListDir +// (nvm_test.go) and withStubShell (fnm_test.go) — necessary because +// on Windows os.UserHomeDir reads %USERPROFILE% and ignores $HOME, +// so t.Setenv("HOME", ...) does not redirect there. Stubbing at the +// function-seam level is the only portable way to inject a temp +// home directory across all OSes. +func withStubHomeDir(t *testing.T, path string, err error) { + t.Helper() + orig := homeDir + homeDir = func() (string, error) { return path, err } + t.Cleanup(func() { homeDir = orig }) +} + +func TestVoltaHome_OverridesWithEnv(t *testing.T) { + // Sanity check: $VOLTA_HOME takes precedence over the homeDir + // seam. We stub homeDir so any accidental fall-through would + // surface immediately instead of silently hitting the + // developer's real $HOME. + t.Setenv("VOLTA_HOME", "/custom/volta/root") + withStubHomeDir(t, "/should/be/ignored", nil) + + got := voltaHome() + if got != "/custom/volta/root" { + t.Errorf("got %q, want %q", got, "/custom/volta/root") + } +} + +func TestVoltaHome_FallsBackToDotVolta(t *testing.T) { + t.Setenv("VOLTA_HOME", "") + home := t.TempDir() + withStubHomeDir(t, home, nil) + + got := voltaHome() + want := filepath.Join(home, ".volta") + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestVoltaHome_TrimsWhitespace(t *testing.T) { + // A path with leading/trailing whitespace is invalid; we treat + // it as "not set" and fall through to the default. This matches + // how nvmDir handles the same case. + t.Setenv("VOLTA_HOME", " ") + trimmed := strings.TrimSpace(os.Getenv("VOLTA_HOME")) + if trimmed != "" { + t.Fatalf("sanity: env not whitespace-stripped, got %q", trimmed) + } + // Even with VOLTA_HOME=" ", fallback should kick in. + home := t.TempDir() + withStubHomeDir(t, home, nil) + + got := voltaHome() + want := filepath.Join(home, ".volta") + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestVoltaHome_EmptyWhenUserHomeFails(t *testing.T) { + // homeDir erroring should make voltaHome() return "" — same + // safety net as NVM (avoiding a panic or surprising + // filepath.Join result on a stripped-down CI runner). + t.Setenv("VOLTA_HOME", "") + withStubHomeDir(t, "", errSentinelForTest) + + got := voltaHome() + if got != "" { + t.Errorf("got %q, want empty string", got) + } +} + +func TestVoltaBinaryPath_UsesVoltaHome(t *testing.T) { + t.Setenv("VOLTA_HOME", "/custom/root") + got := voltaBinaryPath() + want := filepath.Join("/custom/root", "bin", "volta") + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestVoltaBinaryPath_EmptyWhenHomeUnresolved(t *testing.T) { + t.Setenv("VOLTA_HOME", "") + withStubHomeDir(t, "", errSentinelForTest) + + got := voltaBinaryPath() + if got != "" { + t.Errorf("got %q, want empty string when neither VOLTA_HOME nor homeDir resolves", got) + } +}