mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +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.
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", "images", "create", "--data", `{"image_type":"message","image":"x"}`, "--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")
|
|
}
|