Files
magpie/internal/provider/codex_daemon_test.go
T

263 lines
8.7 KiB
Go

package provider
import (
"context"
"errors"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"github.com/yetone/magpie/internal/proc"
)
func TestIsManagedDaemon(t *testing.T) {
for args, want := range map[string]bool{
"/Users/me/.codex/packages/standalone/current/bin/codex app-server --managed-daemon --listen unix://": true,
`C:\Users\me\.codex\bin\codex.exe app-server --managed-daemon=true`: true,
"codex -c features.x=true app-server --managed-daemon": true,
// the ChatGPT app's own codex, and other apps' app-servers
"/Applications/ChatGPT.app/Contents/Resources/codex -c features.code_mode_host=true app-server --analytics-default-enabled": false,
"/Users/me/Library/Application Support/Cindy/codex-package/0.156.0/bin/codex app-server --disable plugins": false,
// the daemon's helpers, and the commands that drive it
"/Users/me/.codex/packages/standalone/current/bin/codex app-server daemon pid-update-loop": false,
"codex app-server daemon restart": false,
"codex --managed-daemon": false,
"codex --managed-daemon app-server": false,
"app-server --managed-daemon": false,
"node /x/server.js app-server --managed-daemon": false,
"/x/codex-helper app-server --managed-daemon": false,
"": false,
} {
if got := isManagedDaemon(args); got != want {
t.Errorf("isManagedDaemon(%q) = %v, want %v", args, got, want)
}
}
}
func TestParsePIDFile(t *testing.T) {
for in, want := range map[string]int{
"4242\n": 4242,
`{"pid":812,"processStartTime":123,"processIdentity":"x","executableIdentity":{"digest":"d"}}`: 812,
"": 0,
"{}": 0,
"not a pid": 0,
`{"pid":-3}`: 0,
"-1": 0,
} {
if got, _ := parsePIDFile([]byte(in)); got != want {
t.Errorf("parsePIDFile(%q) = %d, want %d", in, got, want)
}
}
}
func TestFindCodexDaemon(t *testing.T) {
home := filepath.Join(t.TempDir(), ".codex")
old := processCodexHome
t.Cleanup(func() { processCodexHome = old })
homes := map[int]string{}
processCodexHome = func(pid int) (string, bool) {
h, ok := homes[pid]
return h, ok
}
chatgpt := proc.Process{PID: 10, Args: "/Applications/ChatGPT.app/Contents/Resources/codex app-server --analytics-default-enabled"}
helper := proc.Process{PID: 11, Args: "/x/codex app-server daemon pid-update-loop"}
ps := []proc.Process{chatgpt, helper}
if got := findCodexDaemon(ps, home); got != 0 {
t.Fatalf("no managed daemon: got %d", got)
}
ps = append(ps, proc.Process{PID: 20, Args: "/x/codex app-server --managed-daemon"},
proc.Process{PID: 30, Args: "/y/codex app-server --managed-daemon"})
if got := findCodexDaemon(ps, home); got != 20 {
t.Fatalf("no pid file: got %d, want the first, 20", got)
}
// the pid file of this home names the one that is its
os.MkdirAll(filepath.Join(home, "app-server-daemon"), 0o700)
os.WriteFile(filepath.Join(home, "app-server-daemon", "app-server.pid"), []byte(`{"pid":30}`), 0o600)
if got := findCodexDaemon(ps, home); got != 30 {
t.Fatalf("pid file: got %d, want 30", got)
}
// one known to run for another CODEX_HOME isn't this home's
homes[30] = "/elsewhere/.codex"
homes[20] = home + "/"
if got := findCodexDaemon(ps, home); got != 20 {
t.Fatalf("other home: got %d, want 20", got)
}
homes[20] = "/elsewhere/too"
if got := findCodexDaemon(ps, home); got != 0 {
t.Fatalf("all for other homes: got %d", got)
}
}
// fakeDaemon has the process list show the managed daemon as pid (none
// when 0), or fail when pid is -1.
func fakeDaemon(t *testing.T) *int {
t.Helper()
t.Setenv("HOME", t.TempDir())
t.Setenv("USERPROFILE", t.TempDir())
pid := new(int)
old, oldHome := listProcesses, processCodexHome
t.Cleanup(func() {
listProcesses, processCodexHome = old, oldHome
DismissCodexDaemon()
})
processCodexHome = func(int) (string, bool) { return "", false }
listProcesses = func(context.Context) ([]proc.Process, error) {
switch *pid {
case -1:
return nil, errors.New("no ps")
case 0:
return []proc.Process{{PID: 7, Args: "codex app-server"}}, nil
}
return []proc.Process{{PID: 7, Args: "codex app-server"}, {PID: *pid, Args: "codex app-server --managed-daemon"}}, nil
}
DismissCodexDaemon()
return pid
}
func TestCodexDaemonStale(t *testing.T) {
pid := fakeDaemon(t)
recheck := func() { codexDaemonMu.Lock(); codexDaemonChecked = time.Time{}; codexDaemonMu.Unlock() }
// no daemon running: nothing to say
noteCodexSwitch("a@x", "b@x")
if got := CodexDaemonStale(); got != "" {
t.Fatalf("no daemon: %q", got)
}
// one running when the account changes stays on the one before
*pid = 50
noteCodexSwitch("a@x", "b@x")
if got := CodexDaemonStale(); got != "a@x" {
t.Fatalf("got %q, want a@x", got)
}
// and still after another switch, to a third
noteCodexSwitch("b@x", "c@x")
if got := CodexDaemonStale(); got != "a@x" {
t.Fatalf("after a third: got %q, want a@x", got)
}
// switched back to the one it started on, it is right again
noteCodexSwitch("c@x", "A@x")
if got := CodexDaemonStale(); got != "" {
t.Fatalf("back on its own: %q", got)
}
// restarted since: a new process, which read the new sign-in
noteCodexSwitch("a@x", "b@x")
*pid = 51
recheck()
if got := CodexDaemonStale(); got != "" {
t.Fatalf("restarted: %q", got)
}
// the list failing says nothing about it: what was known stays
noteCodexSwitch("b@x", "a@x")
*pid = -1
recheck()
if got := CodexDaemonStale(); got != "b@x" {
t.Fatalf("list failing: got %q, want b@x", got)
}
DismissCodexDaemon()
if got := CodexDaemonStale(); got != "" {
t.Fatalf("dismissed: %q", got)
}
}
// fakeCodex puts a codex that writes down how it was run in place of the
// real one, which is never run: it would restart the user's daemon.
func fakeCodex(t *testing.T, exit int) (out string) {
t.Helper()
if runtime.GOOS == "windows" {
t.Skip("a shell script stands in for codex")
}
dir := t.TempDir()
out = filepath.Join(dir, "out")
script := "#!/bin/sh\n" +
"echo \"$@\" > '" + out + ".args'\n" +
"echo \"$CODEX_HOME\" > '" + out + ".home'\n" +
"if [ -p /dev/stdin ]; then echo pipe; else echo other; fi > '" + out + ".stdin'\n" +
"echo 'starting…'\n"
if exit != 0 {
script += "echo 'Error: background server socket is stale or unreachable' >&2\nexit " + string(rune('0'+exit)) + "\n"
}
exe := filepath.Join(dir, "codex")
if err := os.WriteFile(exe, []byte(script), 0o755); err != nil {
t.Fatal(err)
}
old := codexExecutable
t.Cleanup(func() { codexExecutable = old })
codexExecutable = func() string { return exe }
return out
}
func TestRestartCodexDaemon(t *testing.T) {
pid := fakeDaemon(t)
t.Setenv("CODEX_HOME", "/somewhere/else")
out := fakeCodex(t, 0)
*pid = 50
noteCodexSwitch("a@x", "b@x")
if err := RestartCodexDaemon(context.Background()); err != nil {
t.Fatal(err)
}
read := func(ext string) string {
b, _ := os.ReadFile(out + ext)
return strings.TrimSpace(string(b))
}
if got := read(".args"); got != "app-server daemon restart" {
t.Errorf("args %q", got)
}
home, _ := os.UserHomeDir()
if got, want := read(".home"), filepath.Join(home, ".codex"); got != want {
t.Errorf("CODEX_HOME %q, want %q (where magpie writes auth.json)", got, want)
}
if got := read(".stdin"); got != "other" {
t.Errorf("stdin is a %s, want the null device", got)
}
codexDaemonMu.Lock()
left := codexDaemonLeft
codexDaemonMu.Unlock()
if left != (leftDaemon{}) {
t.Errorf("still noted after a restart: %+v", left)
}
}
func TestRestartCodexDaemonFails(t *testing.T) {
pid := fakeDaemon(t)
fakeCodex(t, 2)
*pid = 50
noteCodexSwitch("a@x", "b@x")
err := RestartCodexDaemon(context.Background())
if err == nil || !strings.Contains(err.Error(), "stale or unreachable") {
t.Fatalf("err %v", err)
}
if got := CodexDaemonStale(); got != "a@x" {
t.Fatalf("a failed restart forgot the daemon: %q", got)
}
codexExecutable = func() string { return "" }
if err := RestartCodexDaemon(context.Background()); err == nil {
t.Fatal("no codex: no error")
}
}
// Switching Codex's account notes the daemon left on the one before; the
// switch to the account Codex is on already changes nothing.
func TestSwitchLoginNotesCodexDaemon(t *testing.T) {
pid := fakeDaemon(t)
home := signIn(t)
rememberLogins(true)
codexSignIn(t, home, "work@example.com", "r-work")
rememberLogins(true)
*pid = 50
if err := SwitchLogin("codex", "work@example.com"); err != nil {
t.Fatal(err)
}
if got := CodexDaemonStale(); got != "" {
t.Fatalf("no switch, yet %q", got)
}
if err := SwitchLogin("codex", "me@example.com"); err != nil {
t.Fatal(err)
}
if got := CodexDaemonStale(); got != "work@example.com" {
t.Fatalf("got %q, want work@example.com", got)
}
}