Files
goclaw/internal/skills/npm_update_executor_test.go

168 lines
5.8 KiB
Go

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")
}
}