macOS checks a newly written executable before its first exec: 0.27s alone, 2-5s while other new programs start (a go test ./... starting its test binaries). The same file again takes 0.009s, and the pass is kept across a rename and an in-place rewrite.108852b4worked round this for one test with /bin/sh -c; the class was every test that writes a `#!/bin/sh` fake and times its answer. testenv.Program(t, path, script) replaces os.WriteFile(path, script, 0o755) for fakes. On macOS it renames a file that has run once from a pool to path and writes the script into it in place; at the test's cleanup the file goes back to the pool (only a path the test created; a stand-in it wrote over stays). Fakes still end with their tests: nothing about how they are started or waited on changed. Run (and gateway's TestMain) remove the pool at the end. TestMain's PATH stand-ins are run once in the background (testenv.StandIns). Other systems write the file as before. Converted every `#!/bin/sh` fake written by tests under internal/ (agent, gateway, gui, library, plugin, proc, provider, update). Windows, binary and node/bun fakes are unchanged. Under the same load (3 loops each starting 8 freshly written scripts at a time), 4 rounds each, before (108852b4/ origin/main) vs after: - provider TestSavedButSignedOutClaudeServed: 4/4 fail (8.01s) -> 4/4 pass - provider TestClaudeSignInOverStaleCredentials: 4/4 fail (5.00s) -> 4/4 pass - provider TestDevinSignedInOnlyInMagpie: 3/4 fail ("CLI's own []") -> 4/4 pass - gateway TestClaudeRunEndsWhenTheClientLeaves: 4/4 fail -> 4/4 pass (and 3/3 fail -> 3/3 pass at a third of that load) - gateway TestClientGoneRecordedAsCanceled: 4/4 fail -> 4/4 pass - gui TestProvidersAnswerWhileListsComeIn ("cursor found false" at 3.03s): 4/4 fail -> 4/4 pass. The same class, not bfe8bcaf's test order. - proc TestUserPath (login shell's PATH never came, 7.01s): 4/4 fail -> 4/4 pass Unloaded, every touched package passes; no pool directory or process is left after a run.
177 lines
7.5 KiB
Go
177 lines
7.5 KiB
Go
package agent
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/yetone/magpie/internal/testenv"
|
|
)
|
|
|
|
// The install commands are the vendors' own (#727): the installer their
|
|
// docs give first, then npm's package, the one the update uses; per OS.
|
|
func TestInstallCommands(t *testing.T) {
|
|
cmds := func(id, goos string) []string {
|
|
var out []string
|
|
for _, c := range installCommands(id, goos, true) {
|
|
out = append(out, c.Via+": "+c.Command)
|
|
}
|
|
return out
|
|
}
|
|
for _, c := range []struct {
|
|
id, goos string
|
|
want []string
|
|
}{
|
|
{"claude", "darwin", []string{"script: curl -fsSL https://claude.ai/install.sh | bash", "npm: npm install -g @anthropic-ai/claude-code"}},
|
|
{"claude", "windows", []string{"powershell: irm https://claude.ai/install.ps1 | iex", "npm: npm install -g @anthropic-ai/claude-code"}},
|
|
{"codex", "darwin", []string{"script: curl -fsSL https://chatgpt.com/codex/install.sh | sh", "brew: brew install --cask codex", "npm: npm install -g @openai/codex"}},
|
|
{"codex", "linux", []string{"script: curl -fsSL https://chatgpt.com/codex/install.sh | sh", "npm: npm install -g @openai/codex"}},
|
|
{"gemini", "linux", []string{"brew: brew install gemini-cli", "npm: npm install -g @google/gemini-cli"}},
|
|
{"gemini", "windows", []string{"npm: npm install -g @google/gemini-cli"}},
|
|
{"opencode", "darwin", []string{"script: curl -fsSL https://opencode.ai/install | bash", "npm: npm install -g opencode-ai"}},
|
|
{"opencode", "windows", []string{"npm: npm install -g opencode-ai"}},
|
|
{"pi", "linux", []string{"npm: npm install -g @earendil-works/pi-coding-agent"}},
|
|
{"cursor", "darwin", nil}, // no command magpie knows
|
|
} {
|
|
if got := cmds(c.id, c.goos); !reflect.DeepEqual(got, c.want) {
|
|
t.Errorf("%s on %s: %q, want %q", c.id, c.goos, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Only the agents not on this machine are offered, and not WSL's twins.
|
|
func TestInstallsOnlyMissing(t *testing.T) {
|
|
home := t.TempDir()
|
|
t.Setenv("PATH", t.TempDir())
|
|
here := &Agent{ID: "codex", Name: "Codex", Path: filepath.Join(home, "codex.toml"), Dir: home}
|
|
gone := &Agent{ID: "gemini", Name: "Gemini CLI", Icon: "gemini", Path: filepath.Join(home, "nope", "settings.json"), Bin: "gemini"}
|
|
wsl := &Agent{ID: "claude@wsl:Ubuntu", Name: "Claude Code", WSL: "Ubuntu", Path: filepath.Join(home, "nope", "x")}
|
|
none := &Agent{ID: "cursor", Name: "Cursor", Path: filepath.Join(home, "nope", "y")}
|
|
got := installsOf([]*Agent{here, gone, wsl, none}, "darwin", true)
|
|
if len(got) != 1 || got[0].ID != "gemini" || got[0].Name != "Gemini CLI" || got[0].Icon != "gemini" || len(got[0].Commands) != 2 {
|
|
t.Fatalf("installs: %+v", got)
|
|
}
|
|
if got := installsOf(nil, "linux", true); got == nil {
|
|
t.Error("none is [] for the page, not null")
|
|
}
|
|
}
|
|
|
|
// With no Node.js here, an npm command installs it first, in the same
|
|
// line (#727, Sun1090): nvm and its LTS Node on a Mac or Linux, winget's
|
|
// Node.js LTS on Windows; the vendors' own installers are as they were.
|
|
func TestInstallCommandsWithoutNode(t *testing.T) {
|
|
nvm := `export NVM_DIR="$HOME/.nvm" && mkdir -p "$NVM_DIR" && ` +
|
|
`t=$(mktemp) && curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/` + nvmVersion +
|
|
`/install.sh -o "$t" && bash "$t" && . "$NVM_DIR/nvm.sh" && nvm install --lts && npm install -g `
|
|
winget := "winget install -e --id OpenJS.NodeJS.LTS --accept-source-agreements --accept-package-agreements; $env:Path = [Environment]::GetEnvironmentVariable('Path','Machine') + ';' + [Environment]::GetEnvironmentVariable('Path','User'); npm.cmd install -g "
|
|
for _, c := range []struct {
|
|
id, goos string
|
|
want []InstallCmd
|
|
}{
|
|
{"pi", "darwin", []InstallCmd{{Via: "npm", Node: "nvm-mac", Command: nvm + "@earendil-works/pi-coding-agent"}}},
|
|
{"pi", "linux", []InstallCmd{{Via: "npm", Node: "nvm", Command: nvm + "@earendil-works/pi-coding-agent"}}},
|
|
{"pi", "windows", []InstallCmd{{Via: "npm", Node: "winget", Command: winget + "@earendil-works/pi-coding-agent"}}},
|
|
{"claude", "linux", []InstallCmd{
|
|
{Via: "script", Command: "curl -fsSL https://claude.ai/install.sh | bash"},
|
|
{Via: "npm", Node: "nvm", Command: nvm + "@anthropic-ai/claude-code"},
|
|
}},
|
|
} {
|
|
if got := installCommands(c.id, c.goos, false); !reflect.DeepEqual(got, c.want) {
|
|
t.Errorf("%s on %s: %+v, want %+v", c.id, c.goos, got, c.want)
|
|
}
|
|
}
|
|
// with Node here, npm's alone, and no Node said
|
|
if got := installCommands("pi", "darwin", true); len(got) != 1 || got[0].Node != "" || got[0].Command != "npm install -g @earendil-works/pi-coding-agent" {
|
|
t.Errorf("with node: %+v", got)
|
|
}
|
|
// installsOf passes it on
|
|
home := t.TempDir()
|
|
t.Setenv("PATH", t.TempDir())
|
|
gone := &Agent{ID: "pi", Name: "Pi", Path: filepath.Join(home, "nope", "models.json"), Bin: "pi"}
|
|
if got := installsOf([]*Agent{gone}, "linux", false); len(got) != 1 || got[0].Commands[0].Node != "nvm" {
|
|
t.Errorf("installs without node: %+v", got)
|
|
}
|
|
}
|
|
|
|
// npm is found in a folder of a user's tools (nvm's, Homebrew's), as
|
|
// npm.cmd on Windows; a folder without it, or a folder named npm, isn't it.
|
|
func TestNpmIn(t *testing.T) {
|
|
empty, nvm, win, odd := t.TempDir(), t.TempDir(), t.TempDir(), t.TempDir()
|
|
testenv.Program(t, filepath.Join(nvm, "npm"), "#!/bin/sh\n")
|
|
if err := os.WriteFile(filepath.Join(win, "npm.cmd"), nil, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Mkdir(filepath.Join(odd, "npm"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, c := range []struct {
|
|
dirs []string
|
|
goos string
|
|
want bool
|
|
}{
|
|
{nil, "darwin", false},
|
|
{[]string{empty, odd, ""}, "linux", false},
|
|
{[]string{empty, nvm}, "darwin", true},
|
|
{[]string{nvm}, "windows", false},
|
|
{[]string{empty, win}, "windows", true},
|
|
{[]string{win}, "linux", false},
|
|
} {
|
|
if got := npmIn(c.dirs, c.goos); got != c.want {
|
|
t.Errorf("npmIn(%q, %s) = %v, want %v", c.dirs, c.goos, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The command without Node is one line every shell it's run in reads:
|
|
// bash and zsh on a Mac or Linux.
|
|
func TestInstallWithoutNodeParses(t *testing.T) {
|
|
c := npmInstall("@earendil-works/pi-coding-agent", "linux", false).Command
|
|
for _, sh := range []string{"bash", "zsh", "sh"} {
|
|
p, err := exec.LookPath(sh)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if out, err := exec.Command(p, "-n", "-c", c).CombinedOutput(); err != nil {
|
|
t.Errorf("%s -n: %v %s", sh, err, out)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A download that fails stops the line where it failed: piped into a
|
|
// shell, the line's status was the shell's, so a 404 or a blocked host
|
|
// went on to source nvm.sh and told the user that file was missing. The
|
|
// command now downloads to a file, and its own failure ends the chain.
|
|
func TestInstallWithoutNodeStopsWhenTheDownloadFails(t *testing.T) {
|
|
if _, err := exec.LookPath("curl"); err != nil {
|
|
t.Skip("no curl to test the download with")
|
|
}
|
|
bad := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}))
|
|
defer bad.Close()
|
|
home := t.TempDir()
|
|
testenv.SetHome(t, home)
|
|
t.Setenv("NVM_DIR", filepath.Join(home, ".nvm"))
|
|
c := strings.Replace(npmInstall("@earendil-works/pi-coding-agent", "linux", false).Command,
|
|
"https://raw.githubusercontent.com/nvm-sh/nvm/"+nvmVersion+"/install.sh", bad.URL+"/install.sh", 1)
|
|
for _, sh := range []string{"bash", "zsh", "sh"} {
|
|
p, err := exec.LookPath(sh)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
out, err := exec.Command(p, "-c", c).CombinedOutput()
|
|
if err == nil {
|
|
t.Errorf("%s: the line went on past the failed download: %s", sh, out)
|
|
continue
|
|
}
|
|
if strings.Contains(string(out), "nvm.sh") {
|
|
t.Errorf("%s: the failed download is still told as a missing nvm.sh: %s", sh, out)
|
|
}
|
|
}
|
|
}
|