diff --git a/internal/detector/n.go b/internal/detector/n.go index 203e97d..3d76a7a 100644 --- a/internal/detector/n.go +++ b/internal/detector/n.go @@ -1,11 +1,48 @@ package detector -import "github.com/Masterminds/semver/v3" +import ( + "context" + "errors" + "fmt" + "sort" + "strings" -// N is the npm-based version manager (https://github.com/tj/n). Simple -// binary installed via npm, prefix configurable via N_PREFIX. + "github.com/Masterminds/semver/v3" + + "github.com/dipto0321/nodeup/internal/platform" +) + +// N is the tj/n implementation (https://github.com/tj/n) — a simple, +// no-subshell Node.js version manager distributed via npm. Unlike +// NVM (a shell function) or Volta (a Rust binary), n is a bash +// script that drops each version into $N_PREFIX/n/versions// +// and symlinks a single "active" version into $N_PREFIX/bin/. +// +// Note: n is documented as Unix-only. The README explicitly states +// it does not work in native Windows shells (PowerShell, Git for +// Windows BASH, or Cygwin). On Windows the user must use WSL or +// fall back to NVMWindows / Nodenv / Volta. We still implement +// the detector uniformly — Detect() on Windows simply returns +// false when there's no `n.exe` on PATH. +// +// Detection is intentionally simple: +// - binary on PATH (`n`) via platform.LookupManagerBinary +// +// We do NOT check $N_PREFIX here for the same reason as Mise: the +// CLI is the authoritative source. Without the binary, we cannot +// query installed versions or invoke mutations. +// +// Phase 1 implements the detection surface only: +// - Detect : PATH lookup (platform.LookupManagerBinary) +// - Version : `n --version`, parsed (returns the script's +// VERSION line verbatim — typically bare "10.2.0" with no "v" +// prefix) +// - ListInstalled: `n ls`, parsed from "node/" lines // -// Implementation status: stub. Real implementation lands in Phase 5. +// 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 N struct{} // NewN constructs a fresh n detector. @@ -13,13 +50,201 @@ func NewN() *N { return &N{} } func (n *N) Name() string { return "n" } -func (n *N) Detect() bool { return false } -func (n *N) Version() (string, error) { return "", nil } -func (n *N) ListInstalled() ([]semver.Version, error) { return nil, nil } -func (n *N) Install(ver semver.Version) error { return nil } -func (n *N) Uninstall(ver semver.Version) error { return nil } -func (n *N) Use(ver semver.Version) error { return nil } -func (n *N) SetDefault(ver semver.Version) error { return nil } +// runShell (declared in fnm.go) is the package-level seam used by +// N to invoke the `n` binary. Tests overwrite it to capture +// arguments and return canned output without spawning a +// subprocess. Production code never reassigns it. + +// ErrNNotImplemented is returned by N 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 ErrNNotImplemented = errors.New("n mutation commands not yet implemented") + +// Detect returns true when `n` appears to be installed — i.e., the +// binary is on PATH. +// +// We deliberately do NOT check $N_PREFIX alone: $N_PREFIX points +// at a directory layout, but the actual `n` script lives under +// $N_PREFIX/bin/n. If that script is not on PATH the user cannot +// invoke `n ls` / `n install` / etc., so the directory alone is +// not a usable install signal. PATH lookup is the only branch. +// +// Note: this is the inverse of asdf's Detect() which accepts +// either PATH OR the data dir. The asymmetry is intentional — +// asdf is a binary everywhere, while n is a binary only when its +// bin/ dir is on PATH. +// +// Per the Manager contract, Detect MUST be cheap — exec.LookPath +// is a single stat walk. +func (n *N) Detect() bool { + return platform.LookupManagerBinary("n") != "" +} + +// Version returns n's own version string. +// +// Per the upstream script (bin/n, display_n_version()), `n +// --version` and `n -V` both invoke `echo "$VERSION" && exit 0`. +// The VERSION variable is set at the top of the script (e.g., +// `VERSION="10.2.0"`), so the output is the bare semver followed +// by a newline. No "v" prefix, no trailing metadata. +// +// We delegate parsing to parseNVersion for testability. The +// function is defensive against: +// +// - trailing newline (always present from echo) +// - leading/trailing whitespace (rare but possible if the +// user pipes through xargs or similar) +// - an optional "v" prefix (none observed upstream, but some +// forks and pre-release builds prepend it) +// +// We deliberately do NOT validate semver here — `n`'s version +// line is a script-internal constant, and future upstream could +// switch to CalVer (matching the mise/mise pattern) without +// breaking the contract. Consistent with parseASDFVersion / +// parseMiseVersion. +func (n *N) Version() (string, error) { + res, err := runShell(context.Background(), "n", "--version") + if err != nil { + return "", fmt.Errorf("n --version: %w", err) + } + return parseNVersion(res.Stdout) +} + +// parseNVersion extracts the version token from `n --version` +// output. Exposed (lowercase) for direct unit testing. +// +// Real observed output (n 10.2.0): +// +// 10.2.0 +// +// The script does `echo "$VERSION" && exit 0`, so the output is +// a single line followed by a newline. We split on whitespace, +// take the first token, and strip an optional "v" prefix. +func parseNVersion(stdout string) (string, error) { + out := strings.TrimSpace(stdout) + if out == "" { + return "", errors.New("n --version returned empty output") + } + fields := strings.Fields(out) + if len(fields) == 0 { + return "", errors.New("n --version returned no tokens") + } + return strings.TrimPrefix(fields[0], "v"), nil +} + +// ListInstalled returns every Node.js version n has installed, +// sorted ascending. Source: `n ls`. +// +// Per the upstream script (bin/n, display_versions_paths), `n ls` +// internally runs: +// +// find "$CACHE_DIR" -maxdepth 2 -type d \ +// | sed 's|'"$CACHE_DIR"'/||g' \ +// | grep -E "/[0-9]+\.[0-9]+\.[0-9]+" \ +// | sed 's|/|.|' \ +// | sort -k 1,1 -k 2,2n -k 3,3n -k 4,4n -t . \ +// | sed 's|\.|/|' +// +// where $CACHE_DIR defaults to $N_PREFIX/n/versions (or +// $N_CACHE_PREFIX/n/versions if set). +// +// The output is a list of "node/" lines, one per +// installed version. The grep regex requires "MAJOR.MINOR.PATCH" +// — so nightly, lts aliases, and other non-semver specifiers are +// filtered upstream before we ever see them. +// +// The sort key is numeric on the three semver components, so the +// list comes back pre-sorted by n itself. We re-sort defensively +// in case upstream's sort ever changes (the cost is negligible). +// +// Note: `n ls` exits with code 0 even when no versions are +// installed — in that case the find produces no output and we +// get an empty stdout. We map that to an empty (non-nil) slice +// rather than an error, matching asdf/mise behavior. +// +// Note: n does not have a "system" sentinel — we don't filter +// for one. +func (n *N) ListInstalled() ([]semver.Version, error) { + res, err := runShell(context.Background(), "n", "ls") + if err != nil { + return nil, fmt.Errorf("n ls: %w", err) + } + return parseNInstalled(res.Stdout) +} + +// parseNInstalled turns raw `n ls` output into a sorted- +// ascending []semver.Version. Exposed (lowercase) for direct unit +// testing. +// +// Expected input format (one entry per line): +// +// node/20.11.1 +// node/22.5.0 +// +// Lines that don't match the expected "tool/version" shape (e.g., +// `node` alone, blank lines, or output from a future n version +// that adds metadata) are skipped rather than aborting the whole +// list. Forward-compatibility for upstream formatting changes. +// +// We use `path.Base` semantics (split on "/" and take the last +// element) so the parser is robust to: +// - tool name prefixes other than "node" (mise's plugin model +// could theoretically apply to n via wrapper scripts) +// - extra path components in a hypothetical future n layout +// +// Returns a non-nil empty slice when no parseable versions are +// present — callers rely on this for "n installed, nothing +// managed yet". +func parseNInstalled(stdout string) ([]semver.Version, error) { + versions := make([]semver.Version, 0) + for _, raw := range strings.Split(stdout, "\n") { + line := strings.TrimSpace(raw) + if line == "" { + continue + } + // Extract the last "/" component. n emits "node/", so + // the version is the part after the slash. Using + // strings.LastIndex is more robust than strings.SplitN + // because it tolerates paths with more components. + slash := strings.LastIndex(line, "/") + if slash < 0 || slash == len(line)-1 { + // No slash, or trailing slash with no version after + // it — not a version line. + continue + } + verStr := line[slash+1:] + v, err := semver.NewVersion(verStr) + if err != nil { + // Skip unparseable versions rather than aborting the + // whole list. Forward-compatibility for new metadata + // formats n might add. + continue + } + versions = append(versions, *v) + } + // n already sorts upstream, but re-sort defensively in case + // the sort algorithm ever changes. Cost is negligible for + // typical install counts (< 20). + 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 +// ErrNNotImplemented. 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 (n *N) Install(ver semver.Version) error { return ErrNNotImplemented } +func (n *N) Uninstall(ver semver.Version) error { return ErrNNotImplemented } +func (n *N) Use(ver semver.Version) error { return ErrNNotImplemented } +func (n *N) SetDefault(ver semver.Version) error { return ErrNNotImplemented } func (n *N) GlobalNpmPrefix(ver semver.Version) (string, error) { - return "", nil + return "", ErrNNotImplemented } diff --git a/internal/detector/n_test.go b/internal/detector/n_test.go new file mode 100644 index 0000000..3b5f4e9 --- /dev/null +++ b/internal/detector/n_test.go @@ -0,0 +1,482 @@ +package detector + +import ( + "context" + "errors" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/Masterminds/semver/v3" + + "github.com/dipto0321/nodeup/internal/platform" +) + +// --- parseNVersion ------------------------------------------------------ + +func TestParseNVersion_StandardOutput(t *testing.T) { + // Real observed output of `n --version` (n 10.2.0): + // $ n --version + // 10.2.0 + // + // The upstream script does `echo "$VERSION" && exit 0` — no + // "v" prefix, single line, trailing newline. + got, err := parseNVersion("10.2.0\n") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "10.2.0" { + t.Errorf("got %q, want %q", got, "10.2.0") + } +} + +func TestParseNVersion_VPrefixed(t *testing.T) { + // Defensive: some forks / pre-release builds may prepend "v". + got, err := parseNVersion("v10.2.0\n") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "10.2.0" { + t.Errorf("got %q, want %q", got, "10.2.0") + } +} + +func TestParseNVersion_LeadingTrailingWhitespace(t *testing.T) { + // Whitespace-only differences must not affect parsing. + got, err := parseNVersion(" 10.2.0 \n") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "10.2.0" { + t.Errorf("got %q, want %q", got, "10.2.0") + } +} + +func TestParseNVersion_Empty(t *testing.T) { + _, err := parseNVersion("") + if err == nil { + t.Error("expected error for empty input, got nil") + } +} + +func TestParseNVersion_WhitespaceOnly(t *testing.T) { + // Whitespace-only output: TrimSpace produces "", we must error. + _, err := parseNVersion("\n \n") + if err == nil { + t.Error("expected error for whitespace-only input, got nil") + } +} + +// --- parseNInstalled ---------------------------------------------------- + +func TestParseNInstalled_RealOutput(t *testing.T) { + // Real observed output of `n ls`: + // $ n ls + // node/18.20.4 + // node/20.11.1 + // node/22.5.0 + // + // Per upstream `display_versions_paths` (bin/n): + // find "$CACHE_DIR" -maxdepth 2 -type d + // | sed 's|...CACHE_DIR.../||g' + // | grep -E "/[0-9]+\.[0-9]+\.[0-9]+" + // | sort ... + // The grep regex filters out anything that doesn't have a + // MAJOR.MINOR.PATCH semver. + stdout := "node/18.20.4\nnode/20.11.1\nnode/22.5.0\n" + got, err := parseNInstalled(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 TestParseNInstalled_UnsortedInput(t *testing.T) { + // n's own sort is numeric by semver components, but a future + // upstream change could break that. Re-sort defensively. + stdout := "node/22.5.0\nnode/18.20.4\nnode/20.11.1\n" + got, err := parseNInstalled(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 TestParseNInstalled_Empty(t *testing.T) { + // Empty stdout: n installed but no node versions yet. n ls + // exits 0 with no output, which we map to an empty (non-nil) + // slice. + got, err := parseNInstalled("") + if err != nil { + t.Fatalf("unexpected error for empty stdout: %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 TestParseNInstalled_BlankLinesIgnored(t *testing.T) { + // Real n output has trailing newlines, plus possibly blank + // lines between entries on some shells. + stdout := "node/20.11.1\n\nnode/22.5.0\n\n" + got, err := parseNInstalled(stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := []string{"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 TestParseNInstalled_SkipsMalformedLines(t *testing.T) { + // Lines without a slash, or with a trailing slash but no + // version, must not abort the parse. + stdout := "node/20.11.1\nnode\nnode/\nnode/22.5.0\n" + got, err := parseNInstalled(stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := []string{"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 TestParseNInstalled_SkipsUnparseableVersions(t *testing.T) { + // Non-semver version strings must be skipped, not abort. + stdout := "node/20.11.1\nnode/latest\nnode/22.5.0\n" + got, err := parseNInstalled(stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := []string{"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 TestParseNInstalled_HandlesDeeperPaths(t *testing.T) { + // A future n version might add an extra path component + // (e.g., "node/v20/20.11.1"). We use LastIndex so the parser + // is robust to that. + stdout := "node/v20/20.11.1\nnode/v22/22.5.0\n" + got, err := parseNInstalled(stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := []string{"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) + } + } +} + +// --- N method tests ----------------------------------------------------- + +func TestN_Name(t *testing.T) { + if got := NewN().Name(); got != "n" { + t.Errorf("Name() = %q, want %q", got, "n") + } +} + +func TestN_Version_Success(t *testing.T) { + // Capture the exact command so we know N uses `--version` + // (matching most CLIs; not `version` like ASDF). + 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 != "n --version" { + t.Errorf("unexpected runShell call: %q (want %q)", req, "n --version") + } + return &platform.RunResult{Stdout: "10.2.0\n"}, nil + }, + ) + + got, err := NewN().Version() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "10.2.0" { + t.Errorf("got %q, want %q", got, "10.2.0") + } + wantCaptured := []string{"n", "--version"} + if len(captured) != len(wantCaptured) { + t.Fatalf("captured %v, want %v", captured, wantCaptured) + } + for i, w := range wantCaptured { + if captured[i] != w { + t.Errorf("captured[%d] = %q, want %q", i, captured[i], w) + } + } +} + +func TestN_Version_VPrefixed(t *testing.T) { + // Defensive parser coverage: v-prefixed output (some forks). + withStubShell(t, nil, func(req string) (*platform.RunResult, error) { + return &platform.RunResult{Stdout: "v10.2.0\n"}, nil + }) + + got, err := NewN().Version() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "10.2.0" { + t.Errorf("got %q, want %q", got, "10.2.0") + } +} + +func TestN_Version_RunShellError(t *testing.T) { + wantErr := errors.New("simulated subprocess failure") + withStubShell(t, nil, func(req string) (*platform.RunResult, error) { + return nil, wantErr + }) + + _, err := NewN().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 TestN_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 := NewN().Version() + if err == nil { + t.Error("expected parsing error from blank output, got nil") + } +} + +func TestN_ListInstalled_Success(t *testing.T) { + // Verify ListInstalled uses the right subcommand: `n ls`. + var captured []string + withStubShell(t, + func(name string, a []string) { + captured = append(captured, name) + captured = append(captured, a...) + }, + func(req string) (*platform.RunResult, error) { + return &platform.RunResult{Stdout: "node/18.20.4\nnode/20.11.1\nnode/22.5.0\n"}, nil + }, + ) + + got, err := NewN().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) + } + } + wantCaptured := []string{"n", "ls"} + if len(captured) != len(wantCaptured) { + t.Fatalf("captured %v, want %v", captured, wantCaptured) + } + for i, w := range wantCaptured { + if captured[i] != w { + t.Errorf("captured[%d] = %q, want %q", i, captured[i], w) + } + } +} + +func TestN_ListInstalled_EmptyStdout(t *testing.T) { + // n installed but no node versions yet. `n ls` prints nothing + // and exits 0; we must return an empty (non-nil) slice. + withStubShell(t, nil, func(req string) (*platform.RunResult, error) { + return &platform.RunResult{Stdout: ""}, nil + }) + + got, err := NewN().ListInstalled() + if err != nil { + t.Fatalf("unexpected error: %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 TestN_ListInstalled_RunShellError(t *testing.T) { + wantErr := errors.New("simulated subprocess failure") + withStubShell(t, nil, func(req string) (*platform.RunResult, error) { + return nil, wantErr + }) + + _, err := NewN().ListInstalled() + if err == nil { + t.Fatal("expected error, got nil") + } + if !errors.Is(err, wantErr) { + t.Errorf("error %v should wrap %v", err, wantErr) + } +} + +func TestN_ListInstalled_SkipsUnparseableLines(t *testing.T) { + // runShell succeeded but the body has malformed lines. We + // must not abort — just skip and return what parsed. + withStubShell(t, nil, func(req string) (*platform.RunResult, error) { + return &platform.RunResult{Stdout: "node/20.11.1\nnode/latest\nnode/22.5.0\n"}, nil + }) + + got, err := NewN().ListInstalled() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := []string{"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) + } + } +} + +// --- Detect tests ------------------------------------------------------- + +func TestN_Detect_NoBinaryOnPath(t *testing.T) { + // No `n` binary on PATH. Detect() must return false. + // + // We REPLACE PATH with an empty temp dir so no stray n + // binary on the runner shadows our negative test. On + // Windows, exec.LookPath requires a ".exe" suffix, so an + // empty dir is a clean negative regardless of platform. + t.Setenv("PATH", t.TempDir()) + + // 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 NewN().Detect() { + t.Error("Detect() = true with no n on PATH, want false") + } +} + +func TestN_Detect_FindsBinaryOnPath(t *testing.T) { + // n on PATH (as a stub binary). Detect() must return true. + // + // On Windows, exec.LookPath requires a ".exe" suffix to find + // an executable; the unadorned "n" works on unix. Use the + // platform-correct filename so the test runs identically on + // linux, macOS, and Windows. + binDir := t.TempDir() + binName := "n" + if runtime.GOOS == "windows" { + binName = "n.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 so the stub is the only + // candidate. exec.LookPath walks PATH itself, so a single- + // entry PATH is sufficient. + 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 !NewN().Detect() { + t.Error("Detect() = false with n on PATH, want true") + } + if called { + t.Error("Detect() invoked runShell — must be a pure-PATH check") + } +} + +// --- Mutation stubs ----------------------------------------------------- + +func TestN_MutationMethods_NotImplemented(t *testing.T) { + // Phase 1: Install / Uninstall / Use / SetDefault / + // GlobalNpmPrefix return ErrNNotImplemented. This sentinel + // lets callers distinguish "not implemented" from other + // errors via errors.Is. + m := NewN() + ver, _ := semver.NewVersion("20.0.0") + + if err := m.Install(*ver); !errors.Is(err, ErrNNotImplemented) { + t.Errorf("Install() = %v, want ErrNNotImplemented", err) + } + if err := m.Uninstall(*ver); !errors.Is(err, ErrNNotImplemented) { + t.Errorf("Uninstall() = %v, want ErrNNotImplemented", err) + } + if err := m.Use(*ver); !errors.Is(err, ErrNNotImplemented) { + t.Errorf("Use() = %v, want ErrNNotImplemented", err) + } + if err := m.SetDefault(*ver); !errors.Is(err, ErrNNotImplemented) { + t.Errorf("SetDefault() = %v, want ErrNNotImplemented", err) + } + if p, err := m.GlobalNpmPrefix(*ver); !errors.Is(err, ErrNNotImplemented) { + t.Errorf("GlobalNpmPrefix() err = %v, want ErrNNotImplemented", err) + } else if p != "" { + t.Errorf("GlobalNpmPrefix() prefix = %q, want \"\"", p) + } +}