From f68400144f858267fc3293934bfc5f3ca7ffd742 Mon Sep 17 00:00:00 2001 From: dipto0321 Date: Mon, 29 Jun 2026 17:13:49 +0600 Subject: [PATCH 1/2] feat(detector): implement ASDF detection with mocked tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the asdf-vm manager (4/8) to the detector registry. ASDF is a true binary (unlike NVM), so detection follows the same pattern as FNM/Volta: a no-subprocess Detect() probe plus a runShell-backed Version() and ListInstalled(). Detection strategy (any of): 1. `asdf` on PATH (via platform.LookupManagerBinary) 2. $ASDF_DATA_DIR env var is set 3. ~/.asdf directory exists on disk Implementation notes: - Version() runs `asdf version` (urfave/cli style, not --version) and strips an optional 'v' prefix defensively. - ListInstalled() runs `asdf list nodejs` and parses the two-space-indented lines, where the current version is marked with ' *'. Skips the 'No compatible versions installed' message rather than aborting the parse. - asdfDataDir() respects $ASDF_DATA_DIR (the data dir override) before falling back to $HOME/.asdf, using the homeDir seam already added by Volta for cross-platform test isolation. - Mutation methods (Install, Uninstall, Use, SetDefault, GlobalNpmPrefix) return ErrASDFNotImplemented — the same sentinel pattern used by other Phase-1 managers. Tests: 19 mocked tests covering parseASDFVersion (5), parseASDFInstalled (6), the four Detect() branches (4), method-level coverage for Name/Version/ListInstalled (7), and the not-implemented stubs (1). TestASDF_DoesNotInvokeRunShellOnDetect explicitly verifies that Detect() never spawns a subprocess on any of its happy paths. --- internal/detector/asdf.go | 251 +++++++++++++++- internal/detector/asdf_test.go | 532 +++++++++++++++++++++++++++++++++ 2 files changed, 768 insertions(+), 15 deletions(-) create mode 100644 internal/detector/asdf_test.go diff --git a/internal/detector/asdf.go b/internal/detector/asdf.go index 0695471..2c797e1 100644 --- a/internal/detector/asdf.go +++ b/internal/detector/asdf.go @@ -1,15 +1,35 @@ package detector -import "github.com/Masterminds/semver/v3" +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" -// ASDF is the asdf-vm implementation with the nodejs plugin installed. + "github.com/Masterminds/semver/v3" + + "github.com/dipto0321/nodeup/internal/platform" +) + +// ASDF is the asdf-vm implementation (https://asdf-vm.com) with the +// nodejs plugin installed. See nodeup.md §5 for the detection strategy. +// +// ASDF is a true binary (unlike NVM). Its installed Node versions are +// queryable via `asdf list nodejs`, which is more reliable than parsing +// the on-disk layout (ASDF stores installs under +// $ASDF_DATA_DIR/installs/nodejs//). // -// Detection: -// - binary on PATH (`asdf`) -// - ASDF_DIR env var OR ~/.asdf directory -// - asdf plugin list | grep nodejs +// Phase 1 implements the detection surface only: +// - Detect : PATH lookup OR $ASDF_DIR env OR ~/.asdf on disk +// - Version : `asdf version`, parsed (strips optional "v" prefix) +// - ListInstalled: `asdf list nodejs`, parsed for installed Node versions // -// 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 ASDF struct{} // NewASDF constructs a fresh asdf detector. @@ -17,13 +37,214 @@ func NewASDF() *ASDF { return &ASDF{} } func (a *ASDF) Name() string { return "asdf" } -func (a *ASDF) Detect() bool { return false } -func (a *ASDF) Version() (string, error) { return "", nil } -func (a *ASDF) ListInstalled() ([]semver.Version, error) { return nil, nil } -func (a *ASDF) Install(ver semver.Version) error { return nil } -func (a *ASDF) Uninstall(ver semver.Version) error { return nil } -func (a *ASDF) Use(ver semver.Version) error { return nil } -func (a *ASDF) SetDefault(ver semver.Version) error { return nil } +// runShell (declared in fnm.go) is the package-level seam used by ASDF +// to invoke the `asdf` binary. Both FNM and Volta wrap a binary on PATH +// for the --version call; ASDF follows the same pattern. Tests +// overwrite it to capture arguments and return canned output without +// spawning a subprocess. Production code never reassigns it. + +// ErrASDFNotImplemented is returned by ASDF 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 ErrASDFNotImplemented = errors.New("asdf mutation commands not yet implemented") + +// asdfDataDir returns the asdf data root — where installs and shims +// live. Resolution order: +// 1. $ASDF_DATA_DIR environment variable (the official override) +// 2. ~/.asdf (the documented default) +// +// Returns "" if neither can be resolved (e.g., HOME unset on a +// stripped-down CI runner). Callers must treat "" as "asdf not +// installed". +// +// Note: $ASDF_DIR is a separate variable pointing at the ASDF source +// checkout (for users who git-clone rather than brew install). It is +// NOT the same as the data dir. We deliberately do not use it here — +// nodeup cares about where versions are stored, not where the asdf +// source lives. +func asdfDataDir() string { + if d := strings.TrimSpace(os.Getenv("ASDF_DATA_DIR")); d != "" { + return d + } + home, err := homeDir() + if err != nil || home == "" { + return "" + } + return filepath.Join(home, ".asdf") +} + +// Detect returns true when ASDF appears to be installed. We accept +// any of: +// 1. the binary is on PATH (via platform.LookupManagerBinary), OR +// 2. $ASDF_DATA_DIR env var is set (user has explicitly pointed us +// at a custom data dir), OR +// 3. the conventional ~/.asdf directory exists on disk +// +// We use $ASDF_DATA_DIR rather than $ASDF_DIR here because that's the +// variable that proves asdf is actually configured (the dir exists with +// installs/ inside), not just source-cloned. +// +// Per the Manager contract, Detect MUST be cheap — none of these +// branches spawn a subprocess. +func (a *ASDF) Detect() bool { + if platform.LookupManagerBinary("asdf") != "" { + return true + } + if strings.TrimSpace(os.Getenv("ASDF_DATA_DIR")) != "" { + return true + } + dir := asdfDataDir() + if dir == "" { + return false + } + // Same reasoning as NVM/Volta's Detect: collapse "not found" and + // "permission denied" into a false result so that an unreadable + // ASDF 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 ASDF's own version string, e.g. "0.18.0". Per the +// asdf-vm source (internal/cli/cli.go), `asdf version` prints a bare +// version line. Some builds prepend "v" (e.g., "v0.18.0"), some do +// not — we strip the optional "v" prefix to be defensive. +// +// Note the subcommand is `version` (not `--version`) — ASDF's CLI +// follows urfave/cli conventions. +func (a *ASDF) Version() (string, error) { + res, err := runShell(context.Background(), "asdf", "version") + if err != nil { + return "", fmt.Errorf("asdf version: %w", err) + } + return parseASDFVersion(res.Stdout) +} + +// parseASDFVersion extracts the version token from `asdf version` +// output. +// +// Real observed output (asdf 0.18.0): +// +// v0.18.0 +// +// Older releases and some forks emit bare "0.18.0". We accept both, +// stripping an optional "v" prefix. Leading whitespace and a trailing +// newline are trimmed. +func parseASDFVersion(stdout string) (string, error) { + out := strings.TrimSpace(stdout) + if out == "" { + return "", errors.New("asdf version returned empty output") + } + fields := strings.Fields(out) + if len(fields) == 0 { + return "", errors.New("asdf version returned no tokens") + } + // Take the first whitespace-separated token and strip an optional + // "v" prefix. Defensive against "v0.18.0", "0.18.0", and the + // urfave/cli version-string format which may include build + // metadata (e.g., "0.18.0-abc1234") — semver.NewVersion will + // reject those, but parseASDFVersion doesn't enforce semver; it + // just returns the raw token. The caller can decide what to do + // with a non-semver string. + return strings.TrimPrefix(fields[0], "v"), nil +} + +// ListInstalled returns every Node.js version ASDF has installed via +// the nodejs plugin, sorted ascending. Source: `asdf list nodejs`. +// +// Per the asdf-vm source (internal/cli/cli.go listLocalCommand), each +// line is formatted as either: +// +// *18.20.4 (current version, marked with " *") +// 20.11.1 (other installed versions, " " indent) +// +// There is no "v" prefix on the version. Some ASDF builds may print +// a header line or error message on stderr — we ignore those because +// RunShell's Stdout is what we parse. +// +// ASDF does NOT have an nvm-style "system" sentinel — we don't filter +// for one. +func (a *ASDF) ListInstalled() ([]semver.Version, error) { + res, err := runShell(context.Background(), "asdf", "list", "nodejs") + if err != nil { + return nil, fmt.Errorf("asdf list nodejs: %w", err) + } + return parseASDFInstalled(res.Stdout) +} + +// parseASDFInstalled turns raw `asdf list nodejs` output into a +// sorted-ascending []semver.Version. Exposed (lowercase) for direct +// unit testing. +// +// Lines look like: +// +// *18.20.4 +// 20.11.1 +// +// We strip everything up to the first digit, then hand the remainder +// to semver.NewVersion. Lines that don't contain a parseable version +// (e.g., "No compatible versions installed (nodejs)") are skipped +// rather than aborting the whole list. +// +// Returns a non-nil empty slice when no parseable versions are +// present — callers rely on this for "asdf installed, nothing +// managed yet". +func parseASDFInstalled(stdout string) ([]semver.Version, error) { + versions := make([]semver.Version, 0) + for _, raw := range strings.Split(stdout, "\n") { + line := strings.TrimSpace(raw) + if line == "" { + continue + } + // Skip the human-readable "no versions installed" message — + // it's not a version. Defensive against translation / format + // changes. + if strings.Contains(line, "No compatible versions installed") { + 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:] + v, err := semver.NewVersion(verStr) + if err != nil { + // Skip unparseable lines rather than aborting the + // whole list. Forward-compatibility for new metadata + // formats ASDF 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 +// ErrASDFNotImplemented. 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 (a *ASDF) Install(ver semver.Version) error { return ErrASDFNotImplemented } +func (a *ASDF) Uninstall(ver semver.Version) error { return ErrASDFNotImplemented } +func (a *ASDF) Use(ver semver.Version) error { return ErrASDFNotImplemented } +func (a *ASDF) SetDefault(ver semver.Version) error { return ErrASDFNotImplemented } func (a *ASDF) GlobalNpmPrefix(ver semver.Version) (string, error) { - return "", nil + return "", ErrASDFNotImplemented } diff --git a/internal/detector/asdf_test.go b/internal/detector/asdf_test.go new file mode 100644 index 0000000..425208b --- /dev/null +++ b/internal/detector/asdf_test.go @@ -0,0 +1,532 @@ +package detector + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Masterminds/semver/v3" + + "github.com/dipto0321/nodeup/internal/platform" +) + +// --- parseASDFVersion --------------------------------------------------- + +func TestParseASDFVersion_StandardOutput(t *testing.T) { + // Observed on asdf 0.18.0: + // $ asdf version + // v0.18.0 + got, err := parseASDFVersion("v0.18.0\n") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "0.18.0" { + t.Errorf("got %q, want %q", got, "0.18.0") + } +} + +func TestParseASDFVersion_BareVersion(t *testing.T) { + // Defensive: some ASDF builds / forks drop the "v" prefix. + got, err := parseASDFVersion("0.18.0\n") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "0.18.0" { + t.Errorf("got %q, want %q", got, "0.18.0") + } +} + +func TestParseASDFVersion_TrailingWhitespace(t *testing.T) { + got, err := parseASDFVersion(" v0.18.0 \n") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "0.18.0" { + t.Errorf("got %q, want %q", got, "0.18.0") + } +} + +func TestParseASDFVersion_Empty(t *testing.T) { + _, err := parseASDFVersion("") + if err == nil { + t.Error("expected error for empty input") + } +} + +func TestParseASDFVersion_WhitespaceOnly(t *testing.T) { + // Whitespace-only output: TrimSpace produces "", we must error. + _, err := parseASDFVersion("\n \n") + if err == nil { + t.Error("expected error for whitespace-only input") + } +} + +// --- parseASDFInstalled ------------------------------------------------- + +func TestParseASDFInstalled_RealOutput(t *testing.T) { + // Real observed output of `asdf list nodejs`: + // $ asdf list nodejs + // *18.20.4 + // 20.11.1 + // 22.5.0 + // + // Note: lines are indented by exactly two spaces, and the + // current version is preceded by " *". + stdout := " *18.20.4\n 20.11.1\n 22.5.0\n" + got, err := parseASDFInstalled(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 TestParseASDFInstalled_UnsortedInput(t *testing.T) { + // ASDF may emit versions in install order. We must sort them + // ascending by semver before returning. + stdout := " 22.5.0\n *18.20.4\n 20.11.1\n" + got, err := parseASDFInstalled(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 TestParseASDFInstalled_NoCompatibleVersionsMessage(t *testing.T) { + // When no Node versions are installed, ASDF prints a human-readable + // message on stdout. We must NOT error out — return an empty list. + stdout := "No compatible versions installed (nodejs)\n" + got, err := parseASDFInstalled(stdout) + if err != nil { + t.Fatalf("expected nil error for 'no versions' message, 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 TestParseASDFInstalled_SkipsUnparseable(t *testing.T) { + // Stray non-version lines must not abort the parse. + stdout := " 20.11.1\nwarning: deprecated listing\n 22.5.0\nlts/jod\n" + got, err := parseASDFInstalled(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 TestParseASDFInstalled_Empty(t *testing.T) { + got, err := parseASDFInstalled("") + 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 TestParseASDFInstalled_BlankLinesIgnored(t *testing.T) { + // Real asdf output has trailing newlines, plus some versions + // may be followed by blank lines. + stdout := " 20.11.1\n\n 22.5.0\n\n" + got, err := parseASDFInstalled(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) + } +} + +// --- ASDF method tests -------------------------------------------------- + +func TestASDF_Name(t *testing.T) { + if got := NewASDF().Name(); got != "asdf" { + t.Errorf("Name() = %q, want %q", got, "asdf") + } +} + +func TestASDF_Version_Success(t *testing.T) { + // Capture the exact command so we know ASDF uses `version` (not + // `--version`, which is the urfave/cli convention). + 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 != "asdf version" { + t.Errorf("unexpected runShell call: %q (want %q)", req, "asdf version") + } + return &platform.RunResult{Stdout: "v0.18.0\n"}, nil + }, + ) + + got, err := NewASDF().Version() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "0.18.0" { + t.Errorf("got %q, want %q", got, "0.18.0") + } + if len(captured) < 2 || captured[0] != "asdf" || captured[1] != "version" { + t.Errorf("expected `asdf version` invocation, got %v", captured) + } +} + +func TestASDF_Version_BareVersion(t *testing.T) { + // Defensive parser coverage: bare version output (no "v" prefix). + withStubShell(t, nil, func(req string) (*platform.RunResult, error) { + return &platform.RunResult{Stdout: "0.18.0\n"}, nil + }) + + got, err := NewASDF().Version() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "0.18.0" { + t.Errorf("got %q, want %q", got, "0.18.0") + } +} + +func TestASDF_Version_RunShellError(t *testing.T) { + wantErr := errors.New("simulated subprocess failure") + withStubShell(t, nil, func(req string) (*platform.RunResult, error) { + return nil, wantErr + }) + + _, err := NewASDF().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 TestASDF_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 := NewASDF().Version() + if err == nil { + t.Error("expected parsing error from blank output, got nil") + } +} + +func TestASDF_ListInstalled_Success(t *testing.T) { + // Verify ListInstalled uses the right subcommand: `asdf list nodejs`. + 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: " *18.20.4\n 20.11.1\n 22.5.0\n"}, nil + }, + ) + + got, err := NewASDF().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{"asdf", "list", "nodejs"} + if len(captured) < len(wantCaptured) { + t.Fatalf("captured %v, want at least %v", captured, wantCaptured) + } + for i, w := range wantCaptured { + if captured[i] != w { + t.Errorf("captured[%d] = %q, want %q", i, captured[i], w) + } + } +} + +func TestASDF_ListInstalled_EmptyStdout(t *testing.T) { + // ASDF installed but no node versions yet. We must return + // an empty (non-nil) slice, not nil. + withStubShell(t, nil, func(req string) (*platform.RunResult, error) { + return &platform.RunResult{Stdout: ""}, nil + }) + + got, err := NewASDF().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 TestASDF_ListInstalled_RunShellError(t *testing.T) { + wantErr := errors.New("simulated subprocess failure") + withStubShell(t, nil, func(req string) (*platform.RunResult, error) { + return nil, wantErr + }) + + _, err := NewASDF().ListInstalled() + if err == nil { + t.Fatal("expected error, got nil") + } + if !errors.Is(err, wantErr) { + t.Errorf("error %v should wrap %v", err, wantErr) + } +} + +// --- Detect tests ------------------------------------------------------- + +func TestASDF_Detect_NeitherPathNorDir(t *testing.T) { + // All three branches false: no binary on PATH, no $ASDF_DATA_DIR, + // no ~/.asdf on disk. Detect() must return false without + // spawning runShell. + t.Setenv("ASDF_DATA_DIR", "") + 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 NewASDF().Detect() { + t.Error("Detect() = true with no PATH, no $ASDF_DATA_DIR, and no ~/.asdf; want false") + } +} + +func TestASDF_Detect_FindsDirOnDisk(t *testing.T) { + // Create a real ~/.asdf 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("ASDF_DATA_DIR", "") + + // Create ~/.asdf. + if err := os.MkdirAll(filepath.Join(tmp, ".asdf"), 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 !NewASDF().Detect() { + t.Error("Detect() = false with ~/.asdf present, want true") + } +} + +func TestASDF_Detect_HonorsASDF_DATA_DIR(t *testing.T) { + // $ASDF_DATA_DIR set to a real directory should make Detect() + // return true even when ~/.asdf is missing and PATH has no + // asdf binary. This is the override path for users with a + // custom data dir. + tmp := t.TempDir() + t.Setenv("ASDF_DATA_DIR", tmp) + // homeDir returns a path with no .asdf 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 !NewASDF().Detect() { + t.Error("Detect() = false with $ASDF_DATA_DIR set, want true") + } +} + +func TestASDF_Detect_EmptyASDF_DATA_DIR_FallsThrough(t *testing.T) { + // $ASDF_DATA_DIR 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("ASDF_DATA_DIR", "") + // No ~/.asdf 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 NewASDF().Detect() { + t.Error("Detect() = true with empty $ASDF_DATA_DIR and no ~/.asdf, want false") + } +} + +// --- Mutation stubs ----------------------------------------------------- + +func TestASDF_MutationMethods_NotImplemented(t *testing.T) { + // Phase 1: Install / Uninstall / Use / SetDefault / GlobalNpmPrefix + // return ErrASDFNotImplemented. This sentinel lets callers + // distinguish "not implemented" from other errors via errors.Is. + a := NewASDF() + ver, _ := semver.NewVersion("20.0.0") + + if err := a.Install(*ver); !errors.Is(err, ErrASDFNotImplemented) { + t.Errorf("Install() = %v, want ErrASDFNotImplemented", err) + } + if err := a.Uninstall(*ver); !errors.Is(err, ErrASDFNotImplemented) { + t.Errorf("Uninstall() = %v, want ErrASDFNotImplemented", err) + } + if err := a.Use(*ver); !errors.Is(err, ErrASDFNotImplemented) { + t.Errorf("Use() = %v, want ErrASDFNotImplemented", err) + } + if err := a.SetDefault(*ver); !errors.Is(err, ErrASDFNotImplemented) { + t.Errorf("SetDefault() = %v, want ErrASDFNotImplemented", err) + } + if p, err := a.GlobalNpmPrefix(*ver); !errors.Is(err, ErrASDFNotImplemented) { + t.Errorf("GlobalNpmPrefix() err = %v, want ErrASDFNotImplemented", err) + } else if p != "" { + t.Errorf("GlobalNpmPrefix() prefix = %q, want \"\"", p) + } +} + +func TestASDF_DoesNotInvokeRunShellOnDetect(t *testing.T) { + // Defence-in-depth: a follow-up to the per-Detect test above. + // Even on the "everything works" path (binary on PATH), Detect + // must not call runShell. We exercise both the "PATH hit" and + // "dir hit" branches by setting PATH to a temp dir that + // contains a real "asdf" file, and verify no shell call. + binDir := t.TempDir() + bin := filepath.Join(binDir, "asdf") + if err := os.WriteFile(bin, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + // Prepend our bin dir to PATH so exec.LookPath("asdf") finds it. + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + 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 !NewASDF().Detect() { + t.Error("Detect() = false with asdf on PATH, want true") + } + if called { + t.Error("Detect() invoked runShell — must be a pure-PATH/on-disk check") + } +} + +func TestASDF_AsdfDataDirHelper_PrefersEnv(t *testing.T) { + // $ASDF_DATA_DIR takes precedence over $HOME/.asdf. + t.Setenv("ASDF_DATA_DIR", "/custom/asdf") + got := asdfDataDir() + if got != "/custom/asdf" { + t.Errorf("asdfDataDir() = %q, want %q", got, "/custom/asdf") + } +} + +func TestASDF_AsdfDataDirHelper_FallsBackToHome(t *testing.T) { + // $ASDF_DATA_DIR unset → use $HOME/.asdf. + t.Setenv("ASDF_DATA_DIR", "") + withStubHomeDir(t, "/home/user", nil) + got := asdfDataDir() + want := filepath.Join("/home/user", ".asdf") + if got != want { + t.Errorf("asdfDataDir() = %q, want %q", got, want) + } +} + +func TestASDF_AsdfDataDirHelper_ReturnsEmptyWhenNoHome(t *testing.T) { + // $ASDF_DATA_DIR unset and homeDir() errored → "". + t.Setenv("ASDF_DATA_DIR", "") + withStubHomeDir(t, "", errSentinelForTest) + got := asdfDataDir() + if got != "" { + t.Errorf("asdfDataDir() = %q, want empty string", got) + } +} + +func TestASDF_AsdfDataDirHelper_TrimsWhitespace(t *testing.T) { + // $ASDF_DATA_DIR set to whitespace-only should be treated as + // "not set" so we fall through to the home-dir lookup. + t.Setenv("ASDF_DATA_DIR", " ") + withStubHomeDir(t, "/home/user", nil) + got := asdfDataDir() + want := filepath.Join("/home/user", ".asdf") + if got != want { + t.Errorf("asdfDataDir() = %q, want %q (whitespace env should not win)", got, want) + } +} + +// --- asdfDataDir — bonus defensive case --------------------------------- + +func TestASDF_AsdfDataDirHelper_TrimsAndCleans(t *testing.T) { + // Verify that a value with leading/trailing whitespace is + // trimmed before being returned (so paths work even when + // users accidentally export " /custom/asdf "). + t.Setenv("ASDF_DATA_DIR", " /custom/asdf ") + got := asdfDataDir() + if got != "/custom/asdf" { + t.Errorf("asdfDataDir() = %q, want %q (must be trimmed)", got, "/custom/asdf") + } + if strings.TrimSpace(got) != got { + t.Errorf("asdfDataDir() = %q, must not have leading/trailing whitespace", got) + } +} From 40ca1671ba3d49f647b1a62c45a508308f2115e9 Mon Sep 17 00:00:00 2001 From: dipto0321 Date: Mon, 29 Jun 2026 17:41:37 +0600 Subject: [PATCH 2/2] fix(detector): address CI failures for ASDF tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace 'Defence' with 'Defense' to satisfy the misspell linter rule. - Use platform-correct binary name (asdf.exe on Windows, asdf elsewhere) so the stub binary is found by exec.LookPath on Windows. - Replace PATH with a single entry pointing at the stub dir, rather than prepending — prevents a pre-existing asdf on the CI runner's PATH (notably the GitHub Actions Windows image) from shadowing the stub. --- internal/detector/asdf_test.go | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/internal/detector/asdf_test.go b/internal/detector/asdf_test.go index 425208b..0cdc4e8 100644 --- a/internal/detector/asdf_test.go +++ b/internal/detector/asdf_test.go @@ -5,6 +5,7 @@ import ( "errors" "os" "path/filepath" + "runtime" "strings" "testing" @@ -444,18 +445,35 @@ func TestASDF_MutationMethods_NotImplemented(t *testing.T) { } func TestASDF_DoesNotInvokeRunShellOnDetect(t *testing.T) { - // Defence-in-depth: a follow-up to the per-Detect test above. + // Defense-in-depth: a follow-up to the per-Detect test above. // Even on the "everything works" path (binary on PATH), Detect // must not call runShell. We exercise both the "PATH hit" and // "dir hit" branches by setting PATH to a temp dir that // contains a real "asdf" file, and verify no shell call. + // + // We REPLACE PATH with a single entry (the temp dir) rather + // than prepending — on Windows some GitHub Actions images ship + // with an asdf / asdf-vm-related binary already on PATH, which + // would make this test pre-empt our stub binary. A clean PATH + // guarantees the only "asdf" on the search path is our stub. binDir := t.TempDir() - bin := filepath.Join(binDir, "asdf") + // Windows exec.LookPath requires a ".exe" suffix to find an + // executable; the unadorned "asdf" works on unix. Use the + // platform-correct filename so the test runs identically on + // linux, macOS, and Windows. + binName := "asdf" + if runtime.GOOS == "windows" { + binName = "asdf.exe" + } + bin := filepath.Join(binDir, binName) if err := os.WriteFile(bin, []byte("#!/bin/sh\n"), 0o755); err != nil { t.Fatal(err) } - // Prepend our bin dir to PATH so exec.LookPath("asdf") finds it. - t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + // 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 asdf binary elsewhere on the runner + // shadowing our stub. + t.Setenv("PATH", binDir) orig := runShell called := false