mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
* refactor: retire legacy error envelopes and enforce typed contract
Consolidate all command error reporting onto the typed errs.* contract, remove
the legacy error surface that predated it, and tighten the lint guards so the
contract holds across the whole repository going forward.
Every failure now reaches stderr as one envelope shape: a category, an
optional subtype, a human- and agent-readable message, and a recovery hint,
with invalid parameters listed under `params`. The legacy ExitError envelope,
its constructors, and the boundary bridge that promoted untyped config and
authorization errors are deleted, leaving a single path from error to wire.
Predicate commands keep their silent-exit behavior through a dedicated signal
that carries only an exit code.
Infrastructure paths that still emitted ad-hoc envelopes — flag parsing,
unknown commands and subcommands, plugin and policy guards, confirmation
prompts, and auth/config failures — now classify into the same taxonomy.
Business, API, auth, and config exit codes are preserved; the one behavioral
change is that Cobra usage failures (missing required flag, unknown command,
bad arguments) now emit the typed validation envelope and exit 2, matching the
explicit flag and subcommand guards, instead of Cobra's plain-text exit 1.
Enforcement is repo-wide rather than per-path:
- The errscontract guards run by default everywhere instead of through a
migration allowlist, so legacy envelopes cannot be reintroduced anywhere.
- errorlint runs across the whole repository: every error wrap must use %w and
every comparison must use errors.Is/errors.As, so interior wraps stay legal
but can no longer break the chain the typed boundary relies on.
- The errs-no-bare-wrap guard is keyed by structural prefix instead of an
explicit per-domain allowlist, so new shortcut domains are covered without
editing a list. It runs where forbidigo is enabled (the shortcut domains and
the auth/config/service command groups); repo-wide chain integrity for the
remaining command paths is carried by errorlint above.
* test: align cli_e2e success assertions to the ok envelope
The api and service success path now emits the {"ok":true} envelope, so the
cli_e2e workflow assertions that still expected the old {"code":0} shape via
AssertStdoutStatus(t, 0) fail once they run with live credentials. Switch those
workflow assertions to AssertStdoutStatus(t, true); the fake-payload helper test
in core_test.go keeps its code-shape assertion.
1597 lines
55 KiB
Go
1597 lines
55 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package cmdupdate
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os/exec"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/larksuite/cli/errs"
|
|
"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.
|
|
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) {
|
|
t.Helper()
|
|
origNew := newUpdater
|
|
newUpdater = func() *selfupdate.Updater {
|
|
u := selfupdate.New()
|
|
u.DetectOverride = func() selfupdate.DetectResult { return result }
|
|
u.NpmInstallOverride = npmFn
|
|
u.VerifyOverride = func(string) error { return nil }
|
|
u.SkillsIndexFetchOverride = successfulSkillsIndexFetch()
|
|
u.SkillsCommandOverride = successfulSkillsCommand()
|
|
return u
|
|
}
|
|
t.Cleanup(func() { newUpdater = origNew })
|
|
}
|
|
|
|
func successfulSkillsIndexFetch() func() *selfupdate.NpmResult {
|
|
return func() *selfupdate.NpmResult {
|
|
r := &selfupdate.NpmResult{}
|
|
r.Stdout.WriteString(`{"skills":[{"name":"lark-calendar"},{"name":"lark-mail"}]}`)
|
|
return r
|
|
}
|
|
}
|
|
|
|
func successfulSkillsCommand() func(args ...string) *selfupdate.NpmResult {
|
|
return func(args ...string) *selfupdate.NpmResult {
|
|
r := &selfupdate.NpmResult{}
|
|
switch strings.Join(args, " ") {
|
|
case "-y skills add https://open.feishu.cn --list":
|
|
r.Stdout.WriteString("Available Skills\n │ lark-calendar\n │ lark-mail\n")
|
|
case "-y skills ls -g --json":
|
|
r.Stdout.WriteString(`[{"name":"lark-calendar","path":"/tmp/lark-calendar","scope":"global","agents":["Codex"]},{"name":"custom-skill","path":"/tmp/custom-skill","scope":"global","agents":["Codex"]}]`)
|
|
case "-y skills ls -g":
|
|
r.Stdout.WriteString("Global Skills\nlark-calendar /tmp/lark-calendar\ncustom-skill /tmp/custom-skill\n")
|
|
default:
|
|
}
|
|
return r
|
|
}
|
|
}
|
|
|
|
func TestNormalizeVersion(t *testing.T) {
|
|
tests := []struct {
|
|
input string
|
|
want string
|
|
}{
|
|
{input: "1.2.3", want: "1.2.3"},
|
|
{input: "v1.2.3", want: "1.2.3"},
|
|
{input: "V1.2.3", want: "1.2.3"},
|
|
{input: " v1.2.3 ", want: "1.2.3"},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.input, func(t *testing.T) {
|
|
if got := normalizeVersion(tt.input); got != tt.want {
|
|
t.Fatalf("normalizeVersion(%q) = %q, want %q", tt.input, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
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 because skills sync writes skills-state.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 }()
|
|
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{} },
|
|
)
|
|
|
|
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{} },
|
|
)
|
|
|
|
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 state-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{} },
|
|
)
|
|
|
|
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 netErr *errs.NetworkError
|
|
if !errors.As(err, &netErr) {
|
|
t.Fatalf("expected *errs.NetworkError, got %T: %v", err, err)
|
|
}
|
|
if netErr.Subtype != errs.SubtypeNetworkTransport {
|
|
t.Errorf("subtype = %q, want %q", netErr.Subtype, errs.SubtypeNetworkTransport)
|
|
}
|
|
if got := output.ExitCodeOf(err); got != output.ExitNetwork {
|
|
t.Errorf("expected ExitNetwork (%d), got %d", output.ExitNetwork, got)
|
|
}
|
|
}
|
|
|
|
// TestUpdateInvalidVersion_Human verifies a malformed registry version surfaces
|
|
// as a typed internal error in human mode, keeping the legacy exit code 5.
|
|
func TestUpdateInvalidVersion_Human(t *testing.T) {
|
|
f, _, _ := newTestFactory(t)
|
|
cmd := NewCmdUpdate(f)
|
|
cmd.SetArgs([]string{})
|
|
|
|
origFetch := fetchLatest
|
|
fetchLatest = func() (string, error) { return "not-a-version", nil }
|
|
defer func() { fetchLatest = origFetch }()
|
|
|
|
cmd.SilenceErrors = true
|
|
cmd.SilenceUsage = true
|
|
|
|
err := cmd.Execute()
|
|
if err == nil {
|
|
t.Fatal("expected non-nil error, got nil")
|
|
}
|
|
var intErr *errs.InternalError
|
|
if !errors.As(err, &intErr) {
|
|
t.Fatalf("expected *errs.InternalError, got %T: %v", err, err)
|
|
}
|
|
if intErr.Subtype != errs.SubtypeInvalidResponse {
|
|
t.Errorf("subtype = %q, want %q", intErr.Subtype, errs.SubtypeInvalidResponse)
|
|
}
|
|
if got := output.ExitCodeOf(err); got != output.ExitInternal {
|
|
t.Errorf("expected ExitInternal (%d), got %d", output.ExitInternal, got)
|
|
}
|
|
}
|
|
|
|
// TestReportError pins reportError's two surfaces after the typed migration:
|
|
// human mode returns the typed error unchanged; JSON mode prints the legacy
|
|
// {ok:false, error:{type, message}} envelope and exits bare with the typed
|
|
// error's exit code (parity with the legacy explicit exit-code argument).
|
|
func TestReportError(t *testing.T) {
|
|
t.Run("human mode returns the typed error", func(t *testing.T) {
|
|
f, _, _ := newTestFactory(t)
|
|
typed := errs.NewAPIError(errs.SubtypeUnknown, "failed to prepare update: disk full")
|
|
err := reportError(&UpdateOptions{JSON: false}, f.IOStreams, "update_error", typed)
|
|
var apiErr *errs.APIError
|
|
if !errors.As(err, &apiErr) {
|
|
t.Fatalf("expected *errs.APIError, got %T: %v", err, err)
|
|
}
|
|
if apiErr != typed {
|
|
t.Errorf("reportError must return the typed error unchanged")
|
|
}
|
|
if got := output.ExitCodeOf(err); got != output.ExitAPI {
|
|
t.Errorf("exit code = %d, want %d (ExitAPI, legacy parity)", got, output.ExitAPI)
|
|
}
|
|
})
|
|
|
|
t.Run("json mode prints envelope and exits bare with typed code", func(t *testing.T) {
|
|
f, stdout, _ := newTestFactory(t)
|
|
typed := errs.NewNetworkError(errs.SubtypeNetworkTransport, "failed to check latest version: timeout")
|
|
err := reportError(&UpdateOptions{JSON: true}, f.IOStreams, "network", typed)
|
|
var bareErr *output.BareError
|
|
if !errors.As(err, &bareErr) {
|
|
t.Fatalf("expected bare *output.BareError, got %T: %v", err, err)
|
|
}
|
|
if bareErr.Code != output.ExitNetwork {
|
|
t.Errorf("bare exit code = %d, want %d", bareErr.Code, output.ExitNetwork)
|
|
}
|
|
out := stdout.String()
|
|
if !strings.Contains(out, `"type": "network"`) && !strings.Contains(out, `"type":"network"`) {
|
|
t.Errorf("JSON envelope missing type, got: %s", out)
|
|
}
|
|
if !strings.Contains(out, "failed to check latest version: timeout") {
|
|
t.Errorf("JSON envelope missing message, got: %s", out)
|
|
}
|
|
})
|
|
}
|
|
|
|
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 state-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{} },
|
|
)
|
|
|
|
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.SkillsIndexFetchOverride = func() *selfupdate.NpmResult {
|
|
t.Fatal("skills sync should not run when binary verification fails")
|
|
return nil
|
|
}
|
|
u.SkillsCommandOverride = func(args ...string) *selfupdate.NpmResult {
|
|
t.Fatal("skills sync 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 bareErr *output.BareError
|
|
if !errors.As(err, &bareErr) {
|
|
t.Fatalf("expected *output.BareError, got %T: %v", err, err)
|
|
}
|
|
if bareErr.Code != output.ExitAPI {
|
|
t.Fatalf("expected ExitAPI (%d), got %d", output.ExitAPI, bareErr.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 state-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{} },
|
|
)
|
|
|
|
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{} },
|
|
)
|
|
|
|
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 }
|
|
u.SkillsIndexFetchOverride = func() *selfupdate.NpmResult {
|
|
r := &selfupdate.NpmResult{}
|
|
r.Err = fmt.Errorf("index unavailable")
|
|
return r
|
|
}
|
|
u.SkillsCommandOverride = func(args ...string) *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_summary") {
|
|
t.Errorf("expected skills_summary 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.SkillsIndexFetchOverride = func() *selfupdate.NpmResult {
|
|
r := &selfupdate.NpmResult{}
|
|
r.Err = fmt.Errorf("index unavailable")
|
|
return r
|
|
}
|
|
u.SkillsCommandOverride = func(args ...string) *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, "lark-cli update --force") {
|
|
t.Errorf("expected force retry hint, got: %s", out)
|
|
}
|
|
}
|
|
|
|
// newTestIO returns a cmdutil.IOStreams backed by bytes.Buffers.
|
|
func newTestIO() *cmdutil.IOStreams {
|
|
return cmdutil.NewIOStreams(&bytes.Buffer{}, &bytes.Buffer{}, &bytes.Buffer{})
|
|
}
|
|
|
|
func TestRunSkillsAndState_DedupHit(t *testing.T) {
|
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.21"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
called := false
|
|
updater := &selfupdate.Updater{
|
|
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
|
called = true
|
|
return &selfupdate.NpmResult{}
|
|
},
|
|
}
|
|
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false)
|
|
if got != nil {
|
|
t.Errorf("runSkillsAndState() = %+v, want nil for dedup hit", got)
|
|
}
|
|
if called {
|
|
t.Error("SkillsCommandOverride called, want skipped due to dedup")
|
|
}
|
|
}
|
|
|
|
func TestRunSkillsAndState_DedupForceBypass(t *testing.T) {
|
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.21"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
called := false
|
|
updater := &selfupdate.Updater{
|
|
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
|
called = true
|
|
return successfulSkillsCommand()(args...)
|
|
},
|
|
}
|
|
got := runSkillsAndState(updater, newTestIO(), "1.0.21", true)
|
|
if got == nil || got.Err != nil {
|
|
t.Fatalf("runSkillsAndState(force=true) = %+v, want successful result", got)
|
|
}
|
|
if !called {
|
|
t.Error("SkillsCommandOverride not called with force=true")
|
|
}
|
|
}
|
|
|
|
func TestRunSkillsAndState_SuccessWritesState(t *testing.T) {
|
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
updater := &selfupdate.Updater{SkillsCommandOverride: successfulSkillsCommand()}
|
|
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false)
|
|
if got == nil || got.Err != nil {
|
|
t.Fatalf("runSkillsAndState() = %+v, want non-nil with nil Err", got)
|
|
}
|
|
state, readable, err := skillscheck.ReadState()
|
|
if err != nil || !readable {
|
|
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
|
|
}
|
|
if state.Version != "1.0.21" {
|
|
t.Errorf("state.Version = %q, want \"1.0.21\"", state.Version)
|
|
}
|
|
}
|
|
|
|
func TestRunSkillsAndState_FailureKeepsOldState(t *testing.T) {
|
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.20"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
updater := &selfupdate.Updater{
|
|
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
|
r := &selfupdate.NpmResult{}
|
|
r.Err = fmt.Errorf("npx failed")
|
|
return r
|
|
},
|
|
}
|
|
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false)
|
|
if got == nil || got.Err == nil {
|
|
t.Fatalf("runSkillsAndState() = %+v, want non-nil with non-nil Err", got)
|
|
}
|
|
state, readable, err := skillscheck.ReadState()
|
|
if err != nil || !readable {
|
|
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
|
|
}
|
|
if state.Version != "1.0.20" {
|
|
t.Errorf("state.Version = %q, want \"1.0.20\" (failure must not overwrite)", state.Version)
|
|
}
|
|
}
|
|
|
|
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) {
|
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
|
|
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{
|
|
SkillsIndexFetchOverride: successfulSkillsIndexFetch(),
|
|
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
|
skillsCalled = true
|
|
return successfulSkillsCommand()(args...)
|
|
},
|
|
}
|
|
}
|
|
|
|
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("skills sync not called in already-up-to-date branch")
|
|
}
|
|
state, readable, err := skillscheck.ReadState()
|
|
if err != nil || !readable {
|
|
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
|
|
}
|
|
if state.Version != "1.0.21" {
|
|
t.Errorf("state.Version = %q, want \"1.0.21\"", state.Version)
|
|
}
|
|
}
|
|
|
|
func TestUpdateRun_Manual_RunsSkillsSync(t *testing.T) {
|
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
|
|
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{
|
|
SkillsIndexFetchOverride: successfulSkillsIndexFetch(),
|
|
DetectOverride: func() selfupdate.DetectResult {
|
|
return selfupdate.DetectResult{
|
|
Method: selfupdate.InstallManual,
|
|
ResolvedPath: "/usr/local/bin/lark-cli",
|
|
}
|
|
},
|
|
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
|
skillsCalled = true
|
|
return successfulSkillsCommand()(args...)
|
|
},
|
|
}
|
|
}
|
|
|
|
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("skills sync not called in manual branch")
|
|
}
|
|
state, readable, err := skillscheck.ReadState()
|
|
if err != nil || !readable {
|
|
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
|
|
}
|
|
if state.Version != "1.0.21" {
|
|
t.Errorf("state.Version = %q, want \"1.0.21\" (manual path records current binary)", state.Version)
|
|
}
|
|
}
|
|
|
|
func TestUpdateRun_Npm_RunsSkillsSync_WritesLatestState(t *testing.T) {
|
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
|
|
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{
|
|
SkillsIndexFetchOverride: successfulSkillsIndexFetch(),
|
|
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 },
|
|
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
|
skillsCalled = true
|
|
return successfulSkillsCommand()(args...)
|
|
},
|
|
}
|
|
}
|
|
|
|
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("skills sync not called in npm branch")
|
|
}
|
|
state, readable, err := skillscheck.ReadState()
|
|
if err != nil || !readable {
|
|
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
|
|
}
|
|
if state.Version != "1.0.22" {
|
|
t.Errorf("state.Version = %q, want \"1.0.22\" (npm path records latest binary)", state.Version)
|
|
}
|
|
}
|
|
|
|
func TestUpdateRun_CheckIncludesSkillsStatus(t *testing.T) {
|
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
if err := skillscheck.WriteState(skillscheck.SkillsState{
|
|
Version: "1.0.20",
|
|
OfficialSkills: []string{"lark-calendar", "lark-mail"},
|
|
UpdatedSkills: []string{"lark-calendar"},
|
|
SkippedDeletedSkills: []string{"lark-mail"},
|
|
}); 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}
|
|
},
|
|
SkillsIndexFetchOverride: func() *selfupdate.NpmResult {
|
|
skillsCalled = true
|
|
return successfulSkillsIndexFetch()()
|
|
},
|
|
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
|
skillsCalled = true
|
|
return successfulSkillsCommand()(args...)
|
|
},
|
|
}
|
|
}
|
|
|
|
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("skills sync called under --check, want skipped")
|
|
}
|
|
|
|
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)
|
|
}
|
|
if status["official"] != float64(2) || status["updated"] != float64(1) {
|
|
t.Errorf("skills_status counts = %+v, want official:2 updated:1", status)
|
|
}
|
|
}
|
|
|
|
func TestUpdateRun_CheckAlreadyLatest_NoSideEffect(t *testing.T) {
|
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "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{
|
|
SkillsIndexFetchOverride: func() *selfupdate.NpmResult {
|
|
skillsCalled = true
|
|
return successfulSkillsIndexFetch()()
|
|
},
|
|
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
|
skillsCalled = true
|
|
return successfulSkillsCommand()(args...)
|
|
},
|
|
}
|
|
}
|
|
|
|
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("skills sync called under --check (already-latest), want skipped")
|
|
}
|
|
|
|
state, readable, err := skillscheck.ReadState()
|
|
if err != nil || !readable {
|
|
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
|
|
}
|
|
if state.Version != "1.0.20" {
|
|
t.Errorf("state.Version mutated to %q under --check, want \"1.0.20\"", state.Version)
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
func TestRunSkillsAndState_StateWriteFailureWarns(t *testing.T) {
|
|
origSync := syncSkills
|
|
syncSkills = func(opts skillscheck.SyncOptions) *skillscheck.SyncResult {
|
|
return &skillscheck.SyncResult{Err: fmt.Errorf("skills synced but state not written: denied")}
|
|
}
|
|
t.Cleanup(func() { syncSkills = origSync })
|
|
|
|
f, _, stderr := newTestFactory(t)
|
|
got := runSkillsAndState(&selfupdate.Updater{}, f.IOStreams, "1.0.21", false)
|
|
if got == nil || got.Err == nil {
|
|
t.Fatalf("runSkillsAndState() = %+v, want non-nil with write error", got)
|
|
}
|
|
if !strings.Contains(stderr.String(), "warning: skills synced but state not written") {
|
|
t.Errorf("stderr does not contain warning: %q", stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestEmitSkillsTextHints_Success(t *testing.T) {
|
|
f, _, stderr := newTestFactory(t)
|
|
emitSkillsTextHints(f.IOStreams, &skillscheck.SyncResult{Official: []string{"lark-calendar"}, Updated: []string{"lark-calendar"}})
|
|
if !strings.Contains(stderr.String(), "Skills updated") {
|
|
t.Errorf("stderr does not contain 'Skills updated': %q", stderr.String())
|
|
}
|
|
}
|
|
|
|
// TestUpdateCommand_RealSkillsSyncRewritesState is a live integration test that
|
|
// verifies "lark-cli update" correctly triggers skills sync and rewrites the
|
|
// state file. It calls the real npx skills CLI, so the test is skipped when
|
|
// npx or the skills registry is unavailable (e.g. no network or fork PRs).
|
|
func TestUpdateCommand_RealSkillsSyncRewritesState(t *testing.T) {
|
|
// Phase 1: Verify the real npx skills CLI is available; skip otherwise.
|
|
if _, err := exec.LookPath("npx"); err != nil {
|
|
t.Skipf("npx not found in PATH: %v", err)
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
|
defer cancel()
|
|
if err := exec.CommandContext(ctx, "npx", "-y", "skills", "add", "https://open.feishu.cn", "--list").Run(); err != nil {
|
|
t.Skipf("real skills CLI unavailable: %v", err)
|
|
}
|
|
globalOut, err := exec.CommandContext(ctx, "npx", "-y", "skills", "ls", "-g").Output()
|
|
if err != nil {
|
|
t.Skipf("real global skills CLI unavailable: %v", err)
|
|
}
|
|
localSkills := skillscheck.ParseSkillsList(string(globalOut))
|
|
if err := ctx.Err(); err != nil {
|
|
t.Skipf("real skills CLI availability check timed out: %v", err)
|
|
}
|
|
|
|
// Phase 2: Seed a previous sync state simulating an upgrade from v1.0.19.
|
|
// lark-doc and lark-mail are recorded as skipped/deleted, meaning the user
|
|
// intentionally removed them while they were still official skills.
|
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
before := skillscheck.SkillsState{
|
|
Version: "1.0.19",
|
|
OfficialSkills: []string{"lark-approval", "lark-attendance", "lark-base", "lark-calendar", "lark-contact", "lark-doc", "lark-drive", "lark-event", "lark-im", "lark-mail", "lark-markdown", "lark-minutes", "lark-okr", "lark-openapi-explorer", "lark-shared", "lark-sheets", "lark-skill-maker", "lark-slides", "lark-task", "lark-vc", "lark-vc-agent", "lark-whiteboard", "lark-wiki", "lark-workflow-meeting-summary", "lark-workflow-standup-report"},
|
|
UpdatedSkills: []string{"lark-approval", "lark-apps", "lark-attendance", "lark-base", "lark-calendar", "lark-contact", "lark-doc", "lark-drive", "lark-event", "lark-im", "lark-mail", "lark-markdown", "lark-minutes", "lark-okr", "lark-openapi-explorer", "lark-shared", "lark-sheets", "lark-skill-maker", "lark-slides", "lark-task", "lark-vc", "lark-vc-agent", "lark-whiteboard", "lark-wiki", "lark-workflow-meeting-summary", "lark-workflow-standup-report"},
|
|
AddedOfficialSkills: []string{},
|
|
SkippedDeletedSkills: []string{},
|
|
UpdatedAt: "2026-05-20T00:00:00Z",
|
|
}
|
|
if err := skillscheck.WriteState(before); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
state, readable, err := skillscheck.ReadState()
|
|
if err != nil || !readable {
|
|
t.Fatalf("ReadState() before update = (_, %v, %v), want readable", readable, err)
|
|
}
|
|
if state.Version != "1.0.19" {
|
|
t.Fatalf("state.Version before update = %q, want 1.0.19", state.Version)
|
|
}
|
|
|
|
// Phase 3: Mock version functions so the update command believes it has
|
|
// upgraded from 1.0.19 to 1.0.20, then execute "lark-cli update --json".
|
|
// This triggers SyncSkills which calls the real npx skills add command.
|
|
origFetch := fetchLatest
|
|
origVersion := currentVersion
|
|
t.Cleanup(func() { fetchLatest = origFetch; currentVersion = origVersion })
|
|
fetchLatest = func() (string, error) { return "1.0.20", nil }
|
|
currentVersion = func() string { return "1.0.20" }
|
|
|
|
f, stdout, _ := newTestFactory(t)
|
|
cmd := NewCmdUpdate(f)
|
|
cmd.SetArgs([]string{"--json"})
|
|
if err := cmd.Execute(); err != nil {
|
|
t.Fatalf("lark-cli update --json err = %v, want nil", err)
|
|
}
|
|
|
|
// Phase 4: Verify the state file was rewritten with the new version,
|
|
// non-empty official/updated skill lists, and a refreshed timestamp.
|
|
state, readable, err = skillscheck.ReadState()
|
|
if err != nil || !readable {
|
|
t.Fatalf("ReadState() after update = (_, %v, %v), want readable", readable, err)
|
|
}
|
|
if state.Version != "1.0.20" {
|
|
t.Errorf("state.Version after update = %q, want 1.0.20", state.Version)
|
|
}
|
|
if len(state.OfficialSkills) == 0 {
|
|
t.Fatalf("state.OfficialSkills after real sync is empty: %+v", state)
|
|
}
|
|
if len(state.UpdatedSkills) == 0 {
|
|
t.Fatalf("state.UpdatedSkills after real sync is empty: %+v", state)
|
|
}
|
|
if state.UpdatedAt == "" || state.UpdatedAt == before.UpdatedAt {
|
|
t.Errorf("state.UpdatedAt = %q, want refreshed non-empty timestamp", state.UpdatedAt)
|
|
}
|
|
// Verify that previously-skipped skills are handled correctly:
|
|
// - If locally installed → should appear in UpdatedSkills (updated to latest)
|
|
// - If locally absent → should NOT be force-restored in UpdatedSkills,
|
|
// and should remain in SkippedDeletedSkills
|
|
for _, skill := range []string{"lark-doc", "lark-mail"} {
|
|
if containsString(localSkills, skill) {
|
|
if !containsString(state.UpdatedSkills, skill) {
|
|
t.Errorf("state.UpdatedSkills = %v, want installed skill %q updated", state.UpdatedSkills, skill)
|
|
}
|
|
continue
|
|
}
|
|
if containsString(state.UpdatedSkills, skill) {
|
|
t.Errorf("state.UpdatedSkills = %v, want deleted skill %q not restored without --force", state.UpdatedSkills, skill)
|
|
}
|
|
if !containsString(state.SkippedDeletedSkills, skill) {
|
|
t.Errorf("state.SkippedDeletedSkills = %v, want deleted skill %q preserved when still official", state.SkippedDeletedSkills, skill)
|
|
}
|
|
}
|
|
|
|
// Phase 5: Verify the JSON output structure is parseable and contains
|
|
// the expected action fields for AI agent consumption.
|
|
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())
|
|
}
|
|
if env["action"] != "already_up_to_date" {
|
|
t.Errorf("action = %v, want already_up_to_date", env["action"])
|
|
}
|
|
if env["skills_action"] != "synced" {
|
|
t.Errorf("skills_action = %v, want synced", env["skills_action"])
|
|
}
|
|
}
|
|
|
|
// TestUpdateCommand_SkillsSyncColdStart verifies that when skills-state.json does
|
|
// not exist (cold start), the update command installs all official skills and
|
|
// writes a fresh state file. No skill should appear in SkippedDeletedSkills
|
|
// because there is no previous state to preserve user deletions from.
|
|
// This is a live integration test that calls the real npx skills CLI; it is
|
|
// skipped when npx or the skills registry is unavailable.
|
|
func TestUpdateCommand_SkillsSyncColdStart(t *testing.T) {
|
|
// Phase 1: Verify the real npx skills CLI is available; skip otherwise.
|
|
if _, err := exec.LookPath("npx"); err != nil {
|
|
t.Skipf("npx not found in PATH: %v", err)
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
|
defer cancel()
|
|
if err := exec.CommandContext(ctx, "npx", "-y", "skills", "add", "https://open.feishu.cn", "--list").Run(); err != nil {
|
|
t.Skipf("real skills CLI unavailable: %v", err)
|
|
}
|
|
globalOut, err := exec.CommandContext(ctx, "npx", "-y", "skills", "ls", "-g").Output()
|
|
if err != nil {
|
|
t.Skipf("real global skills CLI unavailable: %v", err)
|
|
}
|
|
localSkills := skillscheck.ParseSkillsList(string(globalOut))
|
|
if err := ctx.Err(); err != nil {
|
|
t.Skipf("real skills CLI availability check timed out: %v", err)
|
|
}
|
|
|
|
// Phase 2: Use an isolated config dir with no pre-existing skills-state.json.
|
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
if _, readable, _ := skillscheck.ReadState(); readable {
|
|
t.Fatal("skills-state.json should not exist before update")
|
|
}
|
|
|
|
// Phase 3: Mock version functions so the update command believes it is at
|
|
// v1.0.20, then execute "lark-cli update --json". This triggers SyncSkills
|
|
// which calls the real npx skills add command.
|
|
origFetch := fetchLatest
|
|
origVersion := currentVersion
|
|
t.Cleanup(func() { fetchLatest = origFetch; currentVersion = origVersion })
|
|
fetchLatest = func() (string, error) { return "1.0.20", nil }
|
|
currentVersion = func() string { return "1.0.20" }
|
|
|
|
f, stdout, _ := newTestFactory(t)
|
|
cmd := NewCmdUpdate(f)
|
|
cmd.SetArgs([]string{"--json"})
|
|
if err := cmd.Execute(); err != nil {
|
|
t.Fatalf("lark-cli update --json err = %v, want nil", err)
|
|
}
|
|
|
|
// Phase 4: Verify the state file was created with all official skills in
|
|
// UpdatedSkills and nothing in SkippedDeletedSkills (cold start = no prior
|
|
// deletions to honor). Locally installed skills should appear in UpdatedSkills.
|
|
state, readable, err := skillscheck.ReadState()
|
|
if err != nil || !readable {
|
|
t.Fatalf("ReadState() after update = (_, %v, %v), want readable", readable, err)
|
|
}
|
|
if state.Version != "1.0.20" {
|
|
t.Errorf("state.Version = %q, want 1.0.20", state.Version)
|
|
}
|
|
if len(state.OfficialSkills) == 0 {
|
|
t.Fatalf("state.OfficialSkills after real sync is empty: %+v", state)
|
|
}
|
|
if len(state.UpdatedSkills) == 0 {
|
|
t.Fatalf("state.UpdatedSkills after real sync is empty: %+v", state)
|
|
}
|
|
if state.UpdatedAt == "" {
|
|
t.Error("state.UpdatedAt is empty, want non-empty timestamp")
|
|
}
|
|
// All locally installed official skills must appear in UpdatedSkills.
|
|
officialSet := map[string]bool{}
|
|
for _, s := range state.OfficialSkills {
|
|
officialSet[s] = true
|
|
}
|
|
for _, skill := range localSkills {
|
|
if !officialSet[skill] {
|
|
continue
|
|
}
|
|
if !containsString(state.UpdatedSkills, skill) {
|
|
t.Errorf("state.UpdatedSkills = %v, want locally installed official skill %q updated", state.UpdatedSkills, skill)
|
|
}
|
|
}
|
|
// No skill should be in SkippedDeletedSkills on cold start — there is no
|
|
// previous state recording a user deletion to preserve.
|
|
if len(state.SkippedDeletedSkills) != 0 {
|
|
t.Errorf("state.SkippedDeletedSkills = %v, want empty on cold start", state.SkippedDeletedSkills)
|
|
}
|
|
|
|
// Phase 5: Verify the JSON output structure is parseable and contains
|
|
// the expected action fields for AI agent consumption.
|
|
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())
|
|
}
|
|
if env["action"] != "already_up_to_date" {
|
|
t.Errorf("action = %v, want already_up_to_date", env["action"])
|
|
}
|
|
if env["skills_action"] != "synced" {
|
|
t.Errorf("skills_action = %v, want synced", env["skills_action"])
|
|
}
|
|
}
|
|
|
|
func containsString(values []string, target string) bool {
|
|
for _, value := range values {
|
|
if value == target {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|