534 lines
15 KiB
Go
534 lines
15 KiB
Go
package main
|
|
|
|
import (
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// TestPkgInstallManyBatchesDnf verifies a single batched dnf call rather
|
|
// than one per package.
|
|
func TestPkgInstallManyBatchesDnf(t *testing.T) {
|
|
defer resetMocks()
|
|
pkgMgr = "dnf"
|
|
|
|
var calls [][]string
|
|
runCmd = func(argv []string, _ CmdOpts) CmdResult {
|
|
calls = append(calls, append([]string(nil), argv...))
|
|
return CmdResult{ExitCode: 0}
|
|
}
|
|
|
|
failed := pkgInstallMany([]string{"git", "curl", "vim"})
|
|
if len(failed) != 0 {
|
|
t.Errorf("expected no failures, got %v", failed)
|
|
}
|
|
if len(calls) != 1 {
|
|
t.Fatalf("expected exactly 1 batched call, got %d: %v", len(calls), calls)
|
|
}
|
|
got := strings.Join(calls[0], " ")
|
|
if !strings.HasPrefix(got, "dnf install -y") {
|
|
t.Errorf("expected 'dnf install -y …' prefix, got: %q", got)
|
|
}
|
|
for _, pkg := range []string{"git", "curl", "vim"} {
|
|
if !strings.Contains(got, pkg) {
|
|
t.Errorf("expected %s in batched call, got: %q", pkg, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestPkgInstallManyBatchesApt verifies the same for apt-get.
|
|
func TestPkgInstallManyBatchesApt(t *testing.T) {
|
|
defer resetMocks()
|
|
pkgMgr = "apt-get"
|
|
|
|
var calls [][]string
|
|
runCmd = func(argv []string, _ CmdOpts) CmdResult {
|
|
calls = append(calls, append([]string(nil), argv...))
|
|
return CmdResult{ExitCode: 0}
|
|
}
|
|
|
|
pkgInstallMany([]string{"a", "b", "c"})
|
|
if len(calls) != 1 {
|
|
t.Fatalf("expected 1 batched call, got %d", len(calls))
|
|
}
|
|
if calls[0][0] != "apt-get" || calls[0][1] != "install" || calls[0][2] != "-y" {
|
|
t.Errorf("expected 'apt-get install -y' prefix, got: %v", calls[0])
|
|
}
|
|
}
|
|
|
|
// TestPkgInstallManyBatchesPacman verifies pacman flags.
|
|
func TestPkgInstallManyBatchesPacman(t *testing.T) {
|
|
defer resetMocks()
|
|
pkgMgr = "pacman"
|
|
|
|
var calls [][]string
|
|
runCmd = func(argv []string, _ CmdOpts) CmdResult {
|
|
calls = append(calls, append([]string(nil), argv...))
|
|
return CmdResult{ExitCode: 0}
|
|
}
|
|
|
|
pkgInstallMany([]string{"a", "b"})
|
|
if len(calls) != 1 || calls[0][0] != "pacman" {
|
|
t.Fatalf("expected single pacman call, got %v", calls)
|
|
}
|
|
joined := strings.Join(calls[0], " ")
|
|
if !strings.Contains(joined, "--noconfirm") || !strings.Contains(joined, "--needed") {
|
|
t.Errorf("expected --noconfirm --needed in pacman call, got: %q", joined)
|
|
}
|
|
}
|
|
|
|
// TestPkgInstallManyFallback verifies that a failed batch retries per-package
|
|
// and returns the failures it identifies on the per-package retry.
|
|
func TestPkgInstallManyFallback(t *testing.T) {
|
|
defer resetMocks()
|
|
pkgMgr = "dnf"
|
|
|
|
calls := 0
|
|
runCmd = func(argv []string, _ CmdOpts) CmdResult {
|
|
calls++
|
|
// Fail the first (batched) call, succeed individual retries except for "bad".
|
|
if calls == 1 {
|
|
return CmdResult{ExitCode: 1}
|
|
}
|
|
for _, a := range argv {
|
|
if a == "bad" {
|
|
return CmdResult{ExitCode: 1}
|
|
}
|
|
}
|
|
return CmdResult{ExitCode: 0}
|
|
}
|
|
|
|
failed := pkgInstallMany([]string{"good1", "good2", "bad"})
|
|
if len(failed) != 1 || failed[0] != "bad" {
|
|
t.Errorf("expected only 'bad' to fail, got %v", failed)
|
|
}
|
|
// 1 batch + 3 per-package retries = 4 calls.
|
|
if calls != 4 {
|
|
t.Errorf("expected 4 total calls (1 batch + 3 retries), got %d", calls)
|
|
}
|
|
}
|
|
|
|
// TestPkgInstallManyEmpty: no-op on empty input, no calls.
|
|
func TestPkgInstallManyEmpty(t *testing.T) {
|
|
defer resetMocks()
|
|
pkgMgr = "dnf"
|
|
called := false
|
|
runCmd = func(argv []string, _ CmdOpts) CmdResult {
|
|
called = true
|
|
return CmdResult{ExitCode: 0}
|
|
}
|
|
failed := pkgInstallMany(nil)
|
|
if len(failed) != 0 {
|
|
t.Errorf("expected no failures, got %v", failed)
|
|
}
|
|
if called {
|
|
t.Error("expected no runCmd call for empty input")
|
|
}
|
|
}
|
|
|
|
// TestPkgInstallManyBrew verifies that brew is batched into a single
|
|
// `brew install f1 f2 …` call (formulas only — no casks in this test).
|
|
// Parallel brew calls would deadlock on shared transitive-dep locks
|
|
// (cmake, ninja, libsodium, …), so we deliberately batch and serialize.
|
|
func TestPkgInstallManyBrew(t *testing.T) {
|
|
defer resetMocks()
|
|
pkgMgr = "brew"
|
|
|
|
var mu sync.Mutex
|
|
var calls [][]string
|
|
runCmd = func(argv []string, _ CmdOpts) CmdResult {
|
|
mu.Lock()
|
|
calls = append(calls, append([]string(nil), argv...))
|
|
mu.Unlock()
|
|
return CmdResult{ExitCode: 0}
|
|
}
|
|
|
|
if failed := pkgInstallMany([]string{"git", "vim", "curl"}); len(failed) != 0 {
|
|
t.Errorf("expected no failures, got %v", failed)
|
|
}
|
|
if len(calls) != 1 {
|
|
t.Fatalf("expected exactly 1 batched brew call, got %d: %v", len(calls), calls)
|
|
}
|
|
got := strings.Join(calls[0], " ")
|
|
if got != "brew install git vim curl" {
|
|
t.Errorf("expected 'brew install git vim curl', got %q", got)
|
|
}
|
|
}
|
|
|
|
// TestPkgInstallManyBrewSplitCasks verifies that casks and formulas are
|
|
// emitted in separate calls (because --cask is mutually exclusive with
|
|
// formula installs in one invocation).
|
|
func TestPkgInstallManyBrewSplitCasks(t *testing.T) {
|
|
defer resetMocks()
|
|
pkgMgr = "brew"
|
|
|
|
var calls [][]string
|
|
runCmd = func(argv []string, _ CmdOpts) CmdResult {
|
|
calls = append(calls, append([]string(nil), argv...))
|
|
return CmdResult{ExitCode: 0}
|
|
}
|
|
|
|
// "docker" is in brewCasks; the rest are formulas.
|
|
pkgInstallMany([]string{"git", "docker", "vim"})
|
|
|
|
if len(calls) != 2 {
|
|
t.Fatalf("expected 2 calls (1 formula batch + 1 cask batch), got %d: %v", len(calls), calls)
|
|
}
|
|
formula := strings.Join(calls[0], " ")
|
|
cask := strings.Join(calls[1], " ")
|
|
if formula != "brew install git vim" {
|
|
t.Errorf("expected 'brew install git vim', got %q", formula)
|
|
}
|
|
if cask != "brew install --cask docker" {
|
|
t.Errorf("expected 'brew install --cask docker', got %q", cask)
|
|
}
|
|
}
|
|
|
|
// TestPkgInstallManyBrewFallback: batched formula install fails; we retry
|
|
// per-package and identify the broken one.
|
|
func TestPkgInstallManyBrewFallback(t *testing.T) {
|
|
defer resetMocks()
|
|
pkgMgr = "brew"
|
|
|
|
calls := 0
|
|
runCmd = func(argv []string, _ CmdOpts) CmdResult {
|
|
calls++
|
|
// First call is the batch — fail it.
|
|
if calls == 1 {
|
|
return CmdResult{ExitCode: 1}
|
|
}
|
|
// Per-package retries: only "broken" fails.
|
|
if argv[len(argv)-1] == "broken" {
|
|
return CmdResult{ExitCode: 1}
|
|
}
|
|
return CmdResult{ExitCode: 0}
|
|
}
|
|
|
|
failed := pkgInstallMany([]string{"git", "broken", "curl"})
|
|
if len(failed) != 1 || failed[0] != "broken" {
|
|
t.Errorf("expected only 'broken' to fail, got %v", failed)
|
|
}
|
|
// 1 batch + 3 per-package retries = 4 calls.
|
|
if calls != 4 {
|
|
t.Errorf("expected 4 total calls, got %d", calls)
|
|
}
|
|
}
|
|
|
|
// TestInstallFlatpakBatched: a single batched flatpak install for the
|
|
// happy path.
|
|
func TestInstallFlatpakBatched(t *testing.T) {
|
|
defer resetMocks()
|
|
|
|
hasCmd = func(name string) bool { return name == "flatpak" }
|
|
var calls [][]string
|
|
runCmd = func(argv []string, _ CmdOpts) CmdResult {
|
|
calls = append(calls, append([]string(nil), argv...))
|
|
return CmdResult{ExitCode: 0}
|
|
}
|
|
|
|
installFlatpakPackages([]string{"a.app", "b.app", "c.app"})
|
|
|
|
// Expect: remote-add (1) + single batched install (1) = 2 calls.
|
|
if len(calls) != 2 {
|
|
t.Fatalf("expected 2 calls (remote-add + batched install), got %d: %v", len(calls), calls)
|
|
}
|
|
if calls[1][1] != "install" {
|
|
t.Errorf("expected install as second call, got %v", calls[1])
|
|
}
|
|
for _, app := range []string{"a.app", "b.app", "c.app"} {
|
|
found := false
|
|
for _, a := range calls[1] {
|
|
if a == app {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("expected %s in batched call, got %v", app, calls[1])
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestInstallFlatpakBatchFallback: failed batch retries per-package.
|
|
func TestInstallFlatpakBatchFallback(t *testing.T) {
|
|
defer resetMocks()
|
|
|
|
hasCmd = func(name string) bool { return name == "flatpak" }
|
|
calls := 0
|
|
runCmd = func(argv []string, _ CmdOpts) CmdResult {
|
|
calls++
|
|
// First call: remote-add (always OK)
|
|
// Second call: batched install (fail)
|
|
// Following calls: per-package retries (OK)
|
|
if calls == 2 {
|
|
return CmdResult{ExitCode: 1}
|
|
}
|
|
return CmdResult{ExitCode: 0}
|
|
}
|
|
|
|
installFlatpakPackages([]string{"a.app", "b.app"})
|
|
|
|
// 1 (remote-add) + 1 (failed batch) + 2 (per-package retries) = 4 calls.
|
|
if calls != 4 {
|
|
t.Errorf("expected 4 calls (remote-add + batch + 2 retries), got %d", calls)
|
|
}
|
|
}
|
|
|
|
// TestCheckSystemPackagesParallelOrdering: ordering preserved despite
|
|
// concurrent probes.
|
|
func TestCheckSystemPackagesParallelOrdering(t *testing.T) {
|
|
defer resetMocks()
|
|
pkgMgr = "dnf"
|
|
|
|
// odd-indexed packages "installed", even-indexed "not installed"
|
|
probe = func(argv []string, _ time.Duration) (CmdResult, bool) {
|
|
pkg := argv[len(argv)-1]
|
|
// pkg-0..pkg-7
|
|
idx := pkg[len(pkg)-1] - '0'
|
|
if idx%2 == 1 {
|
|
return CmdResult{ExitCode: 0}, true // installed
|
|
}
|
|
return CmdResult{ExitCode: 1}, true // not installed
|
|
}
|
|
|
|
names := []string{"pkg-0", "pkg-1", "pkg-2", "pkg-3", "pkg-4", "pkg-5", "pkg-6", "pkg-7"}
|
|
res := checkSystemPackages(names)
|
|
|
|
wantToInstall := []string{"pkg-0", "pkg-2", "pkg-4", "pkg-6"}
|
|
wantAlready := []string{"pkg-1", "pkg-3", "pkg-5", "pkg-7"}
|
|
if !equalStringSlices(res.toInstallRegular, wantToInstall) {
|
|
t.Errorf("toInstall: want %v, got %v", wantToInstall, res.toInstallRegular)
|
|
}
|
|
if !equalStringSlices(res.alreadyInstalled, wantAlready) {
|
|
t.Errorf("alreadyInstalled: want %v, got %v", wantAlready, res.alreadyInstalled)
|
|
}
|
|
}
|
|
|
|
func TestCheckCustomPackagesParallel(t *testing.T) {
|
|
defer resetMocks()
|
|
|
|
// pkg with InstallPath /tmp/foo-N; "installed" iff N is odd.
|
|
osStat = func(name string) (os.FileInfo, error) {
|
|
// Map: name like /tmp/foo-1 → installed; /tmp/foo-0 → not.
|
|
idx := name[len(name)-1] - '0'
|
|
if idx%2 == 1 {
|
|
return nil, nil
|
|
}
|
|
return nil, os.ErrNotExist
|
|
}
|
|
|
|
pkgs := []*CustomPackage{
|
|
{Name: "p0", InstallPath: "/tmp/foo-0"},
|
|
{Name: "p1", InstallPath: "/tmp/foo-1"},
|
|
{Name: "p2", InstallPath: "/tmp/foo-2"},
|
|
{Name: "p3", InstallPath: "/tmp/foo-3"},
|
|
}
|
|
res := checkCustomPackages(pkgs)
|
|
|
|
if len(res.toInstall) != 2 || res.toInstall[0].Name != "p0" || res.toInstall[1].Name != "p2" {
|
|
t.Errorf("toInstall: want p0,p2 in order, got %v", names(res.toInstall))
|
|
}
|
|
if len(res.alreadyInstalled) != 2 || res.alreadyInstalled[0].pkg.Name != "p1" || res.alreadyInstalled[1].pkg.Name != "p3" {
|
|
t.Errorf("already: want p1,p3 in order, got %v", customNames(res.alreadyInstalled))
|
|
}
|
|
}
|
|
|
|
func TestInstallNpmToolsBatchSinglePnpmCall(t *testing.T) {
|
|
defer resetMocks()
|
|
|
|
// Pretend ~/.nvm exists so NVM check passes.
|
|
osStat = func(name string) (os.FileInfo, error) {
|
|
if strings.HasSuffix(name, ".nvm") {
|
|
return nil, nil
|
|
}
|
|
return nil, os.ErrNotExist
|
|
}
|
|
|
|
var shellCalls []string
|
|
runShell = func(cmd string, _ CmdOpts) CmdResult {
|
|
shellCalls = append(shellCalls, cmd)
|
|
return CmdResult{ExitCode: 0}
|
|
}
|
|
|
|
pkgs := []*CustomPackage{
|
|
{Name: "claude"},
|
|
{Name: "codex"},
|
|
{Name: "copilot"},
|
|
}
|
|
installNpmToolsBatch(pkgs)
|
|
|
|
// Expect exactly one pnpm add -g call containing all three packages.
|
|
addCalls := 0
|
|
for _, c := range shellCalls {
|
|
if strings.Contains(c, "pnpm add -g") {
|
|
addCalls++
|
|
if !strings.Contains(c, "@anthropic-ai/claude-code") ||
|
|
!strings.Contains(c, "@openai/codex") ||
|
|
!strings.Contains(c, "@github/copilot") {
|
|
t.Errorf("expected all three npm names in batched call, got: %q", c)
|
|
}
|
|
}
|
|
}
|
|
if addCalls != 1 {
|
|
t.Errorf("expected exactly 1 batched pnpm add call, got %d (all calls: %v)", addCalls, shellCalls)
|
|
}
|
|
}
|
|
|
|
func TestInstallNpmToolsBatchFallback(t *testing.T) {
|
|
defer resetMocks()
|
|
osStat = func(name string) (os.FileInfo, error) {
|
|
if strings.HasSuffix(name, ".nvm") {
|
|
return nil, nil
|
|
}
|
|
return nil, os.ErrNotExist
|
|
}
|
|
|
|
calls := 0
|
|
runShell = func(cmd string, _ CmdOpts) CmdResult {
|
|
calls++
|
|
// Fail the first (batched) pnpm add call; succeed thereafter.
|
|
if calls == 1 && strings.Contains(cmd, "pnpm add -g") {
|
|
return CmdResult{ExitCode: 1}
|
|
}
|
|
return CmdResult{ExitCode: 0}
|
|
}
|
|
|
|
pkgs := []*CustomPackage{
|
|
{Name: "claude"},
|
|
{Name: "codex"},
|
|
}
|
|
installNpmToolsBatch(pkgs)
|
|
|
|
// 1 ensureNodeLTS + 1 batch + 2 per-package retries (each may emit
|
|
// 2 shell calls: ensureNodeLTS again + add). Just sanity-check that
|
|
// retries happened.
|
|
if calls < 3 {
|
|
t.Errorf("expected at least 3 shell calls after batch failure, got %d", calls)
|
|
}
|
|
}
|
|
|
|
func TestInstallCustomPackagesWavesIndependentFirst(t *testing.T) {
|
|
defer resetMocks()
|
|
|
|
// Make hasCmd / osStat permissive. ~/.nvm must "exist" so the
|
|
// npm-batch path doesn't bail out at its precondition check.
|
|
hasCmd = func(name string) bool { return true }
|
|
osStat = func(name string) (os.FileInfo, error) {
|
|
if strings.HasSuffix(name, ".nvm") {
|
|
return nil, nil
|
|
}
|
|
return nil, os.ErrNotExist
|
|
}
|
|
osReadFile = func(name string) ([]byte, error) {
|
|
return []byte{}, os.ErrNotExist
|
|
}
|
|
|
|
var orderMu sync.Mutex
|
|
var order []string
|
|
runCmd = func(argv []string, _ CmdOpts) CmdResult {
|
|
orderMu.Lock()
|
|
order = append(order, strings.Join(argv, " "))
|
|
orderMu.Unlock()
|
|
return CmdResult{ExitCode: 0}
|
|
}
|
|
runShell = func(cmd string, _ CmdOpts) CmdResult {
|
|
orderMu.Lock()
|
|
order = append(order, cmd)
|
|
orderMu.Unlock()
|
|
return CmdResult{ExitCode: 0}
|
|
}
|
|
download = func(_, _ string) bool { return true }
|
|
fetchJSON = func(_ string, _ any) bool { return false }
|
|
fetchText = func(_ string) string { return "" }
|
|
|
|
// Mix of independent + node-dependent. We just verify dispatch order:
|
|
// the npm-batched call must appear after some Wave A activity.
|
|
pkgs := []*CustomPackage{
|
|
{Name: "agy"},
|
|
{Name: "oh-my-zsh"},
|
|
{Name: "claude"},
|
|
{Name: "codex"},
|
|
}
|
|
installCustomPackages(pkgs)
|
|
|
|
// The pnpm add -g call must exist and appear after agy/oh-my-zsh
|
|
// install attempts.
|
|
var firstBatchIdx, firstWaveAIdx int = -1, -1
|
|
for i, c := range order {
|
|
if strings.Contains(c, "pnpm add -g @anthropic-ai/claude-code") {
|
|
firstBatchIdx = i
|
|
}
|
|
if (strings.Contains(c, "antigravity.google") || strings.Contains(c, "ohmyzsh")) && firstWaveAIdx == -1 {
|
|
firstWaveAIdx = i
|
|
}
|
|
}
|
|
if firstWaveAIdx == -1 {
|
|
t.Errorf("expected to see Wave A activity (agy/oh-my-zsh), got order: %v", order)
|
|
}
|
|
if firstBatchIdx == -1 {
|
|
t.Errorf("expected to see batched pnpm add call, got order: %v", order)
|
|
}
|
|
if firstWaveAIdx > firstBatchIdx {
|
|
t.Errorf("expected Wave A activity to begin before Wave B batch, got waveA@%d batch@%d", firstWaveAIdx, firstBatchIdx)
|
|
}
|
|
}
|
|
|
|
func TestResolveLatestAllRunsInParallel(t *testing.T) {
|
|
defer resetMocks()
|
|
|
|
// Register a custom resolver that records start order.
|
|
var mu sync.Mutex
|
|
var starts []string
|
|
|
|
latestResolvers["test-fast"] = func(p *CustomPackage) (string, string, bool) {
|
|
mu.Lock()
|
|
starts = append(starts, p.Name)
|
|
mu.Unlock()
|
|
return p.Version, p.SHA256, true
|
|
}
|
|
defer delete(latestResolvers, "test-fast")
|
|
|
|
pkgs := []*CustomPackage{
|
|
{Name: "x", Version: "1", SHA256: "a", FetchLatest: "test-fast"},
|
|
{Name: "y", Version: "2", SHA256: "b", FetchLatest: "test-fast"},
|
|
{Name: "z", Version: "3", SHA256: "c", FetchLatest: "test-fast"},
|
|
}
|
|
resolveLatestAll(pkgs)
|
|
|
|
if len(starts) != 3 {
|
|
t.Errorf("expected all 3 resolvers invoked, got %d: %v", len(starts), starts)
|
|
}
|
|
}
|
|
|
|
// ── small helpers/fakes ────────────────────────────────────────────────
|
|
|
|
func equalStringSlices(a, b []string) bool {
|
|
if len(a) != len(b) {
|
|
return false
|
|
}
|
|
for i := range a {
|
|
if a[i] != b[i] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func names(pkgs []*CustomPackage) []string {
|
|
out := make([]string, len(pkgs))
|
|
for i, p := range pkgs {
|
|
out[i] = p.Name
|
|
}
|
|
return out
|
|
}
|
|
|
|
func customNames(s []customStatus) []string {
|
|
out := make([]string, len(s))
|
|
for i, st := range s {
|
|
out[i] = st.pkg.Name
|
|
}
|
|
return out
|
|
}
|