From 4fd9baa9e69c94343182707003465ae07fd32919 Mon Sep 17 00:00:00 2001 From: dipto0321 Date: Mon, 29 Jun 2026 18:56:01 +0600 Subject: [PATCH] feat(detector): implement Nodenv detection with mocked tests Mirrors the ASDF pattern (PATH binary + env var + ~/.nodenv fallback) since Nodenv and ASDF share a near-identical install layout. Filters the 'system' sentinel from 'nodenv versions' and strips the ' (set by )' annotation that asdf's output lacks. Mutation methods return ErrNodenvNotImplemented (Phase 4 work). --- internal/detector/nodenv.go | 320 ++++++++++++++++- internal/detector/nodenv_test.go | 585 +++++++++++++++++++++++++++++++ 2 files changed, 891 insertions(+), 14 deletions(-) create mode 100644 internal/detector/nodenv_test.go diff --git a/internal/detector/nodenv.go b/internal/detector/nodenv.go index e658d74..00f4552 100644 --- a/internal/detector/nodenv.go +++ b/internal/detector/nodenv.go @@ -1,25 +1,317 @@ 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" +) // Nodenv is the rbenv-style version manager for Node.js -// (https://github.com/nodenv/nodenv). Uses shims under ~/.nodenv/shims. +// (https://github.com/nodenv/nodenv). Nodenv uses shims under +// $NODENV_ROOT/shims and stores installed versions in +// $NODENV_ROOT/versions//. +// +// Nodenv is structured almost identically to asdf — a binary on PATH, +// installed versions in a known directory — so the implementation +// mirrors asdf.go closely. The difference is in the surface area: +// nodenv is purpose-built for Node (asdf is multi-language via +// plugins), and its `versions` subcommand has a `system` sentinel we +// filter out (asdf does not). // -// Implementation status: stub. Real implementation lands in Phase 5. +// Detection accepts any of: +// - the binary is on PATH +// - $NODENV_ROOT env var is set (the official override) +// - the conventional ~/.nodenv directory exists on disk +// +// Phase 1 implements the detection surface only: +// - Detect : PATH lookup OR $NODENV_ROOT env OR ~/.nodenv on disk +// - Version : `nodenv --version`, parsed (strips "nodenv " +// prefix and optional "v" on the version) +// - ListInstalled: `nodenv versions`, parsed for installed Node +// versions ("* " for current, " " for others, "system" +// line is filtered out) +// +// 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 Nodenv struct{} // NewNodenv constructs a fresh nodenv detector. func NewNodenv() *Nodenv { return &Nodenv{} } -func (n *Nodenv) Name() string { return "nodenv" } - -func (n *Nodenv) Detect() bool { return false } -func (n *Nodenv) Version() (string, error) { return "", nil } -func (n *Nodenv) ListInstalled() ([]semver.Version, error) { return nil, nil } -func (n *Nodenv) Install(ver semver.Version) error { return nil } -func (n *Nodenv) Uninstall(ver semver.Version) error { return nil } -func (n *Nodenv) Use(ver semver.Version) error { return nil } -func (n *Nodenv) SetDefault(ver semver.Version) error { return nil } -func (n *Nodenv) GlobalNpmPrefix(ver semver.Version) (string, error) { - return "", nil +func (nd *Nodenv) Name() string { return "nodenv" } + +// runShell (declared in fnm.go) is the package-level seam used by +// Nodenv to invoke the `nodenv` binary. Nodenv is a binary (unlike +// NVM, which is a shell function). Tests overwrite runShell to +// capture arguments and return canned output without spawning a +// subprocess. Production code never reassigns it. + +// ErrNodenvNotImplemented is returned by Nodenv 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 ErrNodenvNotImplemented = errors.New("nodenv mutation commands not yet implemented") + +// nodenvRoot returns the nodenv data root — where installs and +// shims live. Resolution order: +// 1. $NODENV_ROOT environment variable (the official override) +// 2. ~/.nodenv (the documented default — set by the upstream +// dispatcher when NODENV_ROOT is unset) +// +// Returns "" if neither can be resolved (e.g., HOME unset on a +// stripped-down CI runner). Callers must treat "" as "nodenv not +// installed". +// +// Note: NODENV_ROOT is set by the nodenv dispatcher (libexec/nodenv) +// to "$HOME/.nodenv" when the user did not export it themselves. We +// do not reproduce that shell-side defaulting here because env-var +// lookup is cheaper than a homeDir() call and lets users override the +// location without touching $HOME. +func nodenvRoot() string { + if r := strings.TrimSpace(os.Getenv("NODENV_ROOT")); r != "" { + return r + } + home, err := homeDir() + if err != nil || home == "" { + return "" + } + return filepath.Join(home, ".nodenv") +} + +// Detect returns true when Nodenv appears to be installed. We accept +// any of: +// 1. the binary is on PATH (via platform.LookupManagerBinary), OR +// 2. $NODENV_ROOT env var is set (user has explicitly pointed us +// at a custom install root), OR +// 3. the conventional ~/.nodenv directory exists on disk +// +// Per the Manager contract, Detect MUST be cheap — none of these +// branches spawn a subprocess. +func (nd *Nodenv) Detect() bool { + if platform.LookupManagerBinary("nodenv") != "" { + return true + } + if strings.TrimSpace(os.Getenv("NODENV_ROOT")) != "" { + return true + } + dir := nodenvRoot() + if dir == "" { + return false + } + // Collapse "not found" and "permission denied" into a false + // result, matching asdf. An unreadable Nodenv install is + // treated as "not present" rather than a hard error from + // Detect. Version/ListInstalled will surface the real reason + // when the user actually invokes them. + _, err := os.Stat(dir) + return err == nil +} + +// Version returns Nodenv's own version string, e.g. "1.6.2". +// +// Per the upstream source (libexec/nodenv---version), the script +// echoes `nodenv ${git_revision:-$version}`, where $version is +// "1.6.2" and $git_revision is populated by `git describe` when the +// install is a git checkout (e.g., "1.6.2-12-gabc1234" — the "+" +// suffix gets rewritten by semver_compliant() to "+12.abc1234"). +// +// We strip the literal "nodenv " prefix and take the first +// whitespace-separated token, then strip an optional "v" to match +// the asdf / mise / n parsing contract. +func (nd *Nodenv) Version() (string, error) { + res, err := runShell(context.Background(), "nodenv", "--version") + if err != nil { + return "", fmt.Errorf("nodenv --version: %w", err) + } + return parseNodenvVersion(res.Stdout) +} + +// parseNodenvVersion extracts the version token from `nodenv +// --version` output. Exposed (lowercase) for direct unit testing. +// +// Real observed output (nodenv 1.6.2, packaged install): +// +// nodenv 1.6.2 +// +// Git-checkout installs emit: +// +// nodenv 1.6.2-12-gabc1234 +// +// Some forks prepend "v" (e.g., "nodenv v1.6.2"). We accept all +// three shapes — strip the "nodenv " prefix, take the first +// whitespace-separated token, strip an optional "v". +// +// We deliberately do NOT validate semver here — upstream's +// git-revision format (e.g., "1.6.2-12-gabc1234") is valid semver +// after the upstream sed rewrite, but future revisions could +// introduce new shapes that don't parse. Returning the raw token +// matches asdf/mise/n policy: the caller decides what to do with +// non-semver output. +func parseNodenvVersion(stdout string) (string, error) { + out := strings.TrimSpace(stdout) + if out == "" { + return "", errors.New("nodenv --version returned empty output") + } + // Drop the literal "nodenv " prefix. TrimPrefix is case- + // sensitive, so "Nodenv v1.6.2" from a fork won't match — + // that's intentional: we want to fail loudly on unexpected + // branding rather than silently return the wrong token. + stripped, found := strings.CutPrefix(out, "nodenv ") + if !found { + // No "nodenv " prefix at all. Either upstream changed + // its format or a fork uses different branding. Either + // way, we can't trust the output. + return "", fmt.Errorf("nodenv --version output missing 'nodenv ' prefix: %q", out) + } + out = strings.TrimSpace(stripped) + if out == "" { + return "", errors.New("nodenv --version returned no version after prefix") + } + fields := strings.Fields(out) + if len(fields) == 0 { + return "", errors.New("nodenv --version returned no tokens") + } + // Take the first token and strip an optional "v" prefix. + return strings.TrimPrefix(fields[0], "v"), nil +} + +// ListInstalled returns every Node.js version Nodenv has installed, +// sorted ascending. Source: `nodenv versions`. +// +// Per the upstream source (libexec/nodenv-versions), each line is +// formatted as either: +// +// *18.20.4 (set by /home/user/.nodenv/version) +// 20.11.1 +// system +// +// The marker is " * " (two spaces + star, no space after star) for +// the current version, and " " (two spaces, no star) for the +// others. The current-version line ALSO appends " (set by +// )" — we strip that before handing the version to semver. +// +// The `system` line is filtered out — it is not a managed Node +// version and is not semver-parseable. (If we ever support a +// `--include-system` flag for upgrade plans, we'd pass it through +// separately.) +// +// There is no "v" prefix on the version. The git-revision builds +// (e.g., "1.6.2-12-gabc1234") do NOT appear here — `nodenv versions` +// only lists installed node versions, not Nodenv itself. +// +// The upstream script exits with code 1 when no versions are +// installed and emits a "Warning: no Node detected on the system" +// line to stderr. We rely on the shell invocation's exit code +// propagation through runShell for that case — an empty stdout with +// a non-zero exit becomes an error from ListInstalled rather than a +// silent empty slice. (This matches asdf's behavior with an empty +// install.) +func (nd *Nodenv) ListInstalled() ([]semver.Version, error) { + res, err := runShell(context.Background(), "nodenv", "versions") + if err != nil { + return nil, fmt.Errorf("nodenv versions: %w", err) + } + return parseNodenvInstalled(res.Stdout) +} + +// parseNodenvInstalled turns raw `nodenv versions` output into a +// sorted-ascending []semver.Version. Exposed (lowercase) for direct +// unit testing. +// +// Lines look like: +// +// *18.20.4 (set by /home/user/.nodenv/version) +// 20.11.1 +// system +// +// The script also prints a blank line when no versions are present +// (along with the stderr warning we filter at the shell layer). +// +// We: +// 1. skip blank lines and the "system" sentinel +// 2. find the first digit and take everything from there to the +// next whitespace (drops the " (set by ...)" suffix that +// current-version lines carry) +// 3. hand the remainder to semver.NewVersion +// 4. skip lines that don't parse (forward-compat for future +// metadata formats) +// +// Returns a non-nil empty slice when no parseable versions are +// present — callers rely on this for "nodenv installed, nothing +// managed yet". +func parseNodenvInstalled(stdout string) ([]semver.Version, error) { + versions := make([]semver.Version, 0) + for _, raw := range strings.Split(stdout, "\n") { + line := strings.TrimSpace(raw) + if line == "" { + continue + } + // Filter the "system" sentinel. It's not a managed + // version and shouldn't appear in upgrade plans. + if line == "system" { + continue + } + // Find the first digit. Everything before it is the + // prefix decoration ("*", " *", " ", etc.). + idx := strings.IndexFunc(line, func(r rune) bool { + return r >= '0' && r <= '9' + }) + if idx < 0 { + // No digits at all — not a version line. + continue + } + verStr := line[idx:] + // The current-version line ends with " (set by + // )". We don't want that in the semver token, + // so stop at the first whitespace after the digits. + // Asdf doesn't emit this suffix, which is why the asdf + // parser can hand the raw slice to semver.NewVersion. + if sp := strings.IndexFunc(verStr, func(r rune) bool { + return r == ' ' || r == '\t' + }); sp >= 0 { + verStr = verStr[:sp] + } + v, err := semver.NewVersion(verStr) + if err != nil { + // Skip unparseable lines rather than aborting the + // whole list. Forward-compatibility for new + // metadata formats Nodenv might add. + 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 +// ErrNodenvNotImplemented. 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 (nd *Nodenv) Install(ver semver.Version) error { return ErrNodenvNotImplemented } +func (nd *Nodenv) Uninstall(ver semver.Version) error { return ErrNodenvNotImplemented } +func (nd *Nodenv) Use(ver semver.Version) error { return ErrNodenvNotImplemented } +func (nd *Nodenv) SetDefault(ver semver.Version) error { return ErrNodenvNotImplemented } +func (nd *Nodenv) GlobalNpmPrefix(ver semver.Version) (string, error) { + return "", ErrNodenvNotImplemented } diff --git a/internal/detector/nodenv_test.go b/internal/detector/nodenv_test.go new file mode 100644 index 0000000..f2442e3 --- /dev/null +++ b/internal/detector/nodenv_test.go @@ -0,0 +1,585 @@ +package detector + +import ( + "context" + "errors" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/Masterminds/semver/v3" + + "github.com/dipto0321/nodeup/internal/platform" +) + +// --- parseNodenvVersion ------------------------------------------------- + +func TestParseNodenvVersion_StandardOutput(t *testing.T) { + // Observed on nodenv 1.6.2 (packaged install): + // $ nodenv --version + // nodenv 1.6.2 + got, err := parseNodenvVersion("nodenv 1.6.2\n") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "1.6.2" { + t.Errorf("got %q, want %q", got, "1.6.2") + } +} + +func TestParseNodenvVersion_GitRevisionSuffix(t *testing.T) { + // Observed on a git-checkout install: + // $ nodenv --version + // nodenv 1.6.2-12-gabc1234 + // + // Upstream's sed rewrites "-N-gSHA" to "+N.SHA" before + // printing, so the actual stdout is "nodenv 1.6.2+12.abc1234". + // Either form should parse — we just take the first token. + got, err := parseNodenvVersion("nodenv 1.6.2-12-gabc1234\n") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "1.6.2-12-gabc1234" { + t.Errorf("got %q, want %q", got, "1.6.2-12-gabc1234") + } +} + +func TestParseNodenvVersion_VPrefixed(t *testing.T) { + // Defensive: some forks emit "nodenv v1.6.2". + got, err := parseNodenvVersion("nodenv v1.6.2\n") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "1.6.2" { + t.Errorf("got %q, want %q", got, "1.6.2") + } +} + +func TestParseNodenvVersion_TrailingWhitespace(t *testing.T) { + // Defensive: handle trailing whitespace. + got, err := parseNodenvVersion(" nodenv 1.6.2 \n") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "1.6.2" { + t.Errorf("got %q, want %q", got, "1.6.2") + } +} + +func TestParseNodenvVersion_Empty(t *testing.T) { + _, err := parseNodenvVersion("") + if err == nil { + t.Error("expected error for empty input") + } +} + +func TestParseNodenvVersion_WhitespaceOnly(t *testing.T) { + _, err := parseNodenvVersion("\n \n") + if err == nil { + t.Error("expected error for whitespace-only input") + } +} + +func TestParseNodenvVersion_NoVersionAfterPrefix(t *testing.T) { + // "nodenv " with nothing after it should error rather than + // silently returning "nodenv". Upstream never emits this + // shape (it's always "nodenv "), but a malformed + // wrapper or fork could. TrimSpace strips the trailing + // space, leaving the bare word "nodenv" with no version + // token — we must not return that as a version. + _, err := parseNodenvVersion("nodenv \n") + if err == nil { + t.Error("expected error for empty version after 'nodenv' prefix") + } +} + +// --- parseNodenvInstalled ----------------------------------------------- + +func TestParseNodenvInstalled_RealOutput(t *testing.T) { + // Real observed output of `nodenv versions`: + // $ nodenv versions + // *18.20.4 (set by /home/user/.nodenv/version) + // 20.11.1 + // 22.5.0 + // + // Note: `nodenv versions` (NOT `nodenv version`) emits the + // marker+version. The marker is " * " for the current version + // and " " (two spaces, no star) for the others. We strip + // the marker and keep just the version token. + stdout := " *18.20.4 (set by /home/user/.nodenv/version)\n 20.11.1\n 22.5.0\n" + got, err := parseNodenvInstalled(stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := []string{"18.20.4", "20.11.1", "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 TestParseNodenvInstalled_FiltersSystemSentinel(t *testing.T) { + // When `nodenv-which node` succeeds (system node on PATH), + // upstream emits a "system" line before the managed + // versions. We must filter it out — it isn't a managed Node + // version and isn't semver-parseable. + stdout := " system\n *18.20.4 (set by /home/user/.nodenv/version)\n 20.11.1\n" + got, err := parseNodenvInstalled(stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := []string{"18.20.4", "20.11.1"} + 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 TestParseNodenvInstalled_UnsortedInput(t *testing.T) { + // Nodenv may emit versions in install order. We must sort + // them ascending by semver before returning. + stdout := " 22.5.0\n *18.20.4 (set by /home/user/.nodenv/version)\n 20.11.1\n" + got, err := parseNodenvInstalled(stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := []string{"18.20.4", "20.11.1", "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 TestParseNodenvInstalled_EmptyStdout(t *testing.T) { + // No installed versions. Should return an empty (non-nil) + // slice, not nil and not an error. + got, err := parseNodenvInstalled("") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got == nil { + t.Error("got nil slice, want empty non-nil slice") + } + if len(got) != 0 { + t.Errorf("got %d versions, want 0", len(got)) + } +} + +func TestParseNodenvInstalled_OnlyBlankLines(t *testing.T) { + got, err := parseNodenvInstalled("\n\n\n") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got == nil { + t.Error("got nil slice, want empty non-nil slice") + } + if len(got) != 0 { + t.Errorf("got %d versions, want 0", len(got)) + } +} + +func TestParseNodenvInstalled_SkipsUnparseableLines(t *testing.T) { + // Lines that look like version lines but don't parse as + // semver should be skipped silently rather than aborting + // the whole list. Forward-compat for future metadata. + stdout := " garbage-no-version\n 20.11.1\n some-other-text\n" + got, err := parseNodenvInstalled(stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got) != 1 || got[0].String() != "20.11.1" { + t.Errorf("got %v, want [20.11.1]", got) + } +} + +// --- Method tests ------------------------------------------------------ + +func TestNodenv_Name(t *testing.T) { + if got := NewNodenv().Name(); got != "nodenv" { + t.Errorf("Name() = %q, want %q", got, "nodenv") + } +} + +func TestNodenv_Version_Success(t *testing.T) { + // Capture the exact command and args passed to runShell. + var gotName string + var gotArgs []string + withStubShell(t, + func(name string, a []string) { + gotName = name + gotArgs = a + }, + func(req string) (*platform.RunResult, error) { + return &platform.RunResult{Stdout: "nodenv 1.6.2\n"}, nil + }, + ) + + got, err := NewNodenv().Version() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "1.6.2" { + t.Errorf("Version() = %q, want %q", got, "1.6.2") + } + if gotName != "nodenv" { + t.Errorf("runShell name = %q, want %q", gotName, "nodenv") + } + if len(gotArgs) != 1 || gotArgs[0] != "--version" { + t.Errorf("runShell args = %v, want [--version]", gotArgs) + } +} + +func TestNodenv_Version_VPrefixed(t *testing.T) { + // Defensive: forks that emit "nodenv v1.6.2" should still + // yield "1.6.2". + withStubShell(t, nil, + func(req string) (*platform.RunResult, error) { + return &platform.RunResult{Stdout: "nodenv v1.6.2\n"}, nil + }, + ) + got, err := NewNodenv().Version() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "1.6.2" { + t.Errorf("Version() = %q, want %q", got, "1.6.2") + } +} + +func TestNodenv_Version_RunShellError(t *testing.T) { + // runShell error must propagate as a wrapped error so callers + // can distinguish "binary missing" from "binary present but + // version unparseable". + wantErr := errSentinelForTest + withStubShell(t, nil, + func(req string) (*platform.RunResult, error) { + return nil, wantErr + }, + ) + + _, err := NewNodenv().Version() + if err == nil { + t.Fatal("expected error from runShell failure, got nil") + } + if !errors.Is(err, wantErr) { + t.Errorf("error %v should wrap %v", err, wantErr) + } +} + +func TestNodenv_Version_ParsingError(t *testing.T) { + // runShell succeeds but the output is unparseable. The + // returned error must NOT wrap errSentinelForTest (it's a + // parser-level error, not a shell error). + withStubShell(t, nil, + func(req string) (*platform.RunResult, error) { + return &platform.RunResult{Stdout: "nodenv \n"}, nil + }, + ) + _, err := NewNodenv().Version() + if err == nil { + t.Fatal("expected parsing error, got nil") + } +} + +func TestNodenv_ListInstalled_Success(t *testing.T) { + // Capture the exact command and args passed to runShell. + var gotName string + var gotArgs []string + withStubShell(t, + func(name string, a []string) { + gotName = name + gotArgs = a + }, + func(req string) (*platform.RunResult, error) { + return &platform.RunResult{Stdout: " *18.20.4 (set by /home/user/.nodenv/version)\n 20.11.1\n 22.5.0\n"}, nil + }, + ) + + got, err := NewNodenv().ListInstalled() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := []string{"18.20.4", "20.11.1", "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) + } + } + if gotName != "nodenv" { + t.Errorf("runShell name = %q, want %q", gotName, "nodenv") + } + if len(gotArgs) != 1 || gotArgs[0] != "versions" { + t.Errorf("runShell args = %v, want [versions]", gotArgs) + } +} + +func TestNodenv_ListInstalled_EmptyStdout(t *testing.T) { + // `nodenv versions` exited 0 with no stdout — only happens + // if the upstream `versions` helper printed nothing (e.g., + // the "Warning: no Node detected" went to stderr and the + // shell capture dropped it). We treat empty stdout as "no + // versions, no error". + withStubShell(t, nil, + func(req string) (*platform.RunResult, error) { + return &platform.RunResult{Stdout: ""}, nil + }, + ) + got, err := NewNodenv().ListInstalled() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got == nil { + t.Error("got nil slice, want empty non-nil slice") + } + if len(got) != 0 { + t.Errorf("got %d versions, want 0", len(got)) + } +} + +func TestNodenv_ListInstalled_RunShellError(t *testing.T) { + // runShell failure (binary missing, permission denied, etc.) + // must propagate. This is the typical failure mode when + // `nodenv` is on PATH but no install is configured yet — + // upstream exits 1 with stderr "Warning: no Node detected + // on the system". + wantErr := errSentinelForTest + withStubShell(t, nil, + func(req string) (*platform.RunResult, error) { + return nil, wantErr + }, + ) + + _, err := NewNodenv().ListInstalled() + if err == nil { + t.Fatal("expected error from runShell failure, got nil") + } + if !errors.Is(err, wantErr) { + t.Errorf("error %v should wrap %v", err, wantErr) + } +} + +// --- Detect tests ------------------------------------------------------- + +func TestNodenv_Detect_NoBinaryNoEnvNoDir(t *testing.T) { + // All three branches false: no binary on PATH, no + // $NODENV_ROOT, no ~/.nodenv on disk. Detect() must return + // false without spawning runShell. + t.Setenv("NODENV_ROOT", "") + 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 NewNodenv().Detect() { + t.Error("Detect() = true with no PATH, no $NODENV_ROOT, and no ~/.nodenv; want false") + } +} + +func TestNodenv_Detect_FindsDirOnDisk(t *testing.T) { + // Create a real ~/.nodenv directory. We use the homeDir + // seam (rather than t.Setenv("HOME", ...)) for Windows + // portability — os.UserHomeDir reads %USERPROFILE% on + // Windows and ignores $HOME, so the env-var approach + // wouldn't redirect on Windows. + tmp := t.TempDir() + withStubHomeDir(t, tmp, nil) + t.Setenv("NODENV_ROOT", "") + + // Create ~/.nodenv. + if err := os.MkdirAll(filepath.Join(tmp, ".nodenv"), 0o755); err != nil { + t.Fatal(err) + } + + // 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 !NewNodenv().Detect() { + t.Error("Detect() = false with ~/.nodenv present, want true") + } +} + +func TestNodenv_Detect_HonorsNODENV_ROOT(t *testing.T) { + // $NODENV_ROOT set to a real directory should make Detect() + // return true even when ~/.nodenv is missing and PATH has no + // nodenv binary. This is the override path for users with a + // custom install root. + tmp := t.TempDir() + t.Setenv("NODENV_ROOT", tmp) + // homeDir returns a path with no .nodenv subdir — the + // override must take precedence over the default lookup. + withStubHomeDir(t, "/nonexistent/home", nil) + + // 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 !NewNodenv().Detect() { + t.Error("Detect() = false with $NODENV_ROOT set, want true") + } +} + +func TestNodenv_Detect_EmptyNODENV_ROOT_FallsThrough(t *testing.T) { + // $NODENV_ROOT explicitly empty string must be treated as + // "not set" — fall through to the home-dir lookup. + home := t.TempDir() + withStubHomeDir(t, home, nil) + t.Setenv("NODENV_ROOT", "") + // No ~/.nodenv under the home dir. + + // 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 NewNodenv().Detect() { + t.Error("Detect() = true with empty $NODENV_ROOT and no ~/.nodenv, want false") + } +} + +func TestNodenv_Detect_FindsBinaryOnPath(t *testing.T) { + // nodenv on PATH (as a stub binary). Detect() must return + // true. The on-disk branch is separate from this one — we + // verify that the PATH hit alone is sufficient. + // + // On Windows, exec.LookPath requires a ".exe" suffix to + // find an executable; the unadorned "nodenv" works on + // unix. Use the platform-correct filename so the test runs + // identically on linux, macOS, and Windows. + binDir := t.TempDir() + binName := "nodenv" + if runtime.GOOS == "windows" { + binName = "nodenv.exe" + } + bin := filepath.Join(binDir, binName) + if err := os.WriteFile(bin, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + // Set PATH to ONLY our bin dir. exec.LookPath walks PATH + // itself, so a single-entry PATH is sufficient and removes + // any chance of a stray nodenv binary elsewhere on the + // runner shadowing our stub. + t.Setenv("PATH", binDir) + + // Detect() must not invoke runShell — the on-disk check is + // sufficient. Force a failure if it does. + orig := runShell + called := false + runShell = func(ctx context.Context, name string, a ...string) (*platform.RunResult, error) { + called = true + return nil, nil + } + t.Cleanup(func() { runShell = orig }) + + if !NewNodenv().Detect() { + t.Error("Detect() = false with nodenv on PATH, want true") + } + if called { + t.Error("Detect() invoked runShell — must be a pure-PATH/on-disk check") + } +} + +// --- Mutation stubs ----------------------------------------------------- + +func TestNodenv_MutationMethods_NotImplemented(t *testing.T) { + // Phase 1: Install / Uninstall / Use / SetDefault / + // GlobalNpmPrefix return ErrNodenvNotImplemented. This + // sentinel lets callers distinguish "not implemented" from + // other errors via errors.Is. + nd := NewNodenv() + ver, _ := semver.NewVersion("20.0.0") + + if err := nd.Install(*ver); !errors.Is(err, ErrNodenvNotImplemented) { + t.Errorf("Install() = %v, want ErrNodenvNotImplemented", err) + } + if err := nd.Uninstall(*ver); !errors.Is(err, ErrNodenvNotImplemented) { + t.Errorf("Uninstall() = %v, want ErrNodenvNotImplemented", err) + } + if err := nd.Use(*ver); !errors.Is(err, ErrNodenvNotImplemented) { + t.Errorf("Use() = %v, want ErrNodenvNotImplemented", err) + } + if err := nd.SetDefault(*ver); !errors.Is(err, ErrNodenvNotImplemented) { + t.Errorf("SetDefault() = %v, want ErrNodenvNotImplemented", err) + } + if p, err := nd.GlobalNpmPrefix(*ver); !errors.Is(err, ErrNodenvNotImplemented) { + t.Errorf("GlobalNpmPrefix() err = %v, want ErrNodenvNotImplemented", err) + } else if p != "" { + t.Errorf("GlobalNpmPrefix() prefix = %q, want \"\"", p) + } +} + +// --- nodenvRoot helper -------------------------------------------------- + +func TestNodenv_NodenvRootHelper_PrefersEnv(t *testing.T) { + // $NODENV_ROOT takes precedence over $HOME/.nodenv. + t.Setenv("NODENV_ROOT", "/custom/nodenv") + got := nodenvRoot() + if got != "/custom/nodenv" { + t.Errorf("nodenvRoot() = %q, want %q", got, "/custom/nodenv") + } +} + +func TestNodenv_NodenvRootHelper_FallsBackToHome(t *testing.T) { + // $NODENV_ROOT unset → use $HOME/.nodenv. + t.Setenv("NODENV_ROOT", "") + withStubHomeDir(t, "/home/user", nil) + got := nodenvRoot() + want := filepath.Join("/home/user", ".nodenv") + if got != want { + t.Errorf("nodenvRoot() = %q, want %q", got, want) + } +} + +func TestNodenv_NodenvRootHelper_ReturnsEmptyWhenNoHome(t *testing.T) { + // $NODENV_ROOT unset and homeDir() errored → "". + t.Setenv("NODENV_ROOT", "") + withStubHomeDir(t, "", errSentinelForTest) + got := nodenvRoot() + if got != "" { + t.Errorf("nodenvRoot() = %q, want empty string", got) + } +} + +func TestNodenv_NodenvRootHelper_TrimsWhitespace(t *testing.T) { + // $NODENV_ROOT set to whitespace-only should be treated as + // "not set" so we fall through to the home-dir lookup. + t.Setenv("NODENV_ROOT", " ") + withStubHomeDir(t, "/home/user", nil) + got := nodenvRoot() + want := filepath.Join("/home/user", ".nodenv") + if got != want { + t.Errorf("nodenvRoot() = %q, want %q (whitespace env should not win)", got, want) + } +}