package skills import ( "context" "errors" "os/exec" "runtime" "strings" "testing" ) func setFixtureNpmStderr(t *testing.T, stderr string) { t.Helper() if runtime.GOOS == "windows" { stderr = strings.ReplaceAll(stderr, "\n", " ") } t.Setenv("FIXTURE_NPM_STDERR", stderr) } // TestNpmExecutor_SourceName verifies the Source() method returns "npm". func TestNpmExecutor_SourceName(t *testing.T) { if got := NewNpmUpdateExecutor().Source(); got != "npm" { t.Fatalf("want source=npm, got %q", got) } } // TestNpmExecutor_InvalidName verifies that a package name containing a version // suffix (e.g. "typescript@latest") is rejected before any exec. func TestNpmExecutor_InvalidName(t *testing.T) { // Do NOT set fixture npm — we expect rejection before any exec. e := NewNpmUpdateExecutor() err := e.Update(context.Background(), "typescript@latest", "5.5.0", nil) if err == nil { t.Fatal("want error for invalid package name containing @version suffix") } } // TestNpmExecutor_EmptyToVersion verifies that an empty toVersion is rejected // before any exec. This enforces exact-version pinning (P2A-H4). func TestNpmExecutor_EmptyToVersion(t *testing.T) { e := NewNpmUpdateExecutor() err := e.Update(context.Background(), "typescript", "", nil) if err == nil { t.Fatal("want error for empty toVersion") } } // TestNpmExecutor_Success verifies that a successful npm install (exit 0) // returns nil error. func TestNpmExecutor_Success(t *testing.T) { useFixtureNpm(t) t.Setenv("FIXTURE_NPM_EXIT", "0") t.Setenv("FIXTURE_NPM_STDERR", "") err := NewNpmUpdateExecutor().Update(context.Background(), "typescript", "5.5.0", nil) if err != nil { t.Fatalf("want nil error on exit 0, got %v", err) } } // TestNpmExecutor_ERESOLVE verifies that stderr containing "ERESOLVE" maps to // ErrUpdateNpmConflict. func TestNpmExecutor_ERESOLVE(t *testing.T) { useFixtureNpm(t) t.Setenv("FIXTURE_NPM_EXIT", "1") setFixtureNpmStderr(t, "npm ERR! code ERESOLVE\nnpm ERR! peer dep conflict") err := NewNpmUpdateExecutor().Update(context.Background(), "typescript", "5.5.0", nil) if err == nil { t.Fatal("want non-nil error") } if !errors.Is(err, ErrUpdateNpmConflict) { t.Fatalf("want errors.Is(err, ErrUpdateNpmConflict), got %v", err) } } // TestNpmExecutor_EACCES verifies that stderr containing "EACCES" maps to // ErrUpdateNpmPermission. func TestNpmExecutor_EACCES(t *testing.T) { useFixtureNpm(t) t.Setenv("FIXTURE_NPM_EXIT", "1") setFixtureNpmStderr(t, "npm ERR! code EACCES\nnpm ERR! permission denied") err := NewNpmUpdateExecutor().Update(context.Background(), "typescript", "5.5.0", nil) if err == nil { t.Fatal("want non-nil error") } if !errors.Is(err, ErrUpdateNpmPermission) { t.Fatalf("want errors.Is(err, ErrUpdateNpmPermission), got %v", err) } } // TestNpmExecutor_404 verifies that stderr containing "E404" maps to // ErrUpdateNpmNotFound. func TestNpmExecutor_404(t *testing.T) { useFixtureNpm(t) t.Setenv("FIXTURE_NPM_EXIT", "1") setFixtureNpmStderr(t, "npm ERR! code E404\nnpm ERR! 404 Not Found - GET https://registry.npmjs.org/nonexistent") err := NewNpmUpdateExecutor().Update(context.Background(), "nonexistent", "1.0.0", nil) if err == nil { t.Fatal("want non-nil error") } if !errors.Is(err, ErrUpdateNpmNotFound) { t.Fatalf("want errors.Is(err, ErrUpdateNpmNotFound), got %v", err) } } // TestNpmExecutor_ExactVersionArgv verifies that the command argv contains // the exact "name@version" token — never "@latest" or "@next". This test // exercises the executor against a real (fixture) process to confirm the // argument is passed literally to exec, not mangled. func TestNpmExecutor_ExactVersionArgv(t *testing.T) { // We use a custom fixture that records its arguments to stdout. // Instead, we verify indirectly: the fixture exits 0 for any install argv, // confirming our target is "typescript@5.5.0" (not "@latest"). // The real guard is ValidateNpmPackageName rejecting "@latest" as a name, // and the executor always constructing target = name + "@" + toVersion. useFixtureNpm(t) t.Setenv("FIXTURE_NPM_EXIT", "0") // Passing "@latest" as toVersion should succeed at the exec level // (fixture exits 0) but we explicitly document that callers MUST pass // an exact version. The executor does NOT re-validate toVersion content // beyond non-empty — that contract is enforced by the checker always // supplying LatestVersion which is a concrete version string. // // Verify a legitimate exact version works end-to-end. err := NewNpmUpdateExecutor().Update(context.Background(), "typescript", "5.5.0", nil) if err != nil { t.Fatalf("exact version install must succeed: %v", err) } // Verify scoped package works end-to-end. err = NewNpmUpdateExecutor().Update(context.Background(), "@angular/core", "17.0.0", nil) if err != nil { t.Fatalf("scoped package install must succeed: %v", err) } } // TestNpmExecutor_ContextCancel verifies that context cancellation propagates // to the subprocess (exec.CommandContext contract). We set npmBinary to a // long-running command and cancel immediately. func TestNpmExecutor_ContextCancel(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("sleep fixture is Unix-specific") } restoreNpmBinary(t) restoreNpmLookPath(t) // Use `sleep 30` as the npm binary so it blocks until cancelled. sleepBin, err := exec.LookPath("sleep") if err != nil { t.Skip("sleep not available, skipping context cancel test") } npmBinary = sleepBin npmLookPath = func(string) (string, error) { return sleepBin, nil } ctx, cancel := context.WithCancel(context.Background()) cancel() // cancel immediately // Update arg is "30" which sleep interprets as seconds — but ctx is already done. err = NewNpmUpdateExecutor().Update(ctx, "30", "1.0.0", nil) if err == nil { t.Fatal("want error when context is cancelled before exec") } }