mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
Every failure on the authentication, authorization, and configuration
path now surfaces as a typed structured error instead of an ad-hoc
envelope. Users and scripts that consume CLI output get:
- a fixed nine-category taxonomy on the wire, each mapped to a
stable shell exit code (authentication/authorization/config = 3,
network = 4, internal = 5, policy = 6, confirmation = 10)
- identity-aware detail fields (missing_scopes, requested_scopes,
granted_scopes, console_url, log_id, retryable, hint) carried
uniformly on the envelope
- a single canonical policy envelope at exit 6; the legacy
auth_error carve-out is retired
- per-subtype canonical message + hint that preserves Lark's
diagnostic phrasing and routes recovery to the right actor:
app developer (app_scope_not_applied), user (missing_scope,
token_scope_insufficient, user_unauthorized), or tenant admin
(app_unavailable, app_disabled)
- wrong app credentials classify as config/invalid_client whether
surfaced by the Open API endpoint (99991543) or the tenant
access-token mint endpoint (10003 / 10014), instead of
collapsing to a transport error or api/unknown
- local shortcut scope preflight emits the same
authorization/missing_scope envelope (identity + deterministic
missing-scope set) used by the post-call permission path, so AI
consumers read the same structured shape from precheck and from
server-returned permission denial
- streaming download/upload failures keep the same network subtype
split (timeout / TLS / DNS / transport) as the non-stream path
instead of collapsing every cause to a generic transport failure
- console_url is carried only on the bot-perspective
app_scope_not_applied envelope (where the recovery action is
"developer applies the scope at the developer console"); the
user-perspective missing_scope envelope drops the field, since
the only actionable user recovery is `lark-cli auth login --scope`
and pointing an end user at a console they cannot modify is
misleading
- bind workflows (Hermes / OpenClaw / lark-channel) flatten dynamic
Type tags to wire 'config' with the original module name kept
as a metric label
All 10 typed errors are cause-bearing, nil-safe on .Error() and
.Unwrap(), and defensively clone slice setter inputs. Four lint
rules (CheckNilSafeError / CheckBuilderImmutable / CheckUnwrapSymmetry
/ CheckBuildAPIErrorArms) lock these invariants on migrated paths.
573 lines
19 KiB
Go
573 lines
19 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package cmd
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"os"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/larksuite/cli/cmd/api"
|
|
"github.com/larksuite/cli/cmd/auth"
|
|
"github.com/larksuite/cli/cmd/service"
|
|
"github.com/larksuite/cli/internal/build"
|
|
"github.com/larksuite/cli/internal/cmdutil"
|
|
"github.com/larksuite/cli/internal/core"
|
|
"github.com/larksuite/cli/internal/envvars"
|
|
"github.com/larksuite/cli/internal/httpmock"
|
|
"github.com/larksuite/cli/internal/output"
|
|
"github.com/larksuite/cli/internal/skillscheck"
|
|
"github.com/larksuite/cli/internal/update"
|
|
"github.com/larksuite/cli/shortcuts"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
// Canonical strict-mode envelope strings shared across fixtures
|
|
// (reflect.DeepEqual pins them; keep in sync with strictModeStubFrom).
|
|
const (
|
|
strictModeBotMessage = `strict mode is "bot", only bot-identity commands are available`
|
|
strictModeUserMessage = `strict mode is "user", only user-identity commands are available`
|
|
strictModeHint = "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)"
|
|
)
|
|
|
|
// buildIntegrationRootCmd creates a root command with api, service, and shortcut
|
|
// subcommands wired to a test factory, simulating the real CLI command tree.
|
|
func buildIntegrationRootCmd(t *testing.T, f *cmdutil.Factory) *cobra.Command {
|
|
t.Helper()
|
|
rootCmd := &cobra.Command{Use: "lark-cli"}
|
|
rootCmd.SilenceErrors = true
|
|
rootCmd.SetOut(f.IOStreams.Out)
|
|
rootCmd.SetErr(f.IOStreams.ErrOut)
|
|
rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {
|
|
cmd.SilenceUsage = true
|
|
}
|
|
rootCmd.AddCommand(api.NewCmdApi(f, nil))
|
|
service.RegisterServiceCommands(rootCmd, f)
|
|
shortcuts.RegisterShortcuts(rootCmd, f)
|
|
return rootCmd
|
|
}
|
|
|
|
// executeRootIntegration runs a command through the full command tree and
|
|
// handleRootError, returning the exit code matching real CLI behavior.
|
|
func executeRootIntegration(t *testing.T, f *cmdutil.Factory, rootCmd *cobra.Command, args []string) int {
|
|
t.Helper()
|
|
rootCmd.SetArgs(args)
|
|
if err := rootCmd.Execute(); err != nil {
|
|
return handleRootError(f, err)
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// parseEnvelope parses stderr bytes into an ErrorEnvelope.
|
|
func parseEnvelope(t *testing.T, stderr *bytes.Buffer) output.ErrorEnvelope {
|
|
t.Helper()
|
|
if stderr.Len() == 0 {
|
|
t.Fatal("expected non-empty stderr, got empty")
|
|
}
|
|
var env output.ErrorEnvelope
|
|
if err := json.Unmarshal(stderr.Bytes(), &env); err != nil {
|
|
t.Fatalf("failed to parse stderr as ErrorEnvelope: %v\nstderr: %s", err, stderr.String())
|
|
}
|
|
return env
|
|
}
|
|
|
|
// assertEnvelope verifies exit code, stdout is empty, and stderr matches the
|
|
// expected ErrorEnvelope exactly via reflect.DeepEqual.
|
|
func assertEnvelope(t *testing.T, code int, wantCode int, stdout *bytes.Buffer, stderr *bytes.Buffer, want output.ErrorEnvelope) {
|
|
t.Helper()
|
|
if code != wantCode {
|
|
t.Errorf("exit code: got %d, want %d", code, wantCode)
|
|
}
|
|
if stdout.Len() != 0 {
|
|
t.Errorf("expected empty stdout, got:\n%s", stdout.String())
|
|
}
|
|
got := parseEnvelope(t, stderr)
|
|
if !reflect.DeepEqual(got, want) {
|
|
gotJSON, _ := json.MarshalIndent(got, "", " ")
|
|
wantJSON, _ := json.MarshalIndent(want, "", " ")
|
|
t.Errorf("stderr envelope mismatch:\ngot:\n%s\nwant:\n%s", gotJSON, wantJSON)
|
|
}
|
|
}
|
|
|
|
func buildStrictModeIntegrationRootCmd(t *testing.T, f *cmdutil.Factory) *cobra.Command {
|
|
t.Helper()
|
|
rootCmd := &cobra.Command{Use: "lark-cli"}
|
|
rootCmd.SilenceErrors = true
|
|
rootCmd.SetOut(f.IOStreams.Out)
|
|
rootCmd.SetErr(f.IOStreams.ErrOut)
|
|
rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {
|
|
cmd.SilenceUsage = true
|
|
}
|
|
rootCmd.AddCommand(auth.NewCmdAuth(f))
|
|
rootCmd.AddCommand(api.NewCmdApi(f, nil))
|
|
service.RegisterServiceCommands(rootCmd, f)
|
|
shortcuts.RegisterShortcuts(rootCmd, f)
|
|
if mode := f.ResolveStrictMode(context.Background()); mode.IsActive() {
|
|
pruneForStrictMode(rootCmd, mode)
|
|
}
|
|
return rootCmd
|
|
}
|
|
|
|
func newStrictModeDefaultFactory(t *testing.T, profile string, mode core.StrictMode) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer) {
|
|
t.Helper()
|
|
t.Setenv(envvars.CliAppID, "")
|
|
t.Setenv(envvars.CliAppSecret, "")
|
|
t.Setenv(envvars.CliUserAccessToken, "")
|
|
t.Setenv(envvars.CliTenantAccessToken, "")
|
|
t.Setenv(envvars.CliDefaultAs, "")
|
|
|
|
dir := t.TempDir()
|
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
|
|
|
targetMode := mode
|
|
multi := &core.MultiAppConfig{
|
|
CurrentApp: "default",
|
|
Apps: []core.AppConfig{
|
|
{
|
|
Name: "default",
|
|
AppId: "app-default",
|
|
AppSecret: core.PlainSecret("secret-default"),
|
|
Brand: core.BrandFeishu,
|
|
},
|
|
{
|
|
Name: "target",
|
|
AppId: "app-target",
|
|
AppSecret: core.PlainSecret("secret-target"),
|
|
Brand: core.BrandFeishu,
|
|
StrictMode: &targetMode,
|
|
},
|
|
},
|
|
}
|
|
if err := core.SaveMultiAppConfig(multi); err != nil {
|
|
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
|
}
|
|
|
|
stdout := &bytes.Buffer{}
|
|
stderr := &bytes.Buffer{}
|
|
f := cmdutil.NewDefault(
|
|
cmdutil.NewIOStreams(&bytes.Buffer{}, stdout, stderr),
|
|
cmdutil.InvocationContext{Profile: profile},
|
|
)
|
|
return f, stdout, stderr
|
|
}
|
|
|
|
func resetBuffers(stdout *bytes.Buffer, stderr *bytes.Buffer) {
|
|
stdout.Reset()
|
|
stderr.Reset()
|
|
}
|
|
|
|
// --- service command ---
|
|
|
|
func TestIntegration_StrictModeBot_ProfileOverride_HidesCommandsInHelp(t *testing.T) {
|
|
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeBot)
|
|
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
|
|
|
|
code := executeRootIntegration(t, f, rootCmd, []string{"auth", "--help"})
|
|
if code != 0 {
|
|
t.Fatalf("auth --help exit code = %d, want 0", code)
|
|
}
|
|
if stderr.Len() != 0 {
|
|
t.Fatalf("expected empty stderr, got: %s", stderr.String())
|
|
}
|
|
if strings.Contains(stdout.String(), "login") {
|
|
t.Fatalf("auth --help should hide login in bot mode, got:\n%s", stdout.String())
|
|
}
|
|
|
|
resetBuffers(stdout, stderr)
|
|
rootCmd = buildStrictModeIntegrationRootCmd(t, f)
|
|
code = executeRootIntegration(t, f, rootCmd, []string{"im", "--help"})
|
|
if code != 0 {
|
|
t.Fatalf("im --help exit code = %d, want 0", code)
|
|
}
|
|
if stderr.Len() != 0 {
|
|
t.Fatalf("expected empty stderr, got: %s", stderr.String())
|
|
}
|
|
if strings.Contains(stdout.String(), "+messages-search") {
|
|
t.Fatalf("im --help should hide +messages-search in bot mode, got:\n%s", stdout.String())
|
|
}
|
|
if !strings.Contains(stdout.String(), "+chat-create") {
|
|
t.Fatalf("im --help should keep +chat-create in bot mode, got:\n%s", stdout.String())
|
|
}
|
|
}
|
|
|
|
func TestIntegration_StrictModeBot_ProfileOverride_DirectAuthLoginReturnsEnvelope(t *testing.T) {
|
|
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeBot)
|
|
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
|
|
|
|
code := executeRootIntegration(t, f, rootCmd, []string{
|
|
"auth", "login", "--json", "--scope", "im:message.send_as_user",
|
|
})
|
|
|
|
// auth login is user-only, so it gets pruned in strict-mode-bot and the
|
|
// stub error fires (not login.go's inline check, which is shadowed by
|
|
// pruning).
|
|
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
|
|
OK: false,
|
|
Error: &output.ErrDetail{
|
|
Type: "command_denied",
|
|
Message: strictModeBotMessage,
|
|
Hint: strictModeHint,
|
|
Detail: map[string]any{
|
|
"path": "auth/login",
|
|
"layer": "strict_mode",
|
|
"policy_source": "strict-mode",
|
|
"rule_name": "",
|
|
"reason_code": "identity_not_supported",
|
|
"reason": strictModeBotMessage,
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
func TestIntegration_StrictModeBot_ProfileOverride_DirectUserShortcutReturnsEnvelope(t *testing.T) {
|
|
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeBot)
|
|
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
|
|
|
|
code := executeRootIntegration(t, f, rootCmd, []string{
|
|
"im", "+messages-search", "--chat-id", "oc_xxx", "--query", "hello",
|
|
})
|
|
|
|
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
|
|
OK: false,
|
|
Error: &output.ErrDetail{
|
|
Type: "command_denied",
|
|
Message: strictModeBotMessage,
|
|
Hint: strictModeHint,
|
|
Detail: map[string]any{
|
|
"path": "im/+messages-search",
|
|
"layer": "strict_mode",
|
|
"policy_source": "strict-mode",
|
|
"rule_name": "",
|
|
"reason_code": "identity_not_supported",
|
|
"reason": strictModeBotMessage,
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
func TestIntegration_StrictModeUser_ProfileOverride_ChatCreateDryRunSucceeds(t *testing.T) {
|
|
// +chat-create supports both user and bot identities, so strict mode user
|
|
// should allow it and force user identity.
|
|
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeUser)
|
|
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
|
|
|
|
code := executeRootIntegration(t, f, rootCmd, []string{
|
|
"im", "+chat-create", "--name", "probe", "--dry-run",
|
|
})
|
|
|
|
if code != 0 {
|
|
t.Fatalf("exit code = %d, want 0; stderr: %s", code, stderr.String())
|
|
}
|
|
out := stdout.String()
|
|
if out == "" {
|
|
t.Fatal("expected non-empty stdout for dry-run")
|
|
}
|
|
}
|
|
|
|
func TestIntegration_StrictModeUser_ProfileOverride_ShortcutExplicitBotReturnsEnvelope(t *testing.T) {
|
|
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeUser)
|
|
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
|
|
|
|
code := executeRootIntegration(t, f, rootCmd, []string{
|
|
"im", "+chat-create", "--name", "probe", "--as", "bot", "--dry-run",
|
|
})
|
|
|
|
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
|
|
OK: false,
|
|
Identity: "bot",
|
|
Error: &output.ErrDetail{
|
|
Type: "validation",
|
|
Message: `strict mode is "user", only user-identity commands are available`,
|
|
Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)",
|
|
},
|
|
})
|
|
}
|
|
|
|
func TestIntegration_StrictModeBot_ProfileOverride_ServiceExplicitUserReturnsEnvelope(t *testing.T) {
|
|
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeBot)
|
|
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
|
|
|
|
code := executeRootIntegration(t, f, rootCmd, []string{
|
|
"im", "chats", "get", "--params", `{"chat_id":"oc_test"}`, "--as", "user", "--dry-run",
|
|
})
|
|
|
|
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
|
|
OK: false,
|
|
Identity: "user",
|
|
Error: &output.ErrDetail{
|
|
Type: "validation",
|
|
Message: `strict mode is "bot", only bot-identity commands are available`,
|
|
Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)",
|
|
},
|
|
})
|
|
}
|
|
|
|
func TestIntegration_StrictModeUser_ProfileOverride_ServiceBotOnlyMethodReturnsEnvelope(t *testing.T) {
|
|
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeUser)
|
|
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
|
|
|
|
code := executeRootIntegration(t, f, rootCmd, []string{
|
|
"im", "images", "create", "--data", `{"image_type":"message","image":"x"}`, "--dry-run",
|
|
})
|
|
|
|
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
|
|
OK: false,
|
|
Error: &output.ErrDetail{
|
|
Type: "command_denied",
|
|
Message: strictModeUserMessage,
|
|
Hint: strictModeHint,
|
|
Detail: map[string]any{
|
|
"path": "im/images/create",
|
|
"layer": "strict_mode",
|
|
"policy_source": "strict-mode",
|
|
"rule_name": "",
|
|
"reason_code": "identity_not_supported",
|
|
"reason": strictModeUserMessage,
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
func TestIntegration_StrictModeBot_ProfileOverride_APIExplicitUserReturnsEnvelope(t *testing.T) {
|
|
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeBot)
|
|
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
|
|
|
|
code := executeRootIntegration(t, f, rootCmd, []string{
|
|
"api", "--as", "user", "GET", "/open-apis/im/v1/chats/oc_test", "--dry-run",
|
|
})
|
|
|
|
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
|
|
OK: false,
|
|
Identity: "user",
|
|
Error: &output.ErrDetail{
|
|
Type: "validation",
|
|
Message: `strict mode is "bot", only bot-identity commands are available`,
|
|
Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)",
|
|
},
|
|
})
|
|
}
|
|
|
|
// --- shortcut command ---
|
|
|
|
func TestIntegration_Shortcut_BusinessError_OutputsEnvelope(t *testing.T) {
|
|
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
|
AppID: "e2e-sc-err", AppSecret: "secret", Brand: core.BrandFeishu,
|
|
})
|
|
reg.Register(&httpmock.Stub{
|
|
URL: "/open-apis/im/v1/messages",
|
|
Status: 400,
|
|
Body: map[string]interface{}{
|
|
"code": 230002,
|
|
"msg": "Bot/User can NOT be out of the chat.",
|
|
},
|
|
})
|
|
|
|
rootCmd := buildIntegrationRootCmd(t, f)
|
|
code := executeRootIntegration(t, f, rootCmd, []string{
|
|
"im", "+messages-send", "--as", "bot", "--chat-id", "oc_xxx", "--text", "test",
|
|
})
|
|
|
|
// shortcut: typed error via DoAPIJSON path
|
|
assertEnvelope(t, code, output.ExitAPI, stdout, stderr, output.ErrorEnvelope{
|
|
OK: false,
|
|
Identity: "bot",
|
|
Error: &output.ErrDetail{
|
|
Type: "api_error",
|
|
Code: 230002,
|
|
Message: "HTTP 400: Bot/User can NOT be out of the chat.",
|
|
},
|
|
})
|
|
}
|
|
|
|
// TestSetupNotices_ColdStart_NoNotice verifies that missing state
|
|
// produces no skills key in the composed notice.
|
|
func TestSetupNotices_ColdStart_NoNotice(t *testing.T) {
|
|
clearNoticeEnv(t)
|
|
dir := t.TempDir()
|
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
|
|
|
origVersion := build.Version
|
|
build.Version = "1.0.21"
|
|
t.Cleanup(func() { build.Version = origVersion })
|
|
|
|
// Reset pending state to ensure a clean test.
|
|
skillscheck.SetPending(nil)
|
|
update.SetPending(nil)
|
|
output.PendingNotice = nil
|
|
t.Cleanup(func() {
|
|
skillscheck.SetPending(nil)
|
|
update.SetPending(nil)
|
|
output.PendingNotice = nil
|
|
})
|
|
|
|
setupNotices()
|
|
|
|
notice := output.GetNotice()
|
|
if notice == nil {
|
|
return // expected — no pending notices at all
|
|
}
|
|
if _, ok := notice["skills"]; ok {
|
|
t.Errorf("notice.skills present in cold-start state, want absent: %+v", notice)
|
|
}
|
|
}
|
|
|
|
// TestSetupNotices_InSync verifies that matching state produces no
|
|
// skills key in the composed notice.
|
|
func TestSetupNotices_InSync(t *testing.T) {
|
|
clearNoticeEnv(t)
|
|
dir := t.TempDir()
|
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
|
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.21"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
origVersion := build.Version
|
|
build.Version = "1.0.21"
|
|
t.Cleanup(func() { build.Version = origVersion })
|
|
|
|
skillscheck.SetPending(nil)
|
|
update.SetPending(nil)
|
|
output.PendingNotice = nil
|
|
t.Cleanup(func() {
|
|
skillscheck.SetPending(nil)
|
|
update.SetPending(nil)
|
|
output.PendingNotice = nil
|
|
})
|
|
|
|
setupNotices()
|
|
|
|
notice := output.GetNotice()
|
|
if notice != nil {
|
|
if _, ok := notice["skills"]; ok {
|
|
t.Errorf("notice.skills present in in-sync state: %+v", notice)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestSetupNotices_Drift verifies mismatching state produces the
|
|
// drift message with both current and target populated.
|
|
func TestSetupNotices_Drift(t *testing.T) {
|
|
clearNoticeEnv(t)
|
|
dir := t.TempDir()
|
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
|
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.20"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
origVersion := build.Version
|
|
build.Version = "1.0.21"
|
|
t.Cleanup(func() { build.Version = origVersion })
|
|
|
|
skillscheck.SetPending(nil)
|
|
update.SetPending(nil)
|
|
output.PendingNotice = nil
|
|
t.Cleanup(func() {
|
|
skillscheck.SetPending(nil)
|
|
update.SetPending(nil)
|
|
output.PendingNotice = nil
|
|
})
|
|
|
|
setupNotices()
|
|
|
|
notice := output.GetNotice()
|
|
if notice == nil {
|
|
t.Fatal("GetNotice() = nil, want non-nil for drift")
|
|
}
|
|
skills, ok := notice["skills"].(map[string]interface{})
|
|
if !ok {
|
|
t.Fatalf("notice.skills missing, got %+v", notice)
|
|
}
|
|
if skills["current"] != "1.0.20" || skills["target"] != "1.0.21" {
|
|
t.Errorf("notice.skills = %+v, want {current:\"1.0.20\", target:\"1.0.21\"}", skills)
|
|
}
|
|
want := "lark-cli skills 1.0.20 out of sync with binary 1.0.21, run: lark-cli update"
|
|
if msg, _ := skills["message"].(string); msg != want {
|
|
t.Errorf("notice.skills.message = %q, want %q", msg, want)
|
|
}
|
|
if cmd, _ := skills["command"].(string); cmd != "lark-cli update" {
|
|
t.Errorf("notice.skills.command = %q, want %q", cmd, "lark-cli update")
|
|
}
|
|
}
|
|
|
|
// TestSetupNotices_BothUpdateAndSkills verifies the composed envelope
|
|
// emits BOTH "_notice.update" and "_notice.skills" keys when each
|
|
// pending value is set. Drives the skills key via setupNotices() (drift
|
|
// state) and manually populates the update pending afterwards, since
|
|
// clearNoticeEnv suppresses the update goroutine to avoid network
|
|
// flakiness.
|
|
func TestSetupNotices_BothUpdateAndSkills(t *testing.T) {
|
|
clearNoticeEnv(t)
|
|
dir := t.TempDir()
|
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
|
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.20"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
origVersion := build.Version
|
|
build.Version = "1.0.21"
|
|
t.Cleanup(func() { build.Version = origVersion })
|
|
|
|
skillscheck.SetPending(nil)
|
|
update.SetPending(nil)
|
|
output.PendingNotice = nil
|
|
t.Cleanup(func() {
|
|
skillscheck.SetPending(nil)
|
|
update.SetPending(nil)
|
|
output.PendingNotice = nil
|
|
})
|
|
|
|
setupNotices()
|
|
|
|
// After setupNotices, skills pending is set (drift). Manually populate
|
|
// the update side so the composed envelope has both keys — the update
|
|
// goroutine is suppressed by clearNoticeEnv.
|
|
update.SetPending(&update.UpdateInfo{Current: "1.0.21", Latest: "1.0.22"})
|
|
|
|
notice := output.GetNotice()
|
|
if notice == nil {
|
|
t.Fatal("GetNotice() = nil, want both keys")
|
|
}
|
|
if _, ok := notice["update"].(map[string]interface{}); !ok {
|
|
t.Errorf("missing 'update' key: %+v", notice)
|
|
}
|
|
if _, ok := notice["skills"].(map[string]interface{}); !ok {
|
|
t.Errorf("missing 'skills' key: %+v", notice)
|
|
}
|
|
upd, ok := notice["update"].(map[string]interface{})
|
|
if !ok {
|
|
t.Fatalf("notice.update missing or wrong type: %+v", notice)
|
|
}
|
|
if cmd, _ := upd["command"].(string); cmd != "lark-cli update" {
|
|
t.Errorf("notice.update.command = %q, want %q", cmd, "lark-cli update")
|
|
}
|
|
sk, ok := notice["skills"].(map[string]interface{})
|
|
if !ok {
|
|
t.Fatalf("notice.skills missing or wrong type: %+v", notice)
|
|
}
|
|
if cmd, _ := sk["command"].(string); cmd != "lark-cli update" {
|
|
t.Errorf("notice.skills.command = %q, want %q", cmd, "lark-cli update")
|
|
}
|
|
}
|
|
|
|
// clearNoticeEnv unsets the env vars that affect either notice. We
|
|
// proactively SUPPRESS the update notifier (LARKSUITE_CLI_NO_UPDATE_NOTIFIER=1)
|
|
// because setupNotices spawns a goroutine that hits the npm registry —
|
|
// tests focused on the skills check should not depend on network state.
|
|
func clearNoticeEnv(t *testing.T) {
|
|
t.Helper()
|
|
for _, key := range []string{
|
|
"LARKSUITE_CLI_NO_SKILLS_NOTIFIER",
|
|
"CI", "BUILD_NUMBER", "RUN_ID",
|
|
} {
|
|
t.Setenv(key, "")
|
|
os.Unsetenv(key)
|
|
}
|
|
// Suppress the update goroutine's network call deterministically.
|
|
t.Setenv("LARKSUITE_CLI_NO_UPDATE_NOTIFIER", "1")
|
|
}
|