// Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT package update import ( "encoding/json" "net/http" "net/http/httptest" "net/url" "os" "path/filepath" "testing" "time" ) // roundTripFunc adapts a function to http.RoundTripper. type roundTripFunc func(*http.Request) (*http.Response, error) func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) } // clearSkipEnv unsets all env vars that shouldSkip checks, // preventing the host environment (e.g. CI=true) from polluting test results. func clearSkipEnv(t *testing.T) { t.Helper() for _, key := range []string{"LARKSUITE_CLI_NO_UPDATE_NOTIFIER", "CI", "BUILD_NUMBER", "RUN_ID"} { t.Setenv(key, "") os.Unsetenv(key) } } func mustParseURL(raw string) *url.URL { u, err := url.Parse(raw) if err != nil { panic(err) } return u } func TestIsNewer(t *testing.T) { tests := []struct { a, b string want bool }{ {"1.1.0", "1.0.0", true}, {"1.0.0", "1.0.0", false}, {"1.0.0", "1.1.0", false}, {"2.0.0", "1.9.9", true}, {"1.0.1", "1.0.0", true}, {"v1.1.0", "1.0.0", true}, {"1.1.0", "v1.0.0", true}, {"0.0.1", "0.0.0", true}, {"DEV", "1.0.0", false}, // unparseable remote → false {"1.0.0", "DEV", true}, // unparseable local → assume outdated {"1.0.0", "9b933f1", true}, // bare commit hash → assume outdated {"", "1.0.0", false}, // empty remote → false {"1.1.0", "v1.0.0-12-g9b933f1-dirty", true}, // git describe: 1.1.0 > 1.0.0 {"1.0.0", "1.0.0-rc.1", true}, // stable release > prerelease {"1.0.0-rc.2", "1.0.0-rc.1", true}, // prerelease identifiers are ordered {"1.0.0-rc.1", "1.0.0", false}, // prerelease < stable release } for _, tt := range tests { got := IsNewer(tt.a, tt.b) if got != tt.want { t.Errorf("IsNewer(%q, %q) = %v, want %v", tt.a, tt.b, got, tt.want) } } } func TestParseVersion(t *testing.T) { tests := []struct { input string want []int }{ {"1.2.3", []int{1, 2, 3}}, {"v1.2.3", []int{1, 2, 3}}, {"0.0.1", []int{0, 0, 1}}, {"1.0.0-beta.1", []int{1, 0, 0}}, {"1.0.0-rc.1", []int{1, 0, 0}}, {"1.0.0-0", []int{1, 0, 0}}, {"1.0.0+build.123", []int{1, 0, 0}}, {"1.0.0-beta.1+build", []int{1, 0, 0}}, {"1.0.0-", nil}, // empty pre-release {"1.0.0-01", nil}, // leading zero in numeric pre-release {"1.0.0-beta..1", nil}, // empty identifier between dots {"01.0.0", nil}, // leading zero in major {"1.00.0", nil}, // leading zero in minor {"1.0.00", nil}, // leading zero in patch {"DEV", nil}, {"", nil}, {"1.2", nil}, } for _, tt := range tests { got := ParseVersion(tt.input) if tt.want == nil { if got != nil { t.Errorf("ParseVersion(%q) = %v, want nil", tt.input, got) } continue } if got == nil || got[0] != tt.want[0] || got[1] != tt.want[1] || got[2] != tt.want[2] { t.Errorf("ParseVersion(%q) = %v, want %v", tt.input, got, tt.want) } } } func TestShouldSkip(t *testing.T) { tests := []struct { name string version string env map[string]string want bool }{ {"DEV", "DEV", nil, true}, {"dev_lower", "dev", nil, true}, {"empty", "", nil, true}, {"CI", "1.0.0", map[string]string{"CI": "true"}, true}, {"BUILD_NUMBER", "1.0.0", map[string]string{"BUILD_NUMBER": "42"}, true}, {"RUN_ID", "1.0.0", map[string]string{"RUN_ID": "123"}, true}, {"notifier_off", "1.0.0", map[string]string{"LARKSUITE_CLI_NO_UPDATE_NOTIFIER": "1"}, true}, {"git_describe", "v1.0.0-12-g9b933f1", nil, true}, {"git_dirty", "v1.0.0-12-g9b933f1-dirty", nil, true}, {"commit_hash", "9b933f1", nil, true}, {"clean_semver", "1.0.0", nil, false}, {"clean_semver_v", "v1.0.0", nil, false}, {"prerelease_beta", "1.0.0-beta.1", nil, false}, {"prerelease_rc", "2.0.0-rc.1", nil, false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { clearSkipEnv(t) for k, v := range tt.env { t.Setenv(k, v) } got := shouldSkip(tt.version) if got != tt.want { t.Errorf("shouldSkip(%q) = %v, want %v", tt.version, got, tt.want) } }) } } func TestIsRelease(t *testing.T) { tests := []struct { name string ver string want bool }{ {"clean_semver", "1.0.0", true}, {"v_prefix", "v1.0.0", true}, {"prerelease", "1.0.0-beta.1", true}, {"rc", "1.0.0-rc.1", true}, {"alpha_prerelease", "2.0.0-alpha.0", true}, {"git_describe_dirty", "1.0.0-12-g9b933f1-dirty", false}, {"git_describe_clean", "1.0.0-12-g9b933f1", false}, {"bare_commit_hash", "9b933f1", false}, {"dev_marker", "DEV", false}, {"incomplete_semver", "1.0", false}, {"empty", "", false}, {"invalid", "not-a-version", false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := IsRelease(tt.ver); got != tt.want { t.Errorf("IsRelease(%q) = %v, want %v", tt.ver, got, tt.want) } }) } } func TestUpdateInfoMethods(t *testing.T) { info := &UpdateInfo{Current: "1.0.0", Latest: "2.0.0"} got := info.Message() want := "lark-cli 2.0.0 available, current 1.0.0, run: lark-cli update" if got != want { t.Errorf("Message() = %q, want %q", got, want) } } func TestCheckCached(t *testing.T) { clearSkipEnv(t) tmp := t.TempDir() t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp) // No cache → nil info := CheckCached("1.0.0") if info != nil { t.Errorf("expected nil with no cache, got %+v", info) } // Write cache with newer version state := &updateState{LatestVersion: "2.0.0", CheckedAt: time.Now().Unix()} data, _ := json.Marshal(state) os.WriteFile(filepath.Join(tmp, stateFile), data, 0644) info = CheckCached("1.0.0") if info == nil { t.Fatal("expected update info, got nil") } if info.Latest != "2.0.0" || info.Current != "1.0.0" { t.Errorf("unexpected info: %+v", info) } // Same version → nil info = CheckCached("2.0.0") if info != nil { t.Errorf("expected nil when versions match, got %+v", info) } } func TestRefreshCache(t *testing.T) { clearSkipEnv(t) tmp := t.TempDir() t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp) // Set up mock npm registry via DefaultClient srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(npmLatestResponse{Version: "3.0.0"}) })) defer srv.Close() // Redirect all requests to the mock server. DefaultClient = srv.Client() DefaultClient.Transport = roundTripFunc(func(req *http.Request) (*http.Response, error) { req.URL = mustParseURL(srv.URL + req.URL.Path) return http.DefaultTransport.RoundTrip(req) }) defer func() { DefaultClient = nil }() RefreshCache("1.0.0") // Verify cache was written info := CheckCached("1.0.0") if info == nil { t.Fatal("expected update info after refresh, got nil") } if info.Latest != "3.0.0" { t.Errorf("expected latest 3.0.0, got %s", info.Latest) } // Second refresh should be no-op (cache is fresh) — won't hit network. RefreshCache("1.0.0") } func TestPendingAtomicAccess(t *testing.T) { // Initially nil if got := GetPending(); got != nil { t.Errorf("expected nil, got %+v", got) } info := &UpdateInfo{Current: "1.0.0", Latest: "2.0.0"} SetPending(info) got := GetPending() if got == nil || got.Current != "1.0.0" || got.Latest != "2.0.0" { t.Errorf("unexpected pending: %+v", got) } // Clean up for other tests SetPending(nil) } func TestIsCIEnv(t *testing.T) { clearSkipEnv(t) if IsCIEnv() { t.Fatal("IsCIEnv() = true after clearSkipEnv, want false") } for _, key := range []string{"CI", "BUILD_NUMBER", "RUN_ID"} { t.Run(key, func(t *testing.T) { clearSkipEnv(t) t.Setenv(key, "1") if !IsCIEnv() { t.Errorf("IsCIEnv() = false with %s=1, want true", key) } }) } }