package update
import (
"bytes"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
cmdutil "gitcode.com/gitcode-cli/cli/pkg/cmdutil"
"gitcode.com/gitcode-cli/cli/pkg/installupdate"
)
func TestNonManagedInstallReturnsManualGuidance(t *testing.T) {
t.Setenv("GITCODE_CLI_BINARY", t.TempDir()+"/missing/gc")
t.Setenv("GITCODE_CLI_DISTRIBUTION", "pypi")
cmd := NewCmdUpdate(cmdutil.TestFactory())
out := &bytes.Buffer{}
cmd.SetOut(out)
cmd.SetArgs([]string{"--json"})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
var got result
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
t.Fatal(err)
}
if got.Status != "manual" || got.Distribution != "pypi" || !strings.Contains(got.Message, "pipx") {
t.Fatalf("unexpected result: %#v", got)
}
}
func TestManagerMessagesNeverClaimToUninstallOtherChannels(t *testing.T) {
for _, distribution := range []string{"pypi", "homebrew", "system-package", "npm", "archive"} {
message := strings.ToLower(managerMessage(distribution))
if strings.Contains(message, "uninstall") || strings.Contains(message, "remove") {
t.Fatalf("%s guidance mutates another channel: %s", distribution, message)
}
}
}
func TestManagerMessageCoversDetectedChannels(t *testing.T) {
cases := map[string]string{
"homebrew": "brew upgrade gc",
"deb": "package manager",
"rpm": "package manager",
"pypi": "pipx or pip",
"uv": "uv tool upgrade gitcode-cli",
"npm": "npm",
"pnpm": "npm gitcode wrapper",
"npm-local": "npm gitcode wrapper",
"npm-bootstrap": "gitcode install",
"archive-or-source": "release archive",
}
for distribution, want := range cases {
message := managerMessage(distribution)
if !strings.Contains(message, want) {
t.Fatalf("managerMessage(%q) = %q, want it to mention %q", distribution, message, want)
}
}
}
func TestUpdateUsesPathDetectionWhenEnvUnset(t *testing.T) {
t.Setenv("GITCODE_CLI_DISTRIBUTION", "")
binary := t.TempDir() + "/Cellar/gc/0.14.0/bin/gc"
t.Setenv("GITCODE_CLI_BINARY", binary)
cmd := NewCmdUpdate(cmdutil.TestFactory())
out := &bytes.Buffer{}
cmd.SetOut(out)
cmd.SetArgs([]string{"--json"})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
var got result
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
t.Fatal(err)
}
if got.Status != "manual" || got.Distribution != "homebrew" || !strings.Contains(got.Message, "brew upgrade gc") {
t.Fatalf("expected homebrew guidance via path detection, got %#v", got)
}
}
func TestUpdateBootstrapCurrentDoesNotSchedule(t *testing.T) {
bootstrapManifestDir(t)
scheduled := restoreUpdateSeams(t, &installupdate.CheckResult{
Status: "current", Distribution: "npm-bootstrap", Current: "1.2.3", Latest: "1.2.3",
Message: "GitCode CLI 1.2.3 is current.",
}, nil)
cmd := NewCmdUpdate(cmdutil.TestFactory())
out := &bytes.Buffer{}
cmd.SetOut(out)
cmd.SetArgs([]string{"--json"})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
var got result
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
t.Fatal(err)
}
if got.Status != "current" || got.Latest != "1.2.3" {
t.Fatalf("expected current passthrough, got %#v", got)
}
if *scheduled != 0 {
t.Fatalf("startDetached must not run when already current, ran %d times", *scheduled)
}
}
func TestUpdateBootstrapAvailableSchedules(t *testing.T) {
bootstrapManifestDir(t)
scheduled := restoreUpdateSeams(t, &installupdate.CheckResult{
Status: "available", Distribution: "npm-bootstrap", Current: "1.2.3", Latest: "1.3.0",
Message: "GitCode CLI 1.3.0 is available.",
}, nil)
cmd := NewCmdUpdate(cmdutil.TestFactory())
out := &bytes.Buffer{}
cmd.SetOut(out)
cmd.SetArgs([]string{"--json"})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
var got result
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
t.Fatal(err)
}
if got.Status != "scheduled" || got.Latest != "1.3.0" {
t.Fatalf("expected scheduled result, got %#v", got)
}
if *scheduled != 1 {
t.Fatalf("startDetached must run exactly once, ran %d times", *scheduled)
}
}
func TestUpdateBootstrapCheckFailureSurfacesError(t *testing.T) {
bootstrapManifestDir(t)
scheduled := restoreUpdateSeams(t, nil, errors.New("update check failed: network ECONNRESET"))
cmd := NewCmdUpdate(cmdutil.TestFactory())
cmd.SilenceUsage = true
out := &bytes.Buffer{}
cmd.SetOut(out)
cmd.SetErr(&bytes.Buffer{})
cmd.SetArgs([]string{"--json"})
if err := cmd.Execute(); err == nil {
t.Fatal("a failed foreground check must exit non-zero")
}
var got result
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
t.Fatal(err)
}
if got.Status != "error" || !strings.Contains(got.Message, "ECONNRESET") {
t.Fatalf("error result must carry the real cause, got %#v", got)
}
if *scheduled != 0 {
t.Fatalf("startDetached must not run after a failed check, ran %d times", *scheduled)
}
}
func TestUpdateBootstrapBusyPassthroughDoesNotSchedule(t *testing.T) {
bootstrapManifestDir(t)
scheduled := restoreUpdateSeams(t, &installupdate.CheckResult{
Status: "busy", Distribution: "npm-bootstrap", Current: "1.2.3", Latest: "",
Message: "Another update is running.",
}, nil)
cmd := NewCmdUpdate(cmdutil.TestFactory())
out := &bytes.Buffer{}
cmd.SetOut(out)
cmd.SetArgs([]string{"--json"})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
var got result
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
t.Fatal(err)
}
if got.Status != "busy" || !strings.Contains(got.Message, "Another update") {
t.Fatalf("expected busy passthrough, got %#v", got)
}
if *scheduled != 0 {
t.Fatalf("startDetached must not run while another update holds the lock, ran %d times", *scheduled)
}
}
func TestUpdateBootstrapScheduleFailureKeepsJSONContract(t *testing.T) {
bootstrapManifestDir(t)
scheduled := restoreUpdateSeams(t, &installupdate.CheckResult{
Status: "available", Distribution: "npm-bootstrap", Current: "1.2.3", Latest: "1.3.0",
Message: "GitCode CLI 1.3.0 is available.",
}, nil)
startDetached = func(*installupdate.Manifest, bool) error {
*scheduled += 1
return errors.New("exec: node: executable file not found")
}
cmd := NewCmdUpdate(cmdutil.TestFactory())
cmd.SilenceUsage = true
out := &bytes.Buffer{}
cmd.SetOut(out)
cmd.SetErr(&bytes.Buffer{})
cmd.SetArgs([]string{"--json"})
if err := cmd.Execute(); err == nil {
t.Fatal("a failed schedule must exit non-zero")
}
var got result
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
t.Fatal(err)
}
if got.Status != "error" || !strings.Contains(got.Message, "executable file not found") {
t.Fatalf("schedule failure must still emit a JSON error object, got %#v", got)
}
}
func restoreUpdateSeams(t *testing.T, check *installupdate.CheckResult, checkErr error) *int {
t.Cleanup(func() {
checkNow = installupdate.CheckNow
startDetached = installupdate.StartDetached
})
scheduled := new(int)
checkNow = func(*installupdate.Manifest) (*installupdate.CheckResult, error) {
return check, checkErr
}
startDetached = func(*installupdate.Manifest, bool) error {
*scheduled += 1
return nil
}
return scheduled
}
func bootstrapManifestDir(t *testing.T) string {
t.Helper()
dir := t.TempDir()
t.Setenv("GITCODE_CLI_BINARY", filepath.Join(dir, "gc"))
manifest := `{"distribution":"npm-bootstrap","version":"1.2.3","targetDir":"` + filepath.ToSlash(dir) + `","helper":"` + filepath.ToSlash(filepath.Join(dir, "helper.js")) + `"}`
if err := os.WriteFile(filepath.Join(dir, ".gitcode-install.json"), []byte(manifest), 0o600); err != nil {
t.Fatal(err)
}
return dir
}