package cli import ( "context" "encoding/json" "net/http" "net/http/httptest" "os" "path/filepath" "reflect" "runtime" "testing" "time" ) // writeMarker writes a ~/.ao/app-state.json marker pointing at appPath into the // configured state dir (AO_RUN_FILE's directory). func writeMarker(t *testing.T, cfg testConfig, appPath string) { t.Helper() st := appState{SchemaVersion: 1, AppPath: appPath, InstallSource: "npm-bootstrap"} data, err := json.Marshal(st) if err != nil { t.Fatal(err) } dir := filepath.Dir(cfg.runFile) if err := os.MkdirAll(dir, 0o750); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dir, appStateFileName), data, 0o600); err != nil { t.Fatal(err) } } // makeBundle creates a path that stats as a usable bundle on the host OS: // a directory on macOS (.app), a regular file on Windows/Linux (exe/AppImage), // matching isUsableBundle's per-OS rule. func makeBundle(t *testing.T, name string) string { t.Helper() p := filepath.Join(t.TempDir(), name) if runtime.GOOS == "darwin" { if err := os.MkdirAll(p, 0o750); err != nil { t.Fatal(err) } return p } if err := os.WriteFile(p, []byte("x"), 0o600); err != nil { t.Fatal(err) } return p } func TestResolveApp_MarkerHit(t *testing.T) { cfg := setConfigEnv(t) bundle := makeBundle(t, appBundleName) writeMarker(t, cfg, bundle) // No scan locations: a hit must come from the marker. t.Cleanup(swapScanLocations(func() []string { return nil })) c := &commandContext{deps: Deps{}.withDefaults()} got := c.resolveApp() if got != bundle { t.Fatalf("resolveApp = %q, want marker path %q", got, bundle) } } func TestResolveApp_MarkerMissThenScanHit(t *testing.T) { cfg := setConfigEnv(t) // Marker points at a path that does not exist -> must fall through to scan. writeMarker(t, cfg, filepath.Join(t.TempDir(), "gone", appBundleName)) scanBundle := makeBundle(t, appBundleName) t.Cleanup(swapScanLocations(func() []string { return []string{scanBundle} })) c := &commandContext{deps: Deps{}.withDefaults()} got := c.resolveApp() if got != scanBundle { t.Fatalf("resolveApp = %q, want scan path %q", got, scanBundle) } } func TestResolveApp_ScanMissReturnsEmpty(t *testing.T) { setConfigEnv(t) // no marker written t.Cleanup(swapScanLocations(func() []string { return []string{filepath.Join(t.TempDir(), "nope", appBundleName)} })) c := &commandContext{deps: Deps{}.withDefaults()} got := c.resolveApp() if got != "" { t.Fatalf("resolveApp = %q, want empty", got) } } func TestAssetArchMapping(t *testing.T) { cases := map[string]struct { want string wantErr bool }{ "arm64": {want: "arm64"}, "amd64": {want: "x64"}, "386": {wantErr: true}, } for goarch, tc := range cases { got, err := assetArch(goarch) if tc.wantErr { if err == nil { t.Errorf("assetArch(%q) = %q, want error", goarch, got) } continue } if err != nil { t.Errorf("assetArch(%q): unexpected error %v", goarch, err) } if got != tc.want { t.Errorf("assetArch(%q) = %q, want %q", goarch, got, tc.want) } } } func TestAssetName_PerOS(t *testing.T) { got, err := assetName() if err != nil { // On unsupported arch (e.g. arm64 win/linux) an error is expected; the // per-OS expectation below only applies on amd64/arm64 darwin. if runtime.GOOS == "windows" || runtime.GOOS == "linux" { if runtime.GOARCH != "amd64" { return // unsupported-arch error is correct } } t.Fatalf("assetName() unexpected error: %v", err) } switch runtime.GOOS { case "darwin": if got != "agent-orchestrator-darwin-arm64.zip" && got != "agent-orchestrator-darwin-x64.zip" { t.Fatalf("darwin assetName = %q", got) } case "windows": if got != "agent-orchestrator-win32-x64.exe" { t.Fatalf("windows assetName = %q, want agent-orchestrator-win32-x64.exe", got) } case "linux": if got != "agent-orchestrator-linux-x64.AppImage" { t.Fatalf("linux assetName = %q, want agent-orchestrator-linux-x64.AppImage", got) } } } func TestRequireAMD64(t *testing.T) { // Host-independent: requireAMD64 keys off runtime.GOARCH, which is amd64 or // arm64 on every CI/dev host. Either branch is a valid assertion. got, err := requireAMD64() if runtime.GOARCH == "amd64" { if err != nil || got != "x64" { t.Fatalf("requireAMD64() on amd64 = (%q, %v), want (x64, nil)", got, err) } } else if err == nil { t.Fatalf("requireAMD64() on %s = nil error, want unsupported-arch error", runtime.GOARCH) } } func TestWindowsInstalledExe(t *testing.T) { got := windowsInstalledExe("C:\\Users\\me\\AppData\\Local") want := filepath.Join("C:\\Users\\me\\AppData\\Local", "Programs", "Agent Orchestrator", "agent-orchestrator.exe") if got != want { t.Fatalf("windowsInstalledExe = %q, want %q", got, want) } } func TestKnownAppLocations_HostOS(t *testing.T) { // knownAppLocations must return at least one candidate on every supported OS // (given the relevant env). Windows needs LOCALAPPDATA; set it so the test is // deterministic regardless of host. if runtime.GOOS == "windows" { t.Setenv("LOCALAPPDATA", "C:\\Users\\me\\AppData\\Local") } switch runtime.GOOS { case "darwin", "windows", "linux": if len(knownAppLocations()) == 0 { t.Fatalf("knownAppLocations() empty on %s", runtime.GOOS) } } } func TestIsUsableBundle_RegularFileVsDir(t *testing.T) { dir := t.TempDir() file := filepath.Join(dir, "agent-orchestrator.AppImage") if err := os.WriteFile(file, []byte("x"), 0o755); err != nil { t.Fatal(err) } subdir := filepath.Join(dir, "Agent Orchestrator.app") if err := os.MkdirAll(subdir, 0o750); err != nil { t.Fatal(err) } switch runtime.GOOS { case "darwin": if !isUsableBundle(subdir) { t.Fatal("darwin: dir bundle should be usable") } if isUsableBundle(file) { t.Fatal("darwin: regular file should not be a usable bundle") } case "windows", "linux": if !isUsableBundle(file) { t.Fatal("win/linux: regular file should be usable") } if isUsableBundle(subdir) { t.Fatal("win/linux: directory should not be a usable bundle") } } if isUsableBundle(filepath.Join(dir, "missing")) { t.Fatal("missing path should not be usable") } } func TestDownloadURLUsesReleaseRepo(t *testing.T) { orig := releaseRepo releaseRepo = "owner/repo" t.Cleanup(func() { releaseRepo = orig }) got := downloadURL("agent-orchestrator-darwin-arm64.zip") want := "https://github.com/owner/repo/releases/latest/download/agent-orchestrator-darwin-arm64.zip" if got != want { t.Fatalf("downloadURL = %q, want %q", got, want) } } func TestOpenApp_ArgConstruction(t *testing.T) { if runtime.GOOS != "darwin" { t.Skip("openApp launches via `open` only on darwin") } var gotName string var gotArgs []string c := &commandContext{deps: Deps{ CommandOutput: func(_ context.Context, name string, args ...string) ([]byte, error) { gotName = name gotArgs = args return nil, nil }, }.withDefaults()} opened, err := c.openApp(context.Background(), "/Applications/Agent Orchestrator.app") if err != nil { t.Fatal(err) } if !opened { t.Fatal("openApp reported not opened") } if gotName != "open" { t.Fatalf("command = %q, want open", gotName) } wantArgs := []string{"/Applications/Agent Orchestrator.app", "--args", "--installed-via=npm-bootstrap"} if !reflect.DeepEqual(gotArgs, wantArgs) { t.Fatalf("args = %v, want %v", gotArgs, wantArgs) } } func TestOpenApp_DetachedSpawnOnWinLinux(t *testing.T) { if runtime.GOOS != "windows" && runtime.GOOS != "linux" { t.Skip("openApp spawns detached only on windows/linux") } var gotCfg processStartConfig c := &commandContext{deps: Deps{ StartProcess: func(cfg processStartConfig) error { gotCfg = cfg return nil }, }.withDefaults()} appPath := "/some/agent-orchestrator.AppImage" opened, err := c.openApp(context.Background(), appPath) if err != nil { t.Fatal(err) } if !opened { t.Fatal("openApp reported not opened") } if gotCfg.Path != appPath { t.Fatalf("spawn path = %q, want %q", gotCfg.Path, appPath) } wantArgs := []string{"--installed-via=npm-bootstrap"} if !reflect.DeepEqual(gotCfg.Args, wantArgs) { t.Fatalf("spawn args = %v, want %v", gotCfg.Args, wantArgs) } } func TestOpenApp_SpawnFailureFallsBackToManual(t *testing.T) { if runtime.GOOS != "windows" && runtime.GOOS != "linux" { t.Skip("detached-spawn fallback only on windows/linux") } c := &commandContext{deps: Deps{ StartProcess: func(processStartConfig) error { return os.ErrNotExist }, }.withDefaults()} opened, err := c.openApp(context.Background(), "/some/app") if err != nil { t.Fatalf("openApp should swallow spawn errors, got %v", err) } if opened { t.Fatal("openApp should report not-opened on spawn failure") } } // TestDownload_IgnoresShortClientTimeout proves download() does not inherit the // 2s deps.HTTPClient timeout (sized for loopback probes), which would otherwise // fail every real release download. The server responds after a delay that // exceeds the injected client's tiny timeout; download must still succeed. func TestDownload_IgnoresShortClientTimeout(t *testing.T) { const body = "release-zip-bytes" srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { time.Sleep(150 * time.Millisecond) _, _ = w.Write([]byte(body)) })) t.Cleanup(srv.Close) c := &commandContext{deps: Deps{ // 50ms timeout: if download honored this, the 150ms server would fail it. HTTPClient: &http.Client{Timeout: 50 * time.Millisecond}, }.withDefaults()} dst := filepath.Join(t.TempDir(), "out.zip") if err := c.download(context.Background(), srv.URL, dst); err != nil { t.Fatalf("download failed (short client timeout leaked into large-asset path?): %v", err) } got, err := os.ReadFile(dst) if err != nil { t.Fatal(err) } if string(got) != body { t.Fatalf("downloaded %q, want %q", got, body) } } // swapScanLocations replaces the scan-location seam and returns a restore func. func swapScanLocations(fn func() []string) func() { orig := appScanLocations appScanLocations = fn return func() { appScanLocations = orig } }