chore: import upstream snapshot with attribution
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / results (push) Has been cancelled
CI / deadcode (push) Has been cancelled
CI / e2e-live (push) Has been cancelled
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / results (push) Has been cancelled
CI / deadcode (push) Has been cancelled
CI / e2e-live (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,361 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package update
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/transport"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
const (
|
||||
registryURL = "https://registry.npmjs.org/@larksuite/cli/latest"
|
||||
cacheTTL = 24 * time.Hour
|
||||
fetchTimeout = 15 * time.Second
|
||||
stateFile = "update-state.json"
|
||||
maxBody = 256 << 10 // 256 KB
|
||||
|
||||
)
|
||||
|
||||
// UpdateInfo holds version update information.
|
||||
type UpdateInfo struct {
|
||||
Current string `json:"current"`
|
||||
Latest string `json:"latest"`
|
||||
}
|
||||
|
||||
// Message returns a concise update notification including the canonical
|
||||
// fix command. Aligned with skillscheck.StaleNotice.Message style so
|
||||
// AI agents can parse a unified "run: lark-cli update" hint across
|
||||
// both notice types.
|
||||
func (u *UpdateInfo) Message() string {
|
||||
return fmt.Sprintf("lark-cli %s available, current %s, run: lark-cli update", u.Latest, u.Current)
|
||||
}
|
||||
|
||||
// pending stores the latest update info for the current process.
|
||||
var pending atomic.Pointer[UpdateInfo]
|
||||
|
||||
// SetPending stores the update info for consumption by output decorators.
|
||||
func SetPending(info *UpdateInfo) { pending.Store(info) }
|
||||
|
||||
// GetPending returns the pending update info, or nil.
|
||||
func GetPending() *UpdateInfo { return pending.Load() }
|
||||
|
||||
// DefaultClient is the HTTP client used for npm registry requests.
|
||||
// Override in tests with an httptest server client.
|
||||
var DefaultClient *http.Client
|
||||
|
||||
func httpClient() *http.Client {
|
||||
if DefaultClient != nil {
|
||||
return DefaultClient
|
||||
}
|
||||
return &http.Client{
|
||||
Timeout: fetchTimeout,
|
||||
Transport: transport.Shared(),
|
||||
}
|
||||
}
|
||||
|
||||
// updateState is persisted to disk for caching.
|
||||
type updateState struct {
|
||||
LatestVersion string `json:"latest_version"`
|
||||
CheckedAt int64 `json:"checked_at"`
|
||||
}
|
||||
|
||||
// CheckCached checks the local cache only (no network). Always fast.
|
||||
func CheckCached(currentVersion string) *UpdateInfo {
|
||||
if shouldSkip(currentVersion) {
|
||||
return nil
|
||||
}
|
||||
state, _ := loadState()
|
||||
if state == nil || state.LatestVersion == "" {
|
||||
return nil
|
||||
}
|
||||
if !IsNewer(state.LatestVersion, currentVersion) {
|
||||
return nil
|
||||
}
|
||||
return &UpdateInfo{Current: currentVersion, Latest: state.LatestVersion}
|
||||
}
|
||||
|
||||
// RefreshCache fetches the latest version from npm and updates the local cache.
|
||||
// No-op if the cache is still fresh (< 24h). Safe to call from a goroutine.
|
||||
func RefreshCache(currentVersion string) {
|
||||
if shouldSkip(currentVersion) {
|
||||
return
|
||||
}
|
||||
state, _ := loadState()
|
||||
if state != nil && time.Since(time.Unix(state.CheckedAt, 0)) < cacheTTL {
|
||||
return // cache is fresh
|
||||
}
|
||||
latest, err := fetchLatestVersion()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = saveState(&updateState{
|
||||
LatestVersion: latest,
|
||||
CheckedAt: time.Now().Unix(),
|
||||
})
|
||||
}
|
||||
|
||||
func shouldSkip(version string) bool {
|
||||
if os.Getenv("LARKSUITE_CLI_NO_UPDATE_NOTIFIER") != "" {
|
||||
return true
|
||||
}
|
||||
// Suppress in CI environments.
|
||||
if IsCIEnv() {
|
||||
return true
|
||||
}
|
||||
// No version info at all — can't compare.
|
||||
if version == "DEV" || version == "dev" || version == "" {
|
||||
return true
|
||||
}
|
||||
// Skip local dev builds (e.g. v1.0.0-12-g9b933f1-dirty from git describe).
|
||||
// Only released versions (clean X.Y.Z) should check for updates.
|
||||
if !isRelease(version) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isRelease returns true for published versions: clean semver (1.0.0)
|
||||
// and npm prerelease (1.0.0-beta.1, 1.0.0-rc.1).
|
||||
// Returns false for git describe dev builds (v1.0.0-12-g9b933f1-dirty).
|
||||
var gitDescribePattern = regexp.MustCompile(`-\d+-g[0-9a-f]{7,}`)
|
||||
|
||||
func isRelease(version string) bool {
|
||||
v := strings.TrimPrefix(version, "v")
|
||||
if ParseVersion(v) == nil {
|
||||
return false
|
||||
}
|
||||
return !gitDescribePattern.MatchString(v)
|
||||
}
|
||||
|
||||
// IsRelease reports whether version looks like a clean published release
|
||||
// (semver "1.0.0", or npm prerelease "1.0.0-beta.1") and not a git-describe
|
||||
// dev build like "1.0.0-12-g9b933f1-dirty". Exported so internal/skillscheck
|
||||
// can apply the same release-only gating without duplicating the regex.
|
||||
func IsRelease(version string) bool { return isRelease(version) }
|
||||
|
||||
// IsCIEnv returns true when any of the standard CI environment variables
|
||||
// is set. Exported for internal/skillscheck so its skip rules track the
|
||||
// same CI-suppression behavior as the update notifier.
|
||||
func IsCIEnv() bool {
|
||||
for _, key := range []string{"CI", "BUILD_NUMBER", "RUN_ID"} {
|
||||
if os.Getenv(key) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// --- state file I/O ---
|
||||
|
||||
func statePath() string {
|
||||
return filepath.Join(core.GetConfigDir(), stateFile)
|
||||
}
|
||||
|
||||
func loadState() (*updateState, error) {
|
||||
data, err := vfs.ReadFile(statePath())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var s updateState
|
||||
if err := json.Unmarshal(data, &s); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
func saveState(s *updateState) error {
|
||||
dir := core.GetConfigDir()
|
||||
if err := vfs.MkdirAll(dir, 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.Marshal(s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return validate.AtomicWrite(statePath(), data, 0644)
|
||||
}
|
||||
|
||||
// FetchLatest queries the npm registry and returns the latest published version.
|
||||
// This is a synchronous call with timeout, intended for diagnostic commands (doctor).
|
||||
func FetchLatest() (string, error) {
|
||||
return fetchLatestVersion()
|
||||
}
|
||||
|
||||
// --- npm registry ---
|
||||
|
||||
type npmLatestResponse struct {
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
func fetchLatestVersion() (string, error) {
|
||||
resp, err := httpClient().Get(registryURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("npm registry: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxBody))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var result npmLatestResponse
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if result.Version == "" {
|
||||
return "", fmt.Errorf("npm registry: empty version")
|
||||
}
|
||||
return result.Version, nil
|
||||
}
|
||||
|
||||
// --- semver helpers ---
|
||||
|
||||
// IsNewer returns true if version a should be considered an update over b.
|
||||
//
|
||||
// When both parse as semver, standard comparison applies.
|
||||
// When b cannot be parsed (e.g. bare commit hash "9b933f1"), any valid a
|
||||
// is considered newer — an unparseable local version is assumed outdated.
|
||||
// When a cannot be parsed, returns false (can't confirm it's newer).
|
||||
func IsNewer(a, b string) bool {
|
||||
ap := parseVersionDetail(a)
|
||||
bp := parseVersionDetail(b)
|
||||
if ap == nil {
|
||||
return false // can't confirm remote is newer
|
||||
}
|
||||
if bp == nil {
|
||||
return true // local version unparseable → assume outdated
|
||||
}
|
||||
for i := 0; i < 3; i++ {
|
||||
if ap.core[i] > bp.core[i] {
|
||||
return true
|
||||
}
|
||||
if ap.core[i] < bp.core[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return comparePrerelease(ap.prerelease, bp.prerelease) > 0
|
||||
}
|
||||
|
||||
// ParseVersion parses "X.Y.Z" (with optional "v" prefix and pre-release suffix)
|
||||
// into [major, minor, patch]. Returns nil on invalid input.
|
||||
func ParseVersion(v string) []int {
|
||||
parsed := parseVersionDetail(v)
|
||||
if parsed == nil {
|
||||
return nil
|
||||
}
|
||||
return []int{parsed.core[0], parsed.core[1], parsed.core[2]}
|
||||
}
|
||||
|
||||
type parsedVersion struct {
|
||||
core [3]int
|
||||
prerelease string
|
||||
}
|
||||
|
||||
// validPrerelease matches semver pre-release identifiers (dot-separated).
|
||||
// Each identifier is either: "0", a non-zero-leading numeric, or alphanumeric with at least one letter/hyphen.
|
||||
// Rejects empty identifiers ("1.0.0-"), leading-zero numerics ("1.0.0-01"), etc.
|
||||
var validPrerelease = regexp.MustCompile(
|
||||
`^(?:0|[1-9]\d*|[0-9]*[a-zA-Z-][0-9a-zA-Z-]*)` +
|
||||
`(?:\.(?:0|[1-9]\d*|[0-9]*[a-zA-Z-][0-9a-zA-Z-]*))*$`)
|
||||
|
||||
func parseVersionDetail(v string) *parsedVersion {
|
||||
v = strings.TrimPrefix(v, "v")
|
||||
if idx := strings.Index(v, "+"); idx >= 0 {
|
||||
v = v[:idx]
|
||||
}
|
||||
prerelease := ""
|
||||
if idx := strings.Index(v, "-"); idx >= 0 {
|
||||
prerelease = v[idx+1:]
|
||||
v = v[:idx]
|
||||
if prerelease == "" || !validPrerelease.MatchString(prerelease) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
parts := strings.SplitN(v, ".", 3)
|
||||
if len(parts) != 3 {
|
||||
return nil
|
||||
}
|
||||
var nums [3]int
|
||||
for i, p := range parts {
|
||||
if len(p) > 1 && p[0] == '0' {
|
||||
return nil // leading zero in core part (e.g. "01.0.0")
|
||||
}
|
||||
n, err := strconv.Atoi(p)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
nums[i] = n
|
||||
}
|
||||
return &parsedVersion{core: nums, prerelease: prerelease}
|
||||
}
|
||||
|
||||
func comparePrerelease(a, b string) int {
|
||||
if a == "" && b == "" {
|
||||
return 0
|
||||
}
|
||||
if a == "" {
|
||||
return 1
|
||||
}
|
||||
if b == "" {
|
||||
return -1
|
||||
}
|
||||
ap := strings.Split(a, ".")
|
||||
bp := strings.Split(b, ".")
|
||||
for i := 0; i < len(ap) && i < len(bp); i++ {
|
||||
cmp := comparePrereleaseIdentifier(ap[i], bp[i])
|
||||
if cmp != 0 {
|
||||
return cmp
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case len(ap) > len(bp):
|
||||
return 1
|
||||
case len(ap) < len(bp):
|
||||
return -1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func comparePrereleaseIdentifier(a, b string) int {
|
||||
an, aErr := strconv.Atoi(a)
|
||||
bn, bErr := strconv.Atoi(b)
|
||||
aNumeric := aErr == nil
|
||||
bNumeric := bErr == nil
|
||||
switch {
|
||||
case aNumeric && bNumeric:
|
||||
if an > bn {
|
||||
return 1
|
||||
}
|
||||
if an < bn {
|
||||
return -1
|
||||
}
|
||||
return 0
|
||||
case aNumeric:
|
||||
return -1
|
||||
case bNumeric:
|
||||
return 1
|
||||
default:
|
||||
return strings.Compare(a, b)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
// 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user