// Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT package cmdupdate import ( "bytes" "encoding/json" "errors" "fmt" "os" "path/filepath" "strings" "testing" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/selfupdate" "github.com/larksuite/cli/internal/skillscheck" ) // newTestFactory creates a test factory with minimal config. func newTestFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer) { t.Helper() f, stdout, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{}) return f, stdout, stderr } // mockDetect sets up newUpdater to return an Updater with the given DetectResult. // It preserves any existing NpmInstallOverride/SkillsUpdateOverride that may be set later. func mockDetect(t *testing.T, result selfupdate.DetectResult) { t.Helper() origNew := newUpdater newUpdater = func() *selfupdate.Updater { u := selfupdate.New() u.DetectOverride = func() selfupdate.DetectResult { return result } return u } t.Cleanup(func() { newUpdater = origNew }) } // mockDetectAndNpm sets up newUpdater with detect, npm install, and skills overrides all at once. func mockDetectAndNpm(t *testing.T, result selfupdate.DetectResult, npmFn func(string) *selfupdate.NpmResult, skillsFn func() *selfupdate.NpmResult) { t.Helper() origNew := newUpdater newUpdater = func() *selfupdate.Updater { u := selfupdate.New() u.DetectOverride = func() selfupdate.DetectResult { return result } u.NpmInstallOverride = npmFn u.SkillsUpdateOverride = skillsFn u.VerifyOverride = func(string) error { return nil } return u } t.Cleanup(func() { newUpdater = origNew }) } func TestUpdateAlreadyUpToDate_JSON(t *testing.T) { f, stdout, _ := newTestFactory(t) cmd := NewCmdUpdate(f) cmd.SetArgs([]string{"--json"}) origFetch := fetchLatest fetchLatest = func() (string, error) { return "1.0.0", nil } defer func() { fetchLatest = origFetch }() origVersion := currentVersion currentVersion = func() string { return "1.0.0" } defer func() { currentVersion = origVersion }() err := cmd.Execute() if err != nil { t.Fatalf("unexpected error: %v", err) } out := stdout.String() if !strings.Contains(out, `"action": "already_up_to_date"`) { t.Errorf("expected already_up_to_date in JSON output, got: %s", out) } if !strings.Contains(out, `"ok": true`) { t.Errorf("expected ok:true in JSON output, got: %s", out) } } func TestUpdateAlreadyUpToDate_Human(t *testing.T) { f, _, stderr := newTestFactory(t) cmd := NewCmdUpdate(f) cmd.SetArgs([]string{}) origFetch := fetchLatest fetchLatest = func() (string, error) { return "1.0.0", nil } defer func() { fetchLatest = origFetch }() origVersion := currentVersion currentVersion = func() string { return "1.0.0" } defer func() { currentVersion = origVersion }() err := cmd.Execute() if err != nil { t.Fatalf("unexpected error: %v", err) } out := stderr.String() if !strings.Contains(out, "already up to date") { t.Errorf("expected 'already up to date' in stderr, got: %s", out) } } func TestUpdateManual_JSON(t *testing.T) { f, stdout, _ := newTestFactory(t) cmd := NewCmdUpdate(f) cmd.SetArgs([]string{"--json"}) cmd.SilenceErrors = true cmd.SilenceUsage = true origFetch := fetchLatest fetchLatest = func() (string, error) { return "2.0.0", nil } defer func() { fetchLatest = origFetch }() origVersion := currentVersion currentVersion = func() string { return "1.0.0" } defer func() { currentVersion = origVersion }() mockDetect(t, selfupdate.DetectResult{Method: selfupdate.InstallManual, ResolvedPath: "/usr/local/bin/lark-cli"}) err := cmd.Execute() if err != nil { t.Fatalf("unexpected error: %v", err) } out := stdout.String() if !strings.Contains(out, `"action": "manual_required"`) { t.Errorf("expected manual_required in output, got: %s", out) } if !strings.Contains(out, "not installed via npm") { t.Errorf("expected accurate reason in output, got: %s", out) } if !strings.Contains(out, "releases/tag/v2.0.0") { t.Errorf("expected version-pinned URL in output, got: %s", out) } } func TestUpdateManual_Human(t *testing.T) { f, _, stderr := newTestFactory(t) cmd := NewCmdUpdate(f) cmd.SetArgs([]string{}) origFetch := fetchLatest fetchLatest = func() (string, error) { return "2.0.0", nil } defer func() { fetchLatest = origFetch }() origVersion := currentVersion currentVersion = func() string { return "1.0.0" } defer func() { currentVersion = origVersion }() mockDetect(t, selfupdate.DetectResult{Method: selfupdate.InstallManual, ResolvedPath: "/usr/local/bin/lark-cli"}) err := cmd.Execute() if err != nil { t.Fatalf("unexpected error: %v", err) } out := stderr.String() if !strings.Contains(out, "not installed via npm") { t.Errorf("expected 'not installed via npm' in stderr, got: %s", out) } if !strings.Contains(out, "releases/tag/v2.0.0") { t.Errorf("expected version-pinned URL in stderr, got: %s", out) } } func TestUpdateNpm_JSON(t *testing.T) { // Isolate config dir: this test mocks fetchLatest="2.0.0" and lets // runSkillsAndStamp → WriteStamp succeed, which without isolation would // clobber the real ~/.lark-cli/skills.stamp with "2.0.0". t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) f, stdout, _ := newTestFactory(t) cmd := NewCmdUpdate(f) cmd.SetArgs([]string{"--json"}) origFetch := fetchLatest fetchLatest = func() (string, error) { return "2.0.0", nil } defer func() { fetchLatest = origFetch }() origVersion := currentVersion currentVersion = func() string { return "1.0.0" } defer func() { currentVersion = origVersion }() mockDetectAndNpm(t, selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true}, func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }, func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} }, ) err := cmd.Execute() if err != nil { t.Fatalf("unexpected error: %v", err) } out := stdout.String() if !strings.Contains(out, `"action": "updated"`) { t.Errorf("expected updated in output, got: %s", out) } } func TestUpdateNpm_Human(t *testing.T) { // Same isolation as TestUpdateNpm_JSON — see comment there. t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) f, _, stderr := newTestFactory(t) cmd := NewCmdUpdate(f) cmd.SetArgs([]string{}) origFetch := fetchLatest fetchLatest = func() (string, error) { return "2.0.0", nil } defer func() { fetchLatest = origFetch }() origVersion := currentVersion currentVersion = func() string { return "1.0.0" } defer func() { currentVersion = origVersion }() mockDetectAndNpm(t, selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true}, func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }, func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} }, ) err := cmd.Execute() if err != nil { t.Fatalf("unexpected error: %v", err) } out := stderr.String() if !strings.Contains(out, "Successfully updated") { t.Errorf("expected success message in stderr, got: %s", out) } } func TestUpdateForce_JSON(t *testing.T) { // Same stamp-isolation rationale as TestUpdateNpm_JSON. t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) f, stdout, _ := newTestFactory(t) cmd := NewCmdUpdate(f) cmd.SetArgs([]string{"--force", "--json"}) origFetch := fetchLatest fetchLatest = func() (string, error) { return "1.0.0", nil } defer func() { fetchLatest = origFetch }() origVersion := currentVersion currentVersion = func() string { return "1.0.0" } defer func() { currentVersion = origVersion }() mockDetectAndNpm(t, selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true}, func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }, func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} }, ) err := cmd.Execute() if err != nil { t.Fatalf("unexpected error: %v", err) } out := stdout.String() if !strings.Contains(out, `"action": "updated"`) { t.Errorf("expected updated in JSON output, got: %s", out) } } func TestUpdateFetchError_JSON(t *testing.T) { f, stdout, _ := newTestFactory(t) cmd := NewCmdUpdate(f) cmd.SetArgs([]string{"--json"}) origFetch := fetchLatest fetchLatest = func() (string, error) { return "", errors.New("network timeout") } defer func() { fetchLatest = origFetch }() err := cmd.Execute() // cobra silences errors when RunE returns; we just check stdout _ = err out := stdout.String() if !strings.Contains(out, `"ok": false`) { t.Errorf("expected ok:false in JSON output, got: %s", out) } if !strings.Contains(out, "network timeout") { t.Errorf("expected 'network timeout' in JSON output, got: %s", out) } } func TestUpdateFetchError_Human(t *testing.T) { f, _, _ := newTestFactory(t) cmd := NewCmdUpdate(f) cmd.SetArgs([]string{}) origFetch := fetchLatest fetchLatest = func() (string, error) { return "", errors.New("network timeout") } defer func() { fetchLatest = origFetch }() // Suppress cobra's default error printing. cmd.SilenceErrors = true cmd.SilenceUsage = true err := cmd.Execute() if err == nil { t.Fatal("expected non-nil error, got nil") } var exitErr *output.ExitError if !errors.As(err, &exitErr) { t.Fatalf("expected *output.ExitError, got %T: %v", err, err) } if exitErr.Code != output.ExitNetwork { t.Errorf("expected ExitNetwork (%d), got %d", output.ExitNetwork, exitErr.Code) } } func TestUpdateInvalidVersion_JSON(t *testing.T) { f, stdout, _ := newTestFactory(t) cmd := NewCmdUpdate(f) cmd.SetArgs([]string{"--json"}) origFetch := fetchLatest fetchLatest = func() (string, error) { return "not-a-version", nil } defer func() { fetchLatest = origFetch }() _ = cmd.Execute() out := stdout.String() if !strings.Contains(out, "invalid version") { t.Errorf("expected 'invalid version' in JSON output, got: %s", out) } } func TestUpdateDevVersion_JSON(t *testing.T) { // Same stamp-isolation rationale as TestUpdateNpm_JSON. t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) f, stdout, _ := newTestFactory(t) cmd := NewCmdUpdate(f) cmd.SetArgs([]string{"--json"}) origFetch := fetchLatest fetchLatest = func() (string, error) { return "1.0.0", nil } defer func() { fetchLatest = origFetch }() origVersion := currentVersion currentVersion = func() string { return "DEV" } defer func() { currentVersion = origVersion }() mockDetectAndNpm(t, selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true}, func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }, func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} }, ) err := cmd.Execute() if err != nil { t.Fatalf("unexpected error: %v", err) } out := stdout.String() if !strings.Contains(out, `"action": "updated"`) { t.Errorf("expected updated in JSON output, got: %s", out) } } func TestUpdateNpmFail_JSON(t *testing.T) { f, stdout, _ := newTestFactory(t) cmd := NewCmdUpdate(f) cmd.SetArgs([]string{"--json"}) origFetch := fetchLatest fetchLatest = func() (string, error) { return "2.0.0", nil } defer func() { fetchLatest = origFetch }() origVersion := currentVersion currentVersion = func() string { return "1.0.0" } defer func() { currentVersion = origVersion }() origNew := newUpdater newUpdater = func() *selfupdate.Updater { u := selfupdate.New() u.DetectOverride = func() selfupdate.DetectResult { return selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true} } u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { r := &selfupdate.NpmResult{} fmt.Fprint(&r.Stderr, "EACCES: permission denied") r.Err = errors.New("npm install failed") return r } return u } defer func() { newUpdater = origNew }() _ = cmd.Execute() out := stdout.String() if !strings.Contains(out, "permission denied") { t.Errorf("expected 'permission denied' in JSON output, got: %s", out) } if !strings.Contains(out, `"hint"`) { t.Errorf("expected 'hint' field in JSON output, got: %s", out) } } func TestUpdateNpmFail_Human(t *testing.T) { f, _, stderr := newTestFactory(t) cmd := NewCmdUpdate(f) cmd.SetArgs([]string{}) origFetch := fetchLatest fetchLatest = func() (string, error) { return "2.0.0", nil } defer func() { fetchLatest = origFetch }() origVersion := currentVersion currentVersion = func() string { return "1.0.0" } defer func() { currentVersion = origVersion }() origNew := newUpdater newUpdater = func() *selfupdate.Updater { u := selfupdate.New() u.DetectOverride = func() selfupdate.DetectResult { return selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true} } u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { r := &selfupdate.NpmResult{} fmt.Fprint(&r.Stderr, "EACCES: permission denied") r.Err = errors.New("npm install failed") return r } return u } defer func() { newUpdater = origNew }() cmd.SilenceErrors = true cmd.SilenceUsage = true _ = cmd.Execute() out := stderr.String() if !strings.Contains(out, "Update failed") { t.Errorf("expected 'Update failed' in stderr, got: %s", out) } if !strings.Contains(out, "Permission denied") { t.Errorf("expected permission hint in stderr, got: %s", out) } } func TestUpdateNpmVerifyFail_JSON_NoRestoreHintWhenBackupUnavailable(t *testing.T) { f, stdout, _ := newTestFactory(t) cmd := NewCmdUpdate(f) cmd.SetArgs([]string{"--json"}) origFetch := fetchLatest fetchLatest = func() (string, error) { return "2.0.0", nil } defer func() { fetchLatest = origFetch }() origVersion := currentVersion currentVersion = func() string { return "1.0.0" } defer func() { currentVersion = origVersion }() origNew := newUpdater newUpdater = func() *selfupdate.Updater { u := selfupdate.New() u.DetectOverride = func() selfupdate.DetectResult { return selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true} } u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} } u.VerifyOverride = func(string) error { return errors.New("bad binary") } u.RestoreAvailableOverride = func() bool { return false } u.SkillsUpdateOverride = func() *selfupdate.NpmResult { t.Fatal("skills update should not run when binary verification fails") return nil } return u } defer func() { newUpdater = origNew }() err := cmd.Execute() if err == nil { t.Fatal("expected verification failure") } var exitErr *output.ExitError if !errors.As(err, &exitErr) { t.Fatalf("expected *output.ExitError, got %T: %v", err, err) } if exitErr.Code != output.ExitAPI { t.Fatalf("expected ExitAPI (%d), got %d", output.ExitAPI, exitErr.Code) } out := stdout.String() if !strings.Contains(out, "automatic rollback is unavailable") { t.Errorf("expected unavailable rollback hint, got: %s", out) } if strings.Contains(out, "previous version has been restored") { t.Errorf("should not claim restore when no backup is available, got: %s", out) } if !strings.Contains(out, "npm install -g @larksuite/cli@2.0.0") { t.Errorf("expected manual reinstall command in hint, got: %s", out) } if !strings.Contains(out, "skills will not be synced") { t.Errorf("expected skills-not-synced warning in rollback hint, got: %s", out) } if !strings.Contains(out, "npx skills add larksuite/cli -y -g") { t.Errorf("expected npx skills add hint for skills sync, got: %s", out) } } func TestUpdateCheck_JSON_Npm(t *testing.T) { f, stdout, _ := newTestFactory(t) cmd := NewCmdUpdate(f) cmd.SetArgs([]string{"--json", "--check"}) origFetch := fetchLatest fetchLatest = func() (string, error) { return "2.0.0", nil } defer func() { fetchLatest = origFetch }() origVersion := currentVersion currentVersion = func() string { return "1.0.0" } defer func() { currentVersion = origVersion }() mockDetect(t, selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true}) err := cmd.Execute() if err != nil { t.Fatalf("unexpected error: %v", err) } out := stdout.String() if !strings.Contains(out, `"action": "update_available"`) { t.Errorf("expected update_available action, got: %s", out) } if !strings.Contains(out, `"auto_update": true`) { t.Errorf("expected auto_update:true for npm, got: %s", out) } if !strings.Contains(out, "releases/tag/v2.0.0") { t.Errorf("expected version-pinned release URL, got: %s", out) } if !strings.Contains(out, "CHANGELOG") { t.Errorf("expected changelog URL, got: %s", out) } } func TestUpdateCheck_Human_Npm(t *testing.T) { f, _, stderr := newTestFactory(t) cmd := NewCmdUpdate(f) cmd.SetArgs([]string{"--check"}) origFetch := fetchLatest fetchLatest = func() (string, error) { return "2.0.0", nil } defer func() { fetchLatest = origFetch }() origVersion := currentVersion currentVersion = func() string { return "1.0.0" } defer func() { currentVersion = origVersion }() mockDetect(t, selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true}) err := cmd.Execute() if err != nil { t.Fatalf("unexpected error: %v", err) } out := stderr.String() if !strings.Contains(out, "Update available") { t.Errorf("expected 'Update available' in stderr, got: %s", out) } if !strings.Contains(out, "lark-cli update") { t.Errorf("expected 'lark-cli update' instruction for npm, got: %s", out) } } func TestUpdateCheck_Human_Manual(t *testing.T) { f, _, stderr := newTestFactory(t) cmd := NewCmdUpdate(f) cmd.SetArgs([]string{"--check"}) origFetch := fetchLatest fetchLatest = func() (string, error) { return "2.0.0", nil } defer func() { fetchLatest = origFetch }() origVersion := currentVersion currentVersion = func() string { return "1.0.0" } defer func() { currentVersion = origVersion }() mockDetect(t, selfupdate.DetectResult{Method: selfupdate.InstallManual, ResolvedPath: "/usr/local/bin/lark-cli"}) err := cmd.Execute() if err != nil { t.Fatalf("unexpected error: %v", err) } out := stderr.String() if !strings.Contains(out, "Update available") { t.Errorf("expected 'Update available' in stderr, got: %s", out) } if !strings.Contains(out, "manually") { t.Errorf("expected manual download instruction for non-npm, got: %s", out) } if strings.Contains(out, "lark-cli update` to install") { t.Errorf("should NOT suggest 'lark-cli update' for manual install, got: %s", out) } } func TestUpdateNpmNotFound_FallsBackToManual(t *testing.T) { f, stdout, _ := newTestFactory(t) cmd := NewCmdUpdate(f) cmd.SetArgs([]string{"--json"}) origFetch := fetchLatest fetchLatest = func() (string, error) { return "2.0.0", nil } defer func() { fetchLatest = origFetch }() origVersion := currentVersion currentVersion = func() string { return "1.0.0" } defer func() { currentVersion = origVersion }() // npm detected (node_modules in path) but npm binary not available mockDetect(t, selfupdate.DetectResult{ Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: false, }) err := cmd.Execute() if err != nil { t.Fatalf("unexpected error: %v", err) } out := stdout.String() if !strings.Contains(out, `"action": "manual_required"`) { t.Errorf("expected manual_required when npm not found, got: %s", out) } // Must say "npm is not available", not generic "not installed via npm" if !strings.Contains(out, "npm is not available") { t.Errorf("expected 'npm is not available' reason when npm detected but missing, got: %s", out) } } func TestReleaseURL(t *testing.T) { got := releaseURL("2.0.0") if got != "https://github.com/larksuite/cli/releases/tag/v2.0.0" { t.Errorf("expected version-pinned URL, got: %s", got) } got2 := releaseURL("v1.5.0") if got2 != "https://github.com/larksuite/cli/releases/tag/v1.5.0" { t.Errorf("expected no double v prefix, got: %s", got2) } } func TestPermissionHint(t *testing.T) { origOS := currentOS defer func() { currentOS = origOS }() // Linux: EACCES should produce a hint with npm prefix guidance. currentOS = "linux" hint := permissionHint("EACCES: permission denied, access '/usr/local/lib'") if !strings.Contains(hint, "npm global prefix") { t.Errorf("expected npm prefix hint on linux, got: %s", hint) } if strings.Contains(hint, "sudo npm install -g") { t.Errorf("should not suggest raw sudo npm install, got: %s", hint) } // Windows: EACCES hint is suppressed (no EACCES on Windows). currentOS = "windows" hint = permissionHint("EACCES: permission denied") if hint != "" { t.Errorf("expected empty hint on Windows, got: %s", hint) } // Non-EACCES error: always empty. currentOS = "linux" if got := permissionHint("some other error"); got != "" { t.Errorf("expected empty hint for non-EACCES, got: %s", got) } } func TestUpdateWindows_NpmSuccess_JSON(t *testing.T) { // With the rename trick, Windows npm installs can now auto-update. // Same stamp-isolation rationale as TestUpdateNpm_JSON. t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) f, stdout, _ := newTestFactory(t) cmd := NewCmdUpdate(f) cmd.SetArgs([]string{"--json"}) origFetch := fetchLatest fetchLatest = func() (string, error) { return "2.0.0", nil } defer func() { fetchLatest = origFetch }() origVersion := currentVersion currentVersion = func() string { return "1.0.0" } defer func() { currentVersion = origVersion }() origOS := currentOS currentOS = osWindows defer func() { currentOS = origOS }() mockDetectAndNpm(t, selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: `C:\npm\node_modules\@larksuite\cli\bin\lark-cli.exe`, NpmAvailable: true}, func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }, func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} }, ) err := cmd.Execute() if err != nil { t.Fatalf("unexpected error: %v", err) } out := stdout.String() if !strings.Contains(out, `"action": "updated"`) { t.Errorf("expected updated on Windows with rename trick, got: %s", out) } } func TestUpdateWindows_Check_JSON(t *testing.T) { // --check on Windows npm should report auto_update: true (rename trick available). f, stdout, _ := newTestFactory(t) cmd := NewCmdUpdate(f) cmd.SetArgs([]string{"--json", "--check"}) origFetch := fetchLatest fetchLatest = func() (string, error) { return "2.0.0", nil } defer func() { fetchLatest = origFetch }() origVersion := currentVersion currentVersion = func() string { return "1.0.0" } defer func() { currentVersion = origVersion }() origOS := currentOS currentOS = osWindows defer func() { currentOS = origOS }() mockDetect(t, selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: `C:\node_modules\@larksuite\cli\bin\lark-cli.exe`, NpmAvailable: true}) err := cmd.Execute() if err != nil { t.Fatalf("unexpected error: %v", err) } out := stdout.String() if !strings.Contains(out, `"auto_update": true`) { t.Errorf("expected auto_update:true on Windows (rename trick), got: %s", out) } } func TestUpdateWindows_Symbols(t *testing.T) { origOS := currentOS defer func() { currentOS = origOS }() currentOS = "windows" if symOK() != "[OK]" { t.Errorf("expected [OK] on Windows, got: %s", symOK()) } if symFail() != "[FAIL]" { t.Errorf("expected [FAIL] on Windows, got: %s", symFail()) } if symWarn() != "[WARN]" { t.Errorf("expected [WARN] on Windows, got: %s", symWarn()) } if symArrow() != "->" { t.Errorf("expected -> on Windows, got: %s", symArrow()) } currentOS = "darwin" if symOK() != "\u2713" { t.Errorf("expected \u2713 on darwin, got: %s", symOK()) } if symArrow() != "\u2192" { t.Errorf("expected \u2192 on darwin, got: %s", symArrow()) } } func TestUpdateNpm_SkillsSuccess_JSON(t *testing.T) { t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) f, stdout, _ := newTestFactory(t) cmd := NewCmdUpdate(f) cmd.SetArgs([]string{"--json"}) origFetch := fetchLatest fetchLatest = func() (string, error) { return "2.0.0", nil } defer func() { fetchLatest = origFetch }() origVersion := currentVersion currentVersion = func() string { return "1.0.0" } defer func() { currentVersion = origVersion }() mockDetectAndNpm(t, selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true}, func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }, func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} }, ) err := cmd.Execute() if err != nil { t.Fatalf("unexpected error: %v", err) } out := stdout.String() // Should NOT have skills_warning when skills succeed if strings.Contains(out, "skills_warning") { t.Errorf("expected no skills_warning on success, got: %s", out) } } func TestUpdateNpm_SkillsFail_JSON(t *testing.T) { t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) f, stdout, _ := newTestFactory(t) cmd := NewCmdUpdate(f) cmd.SetArgs([]string{"--json"}) origFetch := fetchLatest fetchLatest = func() (string, error) { return "2.0.0", nil } defer func() { fetchLatest = origFetch }() origVersion := currentVersion currentVersion = func() string { return "1.0.0" } defer func() { currentVersion = origVersion }() origNew := newUpdater newUpdater = func() *selfupdate.Updater { u := selfupdate.New() u.DetectOverride = func() selfupdate.DetectResult { return selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true} } u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} } u.VerifyOverride = func(string) error { return nil } // Skills update fails u.SkillsUpdateOverride = func() *selfupdate.NpmResult { r := &selfupdate.NpmResult{} r.Stderr.WriteString("npx: command not found") r.Err = fmt.Errorf("exit status 127") return r } return u } defer func() { newUpdater = origNew }() err := cmd.Execute() if err != nil { t.Fatalf("unexpected error: %v", err) } out := stdout.String() // CLI update should still succeed (ok:true) if !strings.Contains(out, `"ok": true`) { t.Errorf("expected ok:true despite skills failure, got: %s", out) } if !strings.Contains(out, `"action": "updated"`) { t.Errorf("expected action:updated despite skills failure, got: %s", out) } // Should have skills_warning with detail if !strings.Contains(out, "skills_warning") { t.Errorf("expected skills_warning in output, got: %s", out) } if !strings.Contains(out, "skills_detail") { t.Errorf("expected skills_detail in output, got: %s", out) } } func TestUpdateNpm_SkillsFail_Human(t *testing.T) { t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) f, _, stderr := newTestFactory(t) cmd := NewCmdUpdate(f) cmd.SetArgs([]string{}) origFetch := fetchLatest fetchLatest = func() (string, error) { return "2.0.0", nil } defer func() { fetchLatest = origFetch }() origVersion := currentVersion currentVersion = func() string { return "1.0.0" } defer func() { currentVersion = origVersion }() origNew := newUpdater newUpdater = func() *selfupdate.Updater { u := selfupdate.New() u.DetectOverride = func() selfupdate.DetectResult { return selfupdate.DetectResult{Method: selfupdate.InstallNpm, ResolvedPath: "/node_modules/@larksuite/cli/bin/lark-cli", NpmAvailable: true} } u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} } u.VerifyOverride = func(string) error { return nil } u.SkillsUpdateOverride = func() *selfupdate.NpmResult { r := &selfupdate.NpmResult{} r.Stderr.WriteString("npx: command not found") r.Err = fmt.Errorf("exit status 127") return r } return u } defer func() { newUpdater = origNew }() err := cmd.Execute() if err != nil { t.Fatalf("unexpected error: %v", err) } out := stderr.String() // CLI update should still show success if !strings.Contains(out, "Successfully updated") { t.Errorf("expected CLI success message, got: %s", out) } // Skills warning should be shown if !strings.Contains(out, "Skills update failed") { t.Errorf("expected skills failure warning, got: %s", out) } if !strings.Contains(out, "npx -y skills add") { t.Errorf("expected manual skills command hint, got: %s", out) } } // newTestIO returns a cmdutil.IOStreams backed by bytes.Buffers, suitable // for direct calls to internals like runSkillsAndStamp that write to // io.ErrOut. func newTestIO() *cmdutil.IOStreams { return cmdutil.NewIOStreams(&bytes.Buffer{}, &bytes.Buffer{}, &bytes.Buffer{}) } func TestRunSkillsAndStamp_DedupHit(t *testing.T) { dir := t.TempDir() t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) if err := skillscheck.WriteStamp("1.0.21"); err != nil { t.Fatal(err) } called := false updater := &selfupdate.Updater{ SkillsUpdateOverride: func() *selfupdate.NpmResult { called = true return &selfupdate.NpmResult{} }, } got := runSkillsAndStamp(updater, newTestIO(), "1.0.21", false) if got != nil { t.Errorf("runSkillsAndStamp() = %+v, want nil for dedup hit", got) } if called { t.Error("SkillsUpdateOverride called, want skipped due to dedup") } } func TestRunSkillsAndStamp_DedupForceBypass(t *testing.T) { dir := t.TempDir() t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) if err := skillscheck.WriteStamp("1.0.21"); err != nil { t.Fatal(err) } called := false updater := &selfupdate.Updater{ SkillsUpdateOverride: func() *selfupdate.NpmResult { called = true return &selfupdate.NpmResult{} }, } got := runSkillsAndStamp(updater, newTestIO(), "1.0.21", true) if got == nil { t.Fatal("runSkillsAndStamp(force=true) = nil, want non-nil") } if !called { t.Error("SkillsUpdateOverride not called with force=true") } } func TestRunSkillsAndStamp_SuccessWritesStamp(t *testing.T) { dir := t.TempDir() t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) updater := &selfupdate.Updater{ SkillsUpdateOverride: func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} }, } got := runSkillsAndStamp(updater, newTestIO(), "1.0.21", false) if got == nil || got.Err != nil { t.Fatalf("runSkillsAndStamp() = %+v, want non-nil with nil Err", got) } stamp, _ := skillscheck.ReadStamp() if stamp != "1.0.21" { t.Errorf("stamp = %q, want \"1.0.21\"", stamp) } } func TestRunSkillsAndStamp_FailureKeepsOldStamp(t *testing.T) { dir := t.TempDir() t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) if err := skillscheck.WriteStamp("1.0.20"); err != nil { t.Fatal(err) } updater := &selfupdate.Updater{ SkillsUpdateOverride: func() *selfupdate.NpmResult { r := &selfupdate.NpmResult{} r.Err = fmt.Errorf("npx failed") return r }, } got := runSkillsAndStamp(updater, newTestIO(), "1.0.21", false) if got == nil || got.Err == nil { t.Fatalf("runSkillsAndStamp() = %+v, want non-nil with non-nil Err", got) } stamp, _ := skillscheck.ReadStamp() if stamp != "1.0.20" { t.Errorf("stamp = %q, want \"1.0.20\" (failure must not overwrite)", stamp) } } func TestTruncate(t *testing.T) { long := strings.Repeat("x", 3000) got := selfupdate.Truncate(long, 2000) if len(got) != 2000 { t.Errorf("expected truncated length 2000, got %d", len(got)) } short := "hello" got2 := selfupdate.Truncate(short, 2000) if got2 != "hello" { t.Errorf("expected 'hello', got %q", got2) } } func TestUpdateRun_AlreadyLatest_RunsSkillsSync(t *testing.T) { dir := t.TempDir() t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) origFetch := fetchLatest origCur := currentVersion t.Cleanup(func() { fetchLatest = origFetch; currentVersion = origCur }) fetchLatest = func() (string, error) { return "1.0.21", nil } currentVersion = func() string { return "1.0.21" } skillsCalled := false origNew := newUpdater t.Cleanup(func() { newUpdater = origNew }) newUpdater = func() *selfupdate.Updater { return &selfupdate.Updater{ SkillsUpdateOverride: func() *selfupdate.NpmResult { skillsCalled = true return &selfupdate.NpmResult{} }, } } f, _, _ := newTestFactory(t) opts := &UpdateOptions{Factory: f, JSON: true} if err := updateRun(opts); err != nil { t.Fatalf("updateRun() err = %v, want nil", err) } if !skillsCalled { t.Error("RunSkillsUpdate not called in already-up-to-date branch (cold stamp), want called") } stamp, _ := skillscheck.ReadStamp() if stamp != "1.0.21" { t.Errorf("stamp = %q, want \"1.0.21\"", stamp) } } func TestUpdateRun_Manual_RunsSkillsSync(t *testing.T) { dir := t.TempDir() t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) origFetch := fetchLatest origCur := currentVersion t.Cleanup(func() { fetchLatest = origFetch; currentVersion = origCur }) fetchLatest = func() (string, error) { return "1.0.22", nil } currentVersion = func() string { return "1.0.21" } skillsCalled := false origNew := newUpdater t.Cleanup(func() { newUpdater = origNew }) newUpdater = func() *selfupdate.Updater { return &selfupdate.Updater{ DetectOverride: func() selfupdate.DetectResult { return selfupdate.DetectResult{ Method: selfupdate.InstallManual, ResolvedPath: "/usr/local/bin/lark-cli", } }, SkillsUpdateOverride: func() *selfupdate.NpmResult { skillsCalled = true return &selfupdate.NpmResult{} }, } } f, _, _ := newTestFactory(t) opts := &UpdateOptions{Factory: f, JSON: true} if err := updateRun(opts); err != nil { t.Fatalf("updateRun() err = %v, want nil", err) } if !skillsCalled { t.Error("RunSkillsUpdate not called in manual branch, want called") } stamp, _ := skillscheck.ReadStamp() if stamp != "1.0.21" { t.Errorf("stamp = %q, want \"1.0.21\" (manual path stamps cur)", stamp) } } func TestUpdateRun_Npm_RunsSkillsSync_StampsLatest(t *testing.T) { dir := t.TempDir() t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) origFetch := fetchLatest origCur := currentVersion t.Cleanup(func() { fetchLatest = origFetch; currentVersion = origCur }) fetchLatest = func() (string, error) { return "1.0.22", nil } currentVersion = func() string { return "1.0.21" } skillsCalled := false origNew := newUpdater t.Cleanup(func() { newUpdater = origNew }) newUpdater = func() *selfupdate.Updater { return &selfupdate.Updater{ DetectOverride: func() selfupdate.DetectResult { return selfupdate.DetectResult{ Method: selfupdate.InstallNpm, NpmAvailable: true, ResolvedPath: "/usr/local/bin/lark-cli", } }, NpmInstallOverride: func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }, VerifyOverride: func(expectedVersion string) error { return nil }, SkillsUpdateOverride: func() *selfupdate.NpmResult { skillsCalled = true return &selfupdate.NpmResult{} }, } } f, _, _ := newTestFactory(t) opts := &UpdateOptions{Factory: f, JSON: true} if err := updateRun(opts); err != nil { t.Fatalf("updateRun() err = %v, want nil", err) } if !skillsCalled { t.Error("RunSkillsUpdate not called in npm branch") } stamp, _ := skillscheck.ReadStamp() if stamp != "1.0.22" { t.Errorf("stamp = %q, want \"1.0.22\" (npm path stamps latest)", stamp) } } func TestUpdateRun_CheckIncludesSkillsStatus(t *testing.T) { dir := t.TempDir() t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) if err := skillscheck.WriteStamp("1.0.20"); err != nil { t.Fatal(err) } origFetch := fetchLatest origCur := currentVersion t.Cleanup(func() { fetchLatest = origFetch; currentVersion = origCur }) fetchLatest = func() (string, error) { return "1.0.22", nil } currentVersion = func() string { return "1.0.21" } origNew := newUpdater t.Cleanup(func() { newUpdater = origNew }) skillsCalled := false newUpdater = func() *selfupdate.Updater { return &selfupdate.Updater{ DetectOverride: func() selfupdate.DetectResult { return selfupdate.DetectResult{Method: selfupdate.InstallNpm, NpmAvailable: true} }, SkillsUpdateOverride: func() *selfupdate.NpmResult { skillsCalled = true return &selfupdate.NpmResult{} }, } } f, stdout, _ := newTestFactory(t) opts := &UpdateOptions{Factory: f, JSON: true, Check: true} if err := updateRun(opts); err != nil { t.Fatalf("updateRun(--check) err = %v, want nil", err) } if skillsCalled { t.Error("RunSkillsUpdate called under --check, want skipped (pure report)") } var env map[string]interface{} if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { t.Fatalf("json.Unmarshal stdout: %v\nstdout: %s", err, stdout.String()) } status, ok := env["skills_status"].(map[string]interface{}) if !ok { t.Fatalf("skills_status missing or wrong type in --check JSON: %s", stdout.String()) } if status["current"] != "1.0.20" || status["target"] != "1.0.21" || status["in_sync"] != false { t.Errorf("skills_status = %+v, want {current:\"1.0.20\", target:\"1.0.21\", in_sync:false}", status) } } func TestUpdateRun_CheckAlreadyLatest_NoSideEffect(t *testing.T) { dir := t.TempDir() t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) if err := skillscheck.WriteStamp("1.0.20"); err != nil { t.Fatal(err) } origFetch := fetchLatest origCur := currentVersion t.Cleanup(func() { fetchLatest = origFetch; currentVersion = origCur }) fetchLatest = func() (string, error) { return "1.0.21", nil } currentVersion = func() string { return "1.0.21" } skillsCalled := false origNew := newUpdater t.Cleanup(func() { newUpdater = origNew }) newUpdater = func() *selfupdate.Updater { return &selfupdate.Updater{ SkillsUpdateOverride: func() *selfupdate.NpmResult { skillsCalled = true return &selfupdate.NpmResult{} }, } } f, stdout, _ := newTestFactory(t) opts := &UpdateOptions{Factory: f, JSON: true, Check: true} if err := updateRun(opts); err != nil { t.Fatalf("updateRun(--check, already-latest) err = %v, want nil", err) } if skillsCalled { t.Error("RunSkillsUpdate called under --check (already-latest), want skipped (pure report)") } stamp, _ := skillscheck.ReadStamp() if stamp != "1.0.20" { t.Errorf("stamp mutated to %q under --check, want \"1.0.20\" (pure report must not write stamp)", stamp) } var env map[string]interface{} if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { t.Fatalf("json.Unmarshal stdout: %v\n%s", err, stdout.String()) } if env["action"] != "already_up_to_date" { t.Errorf("action = %v, want \"already_up_to_date\"", env["action"]) } if _, has := env["skills_action"]; has { t.Errorf("skills_action present under --check, want absent: %+v", env) } status, ok := env["skills_status"].(map[string]interface{}) if !ok { t.Fatalf("skills_status missing under --check + already-latest: %s", stdout.String()) } if status["current"] != "1.0.20" || status["target"] != "1.0.21" || status["in_sync"] != false { t.Errorf("skills_status = %+v, want {current:\"1.0.20\", target:\"1.0.21\", in_sync:false}", status) } } // TestRunSkillsAndStamp_StampWriteFailureWarns verifies the stderr warning // emission when RunSkillsUpdate succeeds but WriteStamp fails. func TestRunSkillsAndStamp_StampWriteFailureWarns(t *testing.T) { // Force WriteStamp to fail by pointing config dir at a path that exists // as a regular file (so MkdirAll fails). tmp := t.TempDir() badPath := filepath.Join(tmp, "blocker") if err := os.WriteFile(badPath, []byte("not-a-dir"), 0o644); err != nil { t.Fatal(err) } t.Setenv("LARKSUITE_CLI_CONFIG_DIR", badPath) f, _, stderr := newTestFactory(t) updater := &selfupdate.Updater{ SkillsUpdateOverride: func() *selfupdate.NpmResult { return &selfupdate.NpmResult{} // success }, } got := runSkillsAndStamp(updater, f.IOStreams, "1.0.21", false) if got == nil || got.Err != nil { t.Fatalf("runSkillsAndStamp() = %+v, want non-nil with nil Err", got) } if !strings.Contains(stderr.String(), "warning: skills synced but stamp not written") { t.Errorf("stderr does not contain warning: %q", stderr.String()) } } // TestEmitSkillsTextHints_Success verifies the "Skills updated" success // message is printed to ErrOut on a successful (Err == nil) result. func TestEmitSkillsTextHints_Success(t *testing.T) { f, _, stderr := newTestFactory(t) emitSkillsTextHints(f.IOStreams, &selfupdate.NpmResult{}) // Err==nil → success if !strings.Contains(stderr.String(), "Skills updated") { t.Errorf("stderr does not contain 'Skills updated': %q", stderr.String()) } }