mirror of
https://github.com/larksuite/cli.git
synced 2026-07-07 17:45:15 +08:00
637 lines
21 KiB
Go
637 lines
21 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package cmd
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"os"
|
|
"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 messages shared across fixtures. The
|
|
// switch-policy hint text is asserted by substring in
|
|
// assertStrictModeDenialEnvelope.
|
|
const (
|
|
strictModeBotMessage = `strict mode is "bot", only bot-identity commands are available`
|
|
strictModeUserMessage = `strict mode is "user", only user-identity commands are available`
|
|
)
|
|
|
|
// 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
|
|
}
|
|
|
|
// typedErrorEnvelope mirrors the typed wire shape produced by
|
|
// WriteTypedErrorEnvelope: the inner error marshals an errs.Problem
|
|
// directly, so "type" is the category, "subtype" is top-level, and there
|
|
// is no nested "detail" object. Recovery info (policy source, reason
|
|
// code, suggestions) is folded into "hint".
|
|
type typedErrorEnvelope struct {
|
|
OK bool `json:"ok"`
|
|
Identity string `json:"identity,omitempty"`
|
|
Error struct {
|
|
Type string `json:"type"`
|
|
Subtype string `json:"subtype"`
|
|
Message string `json:"message"`
|
|
Hint string `json:"hint"`
|
|
Param string `json:"param,omitempty"`
|
|
} `json:"error"`
|
|
}
|
|
|
|
// parseTypedEnvelope decodes stderr as the typed envelope and fails if the
|
|
// legacy nested "detail" object is present (the migration removed it).
|
|
func parseTypedEnvelope(t *testing.T, stderr *bytes.Buffer) typedErrorEnvelope {
|
|
t.Helper()
|
|
if stderr.Len() == 0 {
|
|
t.Fatal("expected non-empty stderr, got empty")
|
|
}
|
|
var raw map[string]any
|
|
if err := json.Unmarshal(stderr.Bytes(), &raw); err != nil {
|
|
t.Fatalf("failed to parse stderr as JSON: %v\nstderr: %s", err, stderr.String())
|
|
}
|
|
if errObj, ok := raw["error"].(map[string]any); ok {
|
|
if _, hasDetail := errObj["detail"]; hasDetail {
|
|
t.Errorf("typed envelope must not carry a nested 'detail' object, got: %s", stderr.String())
|
|
}
|
|
}
|
|
var env typedErrorEnvelope
|
|
if err := json.Unmarshal(stderr.Bytes(), &env); err != nil {
|
|
t.Fatalf("failed to parse stderr as typed envelope: %v\nstderr: %s", err, stderr.String())
|
|
}
|
|
return env
|
|
}
|
|
|
|
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). The typed envelope is a failed_precondition validation
|
|
// error (exit 2); the strict-mode layer + reason code are folded into
|
|
// the hint.
|
|
if code != output.ExitValidation {
|
|
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
|
|
}
|
|
if stdout.Len() != 0 {
|
|
t.Errorf("expected empty stdout, got:\n%s", stdout.String())
|
|
}
|
|
env := parseTypedEnvelope(t, stderr)
|
|
assertStrictModeDenialEnvelope(t, env, strictModeBotMessage)
|
|
}
|
|
|
|
// assertStrictModeDenialEnvelope pins the shared strict-mode denial shape:
|
|
// a validation/failed_precondition envelope whose message is the short
|
|
// historical strict-mode line and whose hint still names the strict_mode
|
|
// layer + identity_not_supported reason code (the safety-critical recovery
|
|
// info), plus the historical switch-policy guidance.
|
|
func assertStrictModeDenialEnvelope(t *testing.T, env typedErrorEnvelope, wantMessage string) {
|
|
t.Helper()
|
|
if env.OK {
|
|
t.Errorf("envelope ok = true, want false")
|
|
}
|
|
if env.Error.Type != "validation" {
|
|
t.Errorf("error.type = %q, want validation", env.Error.Type)
|
|
}
|
|
if env.Error.Subtype != "failed_precondition" {
|
|
t.Errorf("error.subtype = %q, want failed_precondition", env.Error.Subtype)
|
|
}
|
|
if env.Error.Message != wantMessage {
|
|
t.Errorf("error.message = %q, want %q", env.Error.Message, wantMessage)
|
|
}
|
|
if !strings.Contains(env.Error.Hint, "strict_mode") {
|
|
t.Errorf("error.hint = %q, want substring strict_mode (policy layer)", env.Error.Hint)
|
|
}
|
|
if !strings.Contains(env.Error.Hint, "identity_not_supported") {
|
|
t.Errorf("error.hint = %q, want substring identity_not_supported (reason code)", env.Error.Hint)
|
|
}
|
|
if !strings.Contains(env.Error.Hint, "config strict-mode --help") {
|
|
t.Errorf("error.hint = %q, want historical switch-policy guidance", env.Error.Hint)
|
|
}
|
|
}
|
|
|
|
// assertCheckStrictModeEnvelope pins the typed envelope produced by
|
|
// cmdutil.Factory.CheckStrictMode (the identity-guard path for explicit
|
|
// --as on shortcuts / service methods / api): a *errs.ValidationError with
|
|
// subtype invalid_argument, the canonical strict-mode message, and the
|
|
// switch-policy hint.
|
|
func assertCheckStrictModeEnvelope(t *testing.T, env typedErrorEnvelope, wantMessage string) {
|
|
t.Helper()
|
|
if env.OK {
|
|
t.Errorf("envelope ok = true, want false")
|
|
}
|
|
if env.Error.Type != "validation" {
|
|
t.Errorf("error.type = %q, want validation", env.Error.Type)
|
|
}
|
|
if env.Error.Subtype != "invalid_argument" {
|
|
t.Errorf("error.subtype = %q, want invalid_argument", env.Error.Subtype)
|
|
}
|
|
if env.Error.Message != wantMessage {
|
|
t.Errorf("error.message = %q, want %q", env.Error.Message, wantMessage)
|
|
}
|
|
if !strings.Contains(env.Error.Hint, "config strict-mode --help") {
|
|
t.Errorf("error.hint = %q, want switch-policy guidance", env.Error.Hint)
|
|
}
|
|
}
|
|
|
|
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",
|
|
})
|
|
|
|
if code != output.ExitValidation {
|
|
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
|
|
}
|
|
if stdout.Len() != 0 {
|
|
t.Errorf("expected empty stdout, got:\n%s", stdout.String())
|
|
}
|
|
env := parseTypedEnvelope(t, stderr)
|
|
assertStrictModeDenialEnvelope(t, env, 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",
|
|
})
|
|
|
|
if code != output.ExitValidation {
|
|
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
|
|
}
|
|
if stdout.Len() != 0 {
|
|
t.Errorf("expected empty stdout, got:\n%s", stdout.String())
|
|
}
|
|
env := parseTypedEnvelope(t, stderr)
|
|
assertCheckStrictModeEnvelope(t, env, strictModeUserMessage)
|
|
}
|
|
|
|
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",
|
|
})
|
|
|
|
if code != output.ExitValidation {
|
|
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
|
|
}
|
|
if stdout.Len() != 0 {
|
|
t.Errorf("expected empty stdout, got:\n%s", stdout.String())
|
|
}
|
|
env := parseTypedEnvelope(t, stderr)
|
|
assertCheckStrictModeEnvelope(t, env, strictModeBotMessage)
|
|
}
|
|
|
|
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", "chats", "create", "--data", `{"name":"probe"}`, "--dry-run",
|
|
})
|
|
|
|
if code != output.ExitValidation {
|
|
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
|
|
}
|
|
if stdout.Len() != 0 {
|
|
t.Errorf("expected empty stdout, got:\n%s", stdout.String())
|
|
}
|
|
env := parseTypedEnvelope(t, stderr)
|
|
assertStrictModeDenialEnvelope(t, env, 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",
|
|
})
|
|
|
|
if code != output.ExitValidation {
|
|
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
|
|
}
|
|
if stdout.Len() != 0 {
|
|
t.Errorf("expected empty stdout, got:\n%s", stdout.String())
|
|
}
|
|
env := parseTypedEnvelope(t, stderr)
|
|
assertCheckStrictModeEnvelope(t, env, strictModeBotMessage)
|
|
}
|
|
|
|
// --- 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 errs.APIError via the CallAPITyped → BuildAPIError path.
|
|
if code != output.ExitAPI {
|
|
t.Errorf("exit code = %d, want %d (ExitAPI)", code, output.ExitAPI)
|
|
}
|
|
if stdout.Len() != 0 {
|
|
t.Errorf("expected empty stdout, got:\n%s", stdout.String())
|
|
}
|
|
if stderr.Len() == 0 {
|
|
t.Fatal("expected non-empty stderr, got empty")
|
|
}
|
|
var raw struct {
|
|
OK bool `json:"ok"`
|
|
Identity string `json:"identity"`
|
|
Error struct {
|
|
Type string `json:"type"`
|
|
Code int `json:"code"`
|
|
Message string `json:"message"`
|
|
} `json:"error"`
|
|
}
|
|
if err := json.Unmarshal(stderr.Bytes(), &raw); err != nil {
|
|
t.Fatalf("failed to parse typed envelope: %v\nstderr: %s", err, stderr.String())
|
|
}
|
|
if raw.OK {
|
|
t.Errorf("envelope ok = true, want false")
|
|
}
|
|
if raw.Identity != "bot" {
|
|
t.Errorf("identity = %q, want bot", raw.Identity)
|
|
}
|
|
if raw.Error.Type != "api" {
|
|
t.Errorf("error.type = %q, want api", raw.Error.Type)
|
|
}
|
|
if raw.Error.Code != 230002 {
|
|
t.Errorf("error.code = %d, want 230002", raw.Error.Code)
|
|
}
|
|
if raw.Error.Message != "Bot/User can NOT be out of the chat." {
|
|
t.Errorf("error.message = %q, want %q", raw.Error.Message, "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")
|
|
}
|