Files
goclaw/internal/skills/pip_update_executor_test.go

250 lines
7.4 KiB
Go

package skills
import (
"context"
"errors"
"os"
"path/filepath"
"runtime"
"testing"
"time"
)
// setupFixturePipForExecutor overrides pipBinary to the bundled fixture script
// and restores it via t.Cleanup. The fixture honours FIXTURE_PIP_EXIT and
// FIXTURE_PIP_STDERR environment variables for the `install` subcommand.
func setupFixturePipForExecutor(t *testing.T) {
t.Helper()
_, file, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("runtime.Caller failed")
}
fixturePath := filepath.Join(filepath.Dir(file), "testdata", "pip", "bin", "pip3")
if runtime.GOOS == "windows" {
fixturePath += ".cmd"
}
origBinary := pipBinary
origLookPath := pipLookPath
pipBinary = fixturePath
pipLookPath = func(string) (string, error) { return fixturePath, nil }
t.Cleanup(func() {
pipBinary = origBinary
pipLookPath = origLookPath
})
}
// TestPipExecutor_ValidationReject verifies that invalid package names are
// rejected before any subprocess is spawned.
func TestPipExecutor_ValidationReject(t *testing.T) {
setupFixturePipForExecutor(t)
e := NewPipUpdateExecutor()
// "typescript@latest" contains '@' which ValidatePipPackageName rejects.
err := e.Update(context.Background(), "typescript@latest", "1.0.0", nil)
if err == nil {
t.Fatal("expected error for invalid package name, got nil")
}
}
// TestPipExecutor_Success verifies that exit 0 from pip returns nil error.
func TestPipExecutor_Success(t *testing.T) {
setupFixturePipForExecutor(t)
// FIXTURE_PIP_EXIT defaults to 0 — no env override needed.
e := NewPipUpdateExecutor()
err := e.Update(context.Background(), "requests", "2.31.0", nil)
if err != nil {
t.Fatalf("unexpected error on success path: %v", err)
}
}
// TestPipExecutor_ConflictStderr verifies that stderr containing "dependency resolver"
// is classified as ErrUpdatePipConflict.
func TestPipExecutor_ConflictStderr(t *testing.T) {
setupFixturePipForExecutor(t)
t.Setenv("FIXTURE_PIP_EXIT", "1")
t.Setenv("FIXTURE_PIP_STDERR", "ERROR: pip's dependency resolver does not currently take into account all the packages that are installed.")
e := NewPipUpdateExecutor()
err := e.Update(context.Background(), "requests", "2.31.0", nil)
if err == nil {
t.Fatal("expected error, got nil")
}
if !errors.Is(err, ErrUpdatePipConflict) {
t.Errorf("errors.Is(err, ErrUpdatePipConflict) = false; err = %v", err)
}
}
// TestPipExecutor_NetworkStderr verifies that stderr containing "Read timed out"
// is classified as ErrUpdatePipNetwork.
func TestPipExecutor_NetworkStderr(t *testing.T) {
setupFixturePipForExecutor(t)
t.Setenv("FIXTURE_PIP_EXIT", "1")
t.Setenv("FIXTURE_PIP_STDERR", "Read timed out. (read timeout=15)")
e := NewPipUpdateExecutor()
err := e.Update(context.Background(), "numpy", "1.25.0", nil)
if err == nil {
t.Fatal("expected error, got nil")
}
if !errors.Is(err, ErrUpdatePipNetwork) {
t.Errorf("errors.Is(err, ErrUpdatePipNetwork) = false; err = %v", err)
}
}
// TestPipExecutor_PermissionStderr verifies that stderr containing "Permission denied"
// is classified as ErrUpdatePipPermission.
func TestPipExecutor_PermissionStderr(t *testing.T) {
setupFixturePipForExecutor(t)
t.Setenv("FIXTURE_PIP_EXIT", "1")
t.Setenv("FIXTURE_PIP_STDERR", "ERROR: Could not install packages due to an OSError: [Errno 13] Permission denied: '/usr/local/lib/python3.11'")
e := NewPipUpdateExecutor()
err := e.Update(context.Background(), "setuptools", "68.2.2", nil)
if err == nil {
t.Fatal("expected error, got nil")
}
if !errors.Is(err, ErrUpdatePipPermission) {
t.Errorf("errors.Is(err, ErrUpdatePipPermission) = false; err = %v", err)
}
}
// TestPipExecutor_PreReleaseFlag verifies that meta["preRelease"]=true causes
// --pre to be included in the pip install arguments.
// Strategy: the fixture script writes its received args to a temp file when
// FIXTURE_ARGS_FILE is set; the test reads and asserts on that file.
func TestPipExecutor_PreReleaseFlag(t *testing.T) {
// Build a custom fixture that captures args to a temp file.
argsFile := filepath.Join(t.TempDir(), "captured-args.txt")
scriptPath := filepath.Join(t.TempDir(), "pip3")
script := "#!/bin/sh\n" +
"if [ \"$1\" = \"install\" ]; then\n" +
" echo \"$@\" >> \"" + argsFile + "\"\n" +
" exit 0\n" +
"fi\n" +
"exit 2\n"
if runtime.GOOS == "windows" {
scriptPath += ".cmd"
script = "@echo off\r\n" +
"if \"%~1\"==\"install\" (\r\n" +
" echo %* >> \"" + argsFile + "\"\r\n" +
" exit /b 0\r\n" +
")\r\n" +
"exit /b 2\r\n"
}
if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil {
t.Fatalf("write arg-capture script: %v", err)
}
origBinary := pipBinary
origLookPath := pipLookPath
pipBinary = scriptPath
pipLookPath = func(string) (string, error) { return scriptPath, nil }
t.Cleanup(func() {
pipBinary = origBinary
pipLookPath = origLookPath
})
e := NewPipUpdateExecutor()
meta := map[string]any{"preRelease": true}
err := e.Update(context.Background(), "torch", "2.0.0rc2", meta)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
captured, readErr := os.ReadFile(argsFile)
if readErr != nil {
t.Fatalf("args file not written: %v", readErr)
}
argsStr := string(captured)
if argsStr == "" {
t.Fatal("args file is empty")
}
// --pre must appear in the captured install args.
found := false
for _, tok := range splitShellWords(argsStr) {
if tok == "--pre" {
found = true
break
}
}
if !found {
t.Errorf("--pre not found in captured args: %q", argsStr)
}
}
// TestPipExecutor_CtxCancel verifies that context cancellation kills the
// subprocess before it completes.
func TestPipExecutor_CtxCancel(t *testing.T) {
// Build a fixture that sleeps for 60s on install — long enough to guarantee
// the context cancel fires first.
scriptPath := filepath.Join(t.TempDir(), "pip3")
script := "#!/bin/sh\n" +
"if [ \"$1\" = \"install\" ]; then sleep 60; exit 0; fi\n" +
"exit 2\n"
if runtime.GOOS == "windows" {
scriptPath += ".cmd"
script = "@echo off\r\n" +
"if \"%~1\"==\"install\" (\r\n" +
" powershell -NoProfile -Command \"Start-Sleep -Seconds 60\"\r\n" +
" exit /b 0\r\n" +
")\r\n" +
"exit /b 2\r\n"
}
if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil {
t.Fatalf("write sleep script: %v", err)
}
origBinary := pipBinary
origLookPath := pipLookPath
pipBinary = scriptPath
pipLookPath = func(string) (string, error) { return scriptPath, nil }
t.Cleanup(func() {
pipBinary = origBinary
pipLookPath = origLookPath
})
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
e := NewPipUpdateExecutor()
start := time.Now()
err := e.Update(ctx, "torch", "2.0.0", nil)
elapsed := time.Since(start)
if err == nil {
t.Fatal("expected error after context cancel, got nil")
}
// Should complete well under the 60s sleep — allow 3s for CI overhead.
if elapsed > 3*time.Second {
t.Errorf("subprocess not killed promptly: elapsed %v", elapsed)
}
}
// splitShellWords splits a whitespace-separated string into tokens.
// Sufficient for the arg-capture assertions above; not a full shell parser.
func splitShellWords(s string) []string {
var tokens []string
inWord := false
start := 0
for i, ch := range s {
switch {
case ch == ' ' || ch == '\t' || ch == '\n' || ch == '\r':
if inWord {
tokens = append(tokens, s[start:i])
inWord = false
}
default:
if !inWord {
start = i
inWord = true
}
}
}
if inWord {
tokens = append(tokens, s[start:])
}
return tokens
}