mirror of
https://github.com/larksuite/cli.git
synced 2026-07-06 00:06:28 +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.
530 lines
17 KiB
Go
530 lines
17 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package config
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
extcred "github.com/larksuite/cli/extension/credential"
|
|
"github.com/larksuite/cli/internal/cmdutil"
|
|
"github.com/larksuite/cli/internal/core"
|
|
"github.com/larksuite/cli/internal/credential"
|
|
"github.com/larksuite/cli/internal/i18n"
|
|
"github.com/larksuite/cli/internal/keychain"
|
|
"github.com/larksuite/cli/internal/output"
|
|
)
|
|
|
|
type noopConfigKeychain struct{}
|
|
|
|
func (n *noopConfigKeychain) Get(service, account string) (string, error) { return "", nil }
|
|
func (n *noopConfigKeychain) Set(service, account, value string) error { return nil }
|
|
func (n *noopConfigKeychain) Remove(service, account string) error { return nil }
|
|
|
|
type recordingConfigKeychain struct {
|
|
removed []string
|
|
}
|
|
|
|
func (r *recordingConfigKeychain) Get(service, account string) (string, error) { return "", nil }
|
|
func (r *recordingConfigKeychain) Set(service, account, value string) error { return nil }
|
|
func (r *recordingConfigKeychain) Remove(service, account string) error {
|
|
r.removed = append(r.removed, service+":"+account)
|
|
return nil
|
|
}
|
|
|
|
func TestConfigInitCmd_FlagParsing(t *testing.T) {
|
|
clearAgentEnv(t) // assumes local workspace; guard refuses init in agent contexts
|
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
|
f.IOStreams.In = strings.NewReader("secret123\n")
|
|
|
|
var gotOpts *ConfigInitOptions
|
|
cmd := NewCmdConfigInit(f, func(opts *ConfigInitOptions) error {
|
|
gotOpts = opts
|
|
return nil
|
|
})
|
|
cmd.SetArgs([]string{"--app-id", "cli_test", "--app-secret-stdin", "--brand", "lark"})
|
|
err := cmd.Execute()
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if gotOpts.AppID != "cli_test" {
|
|
t.Errorf("expected AppID cli_test, got %s", gotOpts.AppID)
|
|
}
|
|
if !gotOpts.AppSecretStdin {
|
|
t.Error("expected AppSecretStdin=true")
|
|
}
|
|
if gotOpts.Brand != "lark" {
|
|
t.Errorf("expected Brand lark, got %s", gotOpts.Brand)
|
|
}
|
|
}
|
|
|
|
func TestConfigShowCmd_FlagParsing(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
|
})
|
|
|
|
var gotOpts *ConfigShowOptions
|
|
cmd := NewCmdConfigShow(f, func(opts *ConfigShowOptions) error {
|
|
gotOpts = opts
|
|
return nil
|
|
})
|
|
err := cmd.Execute()
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if gotOpts == nil {
|
|
t.Error("expected opts to be set")
|
|
}
|
|
}
|
|
|
|
func TestConfigShowRun_NotConfiguredReturnsStructuredError(t *testing.T) {
|
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
|
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
|
err := configShowRun(&ConfigShowOptions{Factory: f})
|
|
if err == nil {
|
|
t.Fatal("expected error")
|
|
}
|
|
|
|
var cfgErr *core.ConfigError
|
|
if !errors.As(err, &cfgErr) {
|
|
t.Fatalf("error type = %T, want *core.ConfigError", err)
|
|
}
|
|
// Config errors share ExitAuth (3), not ExitValidation.
|
|
if cfgErr.Code != output.ExitAuth {
|
|
t.Fatalf("exit code = %d, want %d (config category → ExitAuth)", cfgErr.Code, output.ExitAuth)
|
|
}
|
|
if cfgErr.Type != "config" || cfgErr.Message != "not configured" {
|
|
t.Fatalf("detail = %+v, want config/not configured", cfgErr)
|
|
}
|
|
}
|
|
|
|
func TestConfigShowRun_NoActiveProfileReturnsStructuredError(t *testing.T) {
|
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
multi := &core.MultiAppConfig{
|
|
CurrentApp: "missing",
|
|
Apps: []core.AppConfig{{
|
|
Name: "default",
|
|
AppId: "app-default",
|
|
AppSecret: core.PlainSecret("secret-default"),
|
|
Brand: core.BrandFeishu,
|
|
}},
|
|
}
|
|
if err := core.SaveMultiAppConfig(multi); err != nil {
|
|
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
|
}
|
|
|
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
|
err := configShowRun(&ConfigShowOptions{Factory: f})
|
|
if err == nil {
|
|
t.Fatal("expected error")
|
|
}
|
|
|
|
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitAuth {
|
|
t.Errorf("exit code = %d, want %d", gotCode, output.ExitAuth)
|
|
}
|
|
if !strings.Contains(err.Error(), "no active profile") {
|
|
t.Fatalf("error = %v, want to contain 'no active profile'", err)
|
|
}
|
|
}
|
|
|
|
func TestConfigInitCmd_LangFlag(t *testing.T) {
|
|
clearAgentEnv(t) // assumes local workspace; guard refuses init in agent contexts
|
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
|
|
|
var gotOpts *ConfigInitOptions
|
|
cmd := NewCmdConfigInit(f, func(opts *ConfigInitOptions) error {
|
|
gotOpts = opts
|
|
return nil
|
|
})
|
|
f.IOStreams.In = strings.NewReader("y\n")
|
|
cmd.SetArgs([]string{"--app-id", "x", "--app-secret-stdin", "--lang", "en"})
|
|
if err := cmd.Execute(); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
// --lang en is canonicalized to en_us in RunE before runF captures opts.
|
|
if gotOpts.Lang != string(i18n.LangEnUS) {
|
|
t.Errorf("expected Lang en_us, got %s", gotOpts.Lang)
|
|
}
|
|
if !gotOpts.langExplicit {
|
|
t.Error("expected langExplicit=true when --lang is passed")
|
|
}
|
|
}
|
|
|
|
func TestConfigInitCmd_LangDefault(t *testing.T) {
|
|
clearAgentEnv(t) // assumes local workspace; guard refuses init in agent contexts
|
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
|
|
|
var gotOpts *ConfigInitOptions
|
|
cmd := NewCmdConfigInit(f, func(opts *ConfigInitOptions) error {
|
|
gotOpts = opts
|
|
return nil
|
|
})
|
|
f.IOStreams.In = strings.NewReader("y\n")
|
|
cmd.SetArgs([]string{"--app-id", "x", "--app-secret-stdin"})
|
|
if err := cmd.Execute(); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if gotOpts.Lang != "" {
|
|
t.Errorf("expected default Lang to be unset (\"\"), got %q", gotOpts.Lang)
|
|
}
|
|
if gotOpts.langExplicit {
|
|
t.Error("expected langExplicit=false when --lang is not passed")
|
|
}
|
|
}
|
|
|
|
// TestSaveInitConfig_OmitLangPreservesPrior guards the single-app replace path:
|
|
// re-running init without --lang must inherit the prior preference, not clear it.
|
|
func TestSaveInitConfig_OmitLangPreservesPrior(t *testing.T) {
|
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
|
|
|
existing := &core.MultiAppConfig{Apps: []core.AppConfig{
|
|
{AppId: "cli_x", AppSecret: core.PlainSecret("s"), Brand: core.BrandFeishu, Lang: i18n.LangJaJP},
|
|
}}
|
|
if err := core.SaveMultiAppConfig(existing); err != nil {
|
|
t.Fatalf("seed config: %v", err)
|
|
}
|
|
|
|
if err := saveInitConfig("", existing, f, "cli_x", core.PlainSecret("s2"), core.BrandFeishu, ""); err != nil {
|
|
t.Fatalf("saveInitConfig (no --lang): %v", err)
|
|
}
|
|
|
|
got, err := core.LoadMultiAppConfig()
|
|
if err != nil {
|
|
t.Fatalf("LoadMultiAppConfig: %v", err)
|
|
}
|
|
if app := got.CurrentAppConfig(""); app == nil || app.Lang != i18n.LangJaJP {
|
|
t.Errorf("Lang after re-init = %v, want %q (preserved)", app, i18n.LangJaJP)
|
|
}
|
|
}
|
|
|
|
// TestConfigInitCmd_InvalidLang verifies a non-empty --lang on config init is
|
|
// strictly validated the same way bind validates: wrong-case / typo / removed
|
|
// codes / hyphen form all exit with ExitValidation. (Empty is a no-op.)
|
|
func TestConfigInitCmd_InvalidLang(t *testing.T) {
|
|
clearAgentEnv(t)
|
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
|
|
cases := []struct {
|
|
name string
|
|
lang string
|
|
}{
|
|
{"wrong case ZH", "ZH"},
|
|
{"typo frr", "frr"},
|
|
{"removed code ar", "ar"},
|
|
{"unknown xx", "xx"},
|
|
{"hyphen form zh-CN", "zh-CN"},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
|
cmd := NewCmdConfigInit(f, nil)
|
|
f.IOStreams.In = strings.NewReader("sec\n")
|
|
cmd.SetArgs([]string{"--lang", tc.lang, "--app-id", "x", "--app-secret-stdin"})
|
|
err := cmd.Execute()
|
|
if err == nil {
|
|
t.Fatalf("expected validation error for --lang %q, got nil", tc.lang)
|
|
}
|
|
exitErr, ok := err.(*output.ExitError)
|
|
if !ok {
|
|
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
|
|
}
|
|
if exitErr.Code != output.ExitValidation {
|
|
t.Errorf("exit code = %d, want %d (validation)", exitErr.Code, output.ExitValidation)
|
|
}
|
|
if !strings.Contains(exitErr.Error(), "invalid --lang") {
|
|
t.Errorf("error message %q does not contain 'invalid --lang'", exitErr.Error())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHasAnyNonInteractiveFlag(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
opts ConfigInitOptions
|
|
want bool
|
|
}{
|
|
{"empty", ConfigInitOptions{}, false},
|
|
{"new", ConfigInitOptions{New: true}, true},
|
|
{"app-id", ConfigInitOptions{AppID: "x"}, true},
|
|
{"app-secret-stdin", ConfigInitOptions{AppSecretStdin: true}, true},
|
|
{"app-id+secret-stdin", ConfigInitOptions{AppID: "x", AppSecretStdin: true}, true},
|
|
{"lang-only", ConfigInitOptions{Lang: "en"}, false},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := tt.opts.hasAnyNonInteractiveFlag()
|
|
if got != tt.want {
|
|
t.Errorf("hasAnyNonInteractiveFlag() = %v, want %v", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestConfigInitRun_NonTerminal_NoFlags_RejectsWithHint(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
|
// TestFactory has IsTerminal=false by default
|
|
opts := &ConfigInitOptions{Factory: f, Ctx: context.Background(), Lang: "zh"}
|
|
err := configInitRun(opts)
|
|
if err == nil {
|
|
t.Fatal("expected error for non-terminal without flags")
|
|
}
|
|
msg := err.Error()
|
|
if !strings.Contains(msg, "--new") {
|
|
t.Errorf("expected error to mention --new, got: %s", msg)
|
|
}
|
|
if !strings.Contains(msg, "terminal") {
|
|
t.Errorf("expected error to mention terminal, got: %s", msg)
|
|
}
|
|
}
|
|
|
|
func TestConfigRemoveCmd_FlagParsing(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
|
|
|
var gotOpts *ConfigRemoveOptions
|
|
cmd := NewCmdConfigRemove(f, func(opts *ConfigRemoveOptions) error {
|
|
gotOpts = opts
|
|
return nil
|
|
})
|
|
err := cmd.Execute()
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if gotOpts == nil {
|
|
t.Fatal("expected opts to be set")
|
|
}
|
|
if gotOpts.Factory != f {
|
|
t.Fatal("expected factory to be preserved in options")
|
|
}
|
|
}
|
|
|
|
func TestConfigRemoveRun_SaveFailurePreservesExistingConfigAndSecrets(t *testing.T) {
|
|
configDir := t.TempDir()
|
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
|
|
|
|
multi := &core.MultiAppConfig{
|
|
Apps: []core.AppConfig{{
|
|
AppId: "app-test",
|
|
AppSecret: core.SecretInput{
|
|
Ref: &core.SecretRef{Source: "keychain", ID: "appsecret:app-test"},
|
|
},
|
|
Brand: core.BrandFeishu,
|
|
Users: []core.AppUser{{UserOpenId: "ou_1", UserName: "Tester"}},
|
|
}},
|
|
}
|
|
if err := core.SaveMultiAppConfig(multi); err != nil {
|
|
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
|
}
|
|
|
|
kc := &recordingConfigKeychain{}
|
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
|
f.Keychain = kc
|
|
|
|
// Make subsequent config saves fail while keeping the existing config readable.
|
|
if err := os.Chmod(configDir, 0500); err != nil {
|
|
t.Fatalf("Chmod(%s) error = %v", configDir, err)
|
|
}
|
|
defer os.Chmod(configDir, 0700)
|
|
|
|
err := configRemoveRun(&ConfigRemoveOptions{Factory: f})
|
|
if err == nil {
|
|
t.Fatal("expected save failure")
|
|
}
|
|
if !strings.Contains(err.Error(), "failed to save config") {
|
|
t.Fatalf("error = %v, want failed to save config", err)
|
|
}
|
|
if len(kc.removed) != 0 {
|
|
t.Fatalf("expected no keychain cleanup before successful save, got removals: %v", kc.removed)
|
|
}
|
|
|
|
// Restore permissions and confirm the original config is still intact.
|
|
if err := os.Chmod(configDir, 0700); err != nil {
|
|
t.Fatalf("restore Chmod(%s) error = %v", configDir, err)
|
|
}
|
|
saved, err := core.LoadMultiAppConfig()
|
|
if err != nil {
|
|
t.Fatalf("LoadMultiAppConfig() error = %v", err)
|
|
}
|
|
if saved == nil || len(saved.Apps) != 1 || saved.Apps[0].AppId != "app-test" {
|
|
t.Fatalf("saved config = %#v, want original single app preserved", saved)
|
|
}
|
|
if got := saved.Apps[0].AppSecret.Ref; got == nil || got.ID != "appsecret:app-test" {
|
|
t.Fatalf("saved app secret ref = %#v, want preserved keychain ref", got)
|
|
}
|
|
|
|
configPath := filepath.Join(configDir, "config.json")
|
|
if _, err := os.Stat(configPath); err != nil {
|
|
t.Fatalf("expected existing config file to remain, stat error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSaveAsProfile_RejectsProfileNameCollisionWithExistingAppID(t *testing.T) {
|
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
|
|
existing := &core.MultiAppConfig{
|
|
Apps: []core.AppConfig{
|
|
{
|
|
Name: "prod",
|
|
AppId: "cli_prod",
|
|
AppSecret: core.PlainSecret("secret"),
|
|
Brand: core.BrandFeishu,
|
|
},
|
|
},
|
|
}
|
|
|
|
err := saveAsProfile(existing, keychain.KeychainAccess(&noopConfigKeychain{}), "cli_prod", "app-new", core.PlainSecret("new-secret"), core.BrandLark, "en")
|
|
if err == nil {
|
|
t.Fatal("expected conflict error")
|
|
}
|
|
if !strings.Contains(err.Error(), "conflicts with existing appId") {
|
|
t.Fatalf("error = %v, want conflict with existing appId", err)
|
|
}
|
|
}
|
|
|
|
func TestUpdateExistingProfileWithoutSecret_RejectsAppIDChange(t *testing.T) {
|
|
multi := &core.MultiAppConfig{
|
|
CurrentApp: "prod",
|
|
Apps: []core.AppConfig{
|
|
{
|
|
Name: "prod",
|
|
AppId: "app-old",
|
|
AppSecret: core.SecretInput{Ref: &core.SecretRef{Source: "keychain", ID: "appsecret:app-old"}},
|
|
Brand: core.BrandFeishu,
|
|
Lang: "zh",
|
|
Users: []core.AppUser{{UserOpenId: "ou_1", UserName: "User"}},
|
|
},
|
|
},
|
|
}
|
|
|
|
err := updateExistingProfileWithoutSecret(multi, "", "app-new", core.BrandLark, "en")
|
|
if err == nil {
|
|
t.Fatal("expected error when changing app ID without a new secret")
|
|
}
|
|
if !strings.Contains(err.Error(), "App Secret") {
|
|
t.Fatalf("error = %v, want mention of App Secret", err)
|
|
}
|
|
}
|
|
|
|
// stubConfigExtProvider simulates env/sidecar credential mode for config guard tests.
|
|
type stubConfigExtProvider struct{ name string }
|
|
|
|
func (s *stubConfigExtProvider) Name() string { return s.name }
|
|
func (s *stubConfigExtProvider) ResolveAccount(_ context.Context) (*extcred.Account, error) {
|
|
return &extcred.Account{AppID: "test-app"}, nil
|
|
}
|
|
func (s *stubConfigExtProvider) ResolveToken(_ context.Context, _ extcred.TokenSpec) (*extcred.Token, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func newConfigFactoryWithExternalProvider(t *testing.T) *cmdutil.Factory {
|
|
t.Helper()
|
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
stub := &stubConfigExtProvider{name: "env"}
|
|
cred := credential.NewCredentialProvider([]extcred.Provider{stub}, nil, nil, nil)
|
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
|
f.Credential = cred
|
|
return f
|
|
}
|
|
|
|
func TestConfigBlockedByExternalProvider(t *testing.T) {
|
|
f := newConfigFactoryWithExternalProvider(t)
|
|
|
|
tests := []struct {
|
|
name string
|
|
args []string
|
|
}{
|
|
{"init", []string{"init", "--app-id", "x", "--app-secret-stdin"}},
|
|
{"remove", []string{"remove"}},
|
|
{"show", []string{"show"}},
|
|
{"default-as", []string{"default-as", "user"}},
|
|
{"strict-mode", []string{"strict-mode", "off"}},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
cmd := NewCmdConfig(f)
|
|
cmd.SilenceErrors = true
|
|
cmd.SetErr(io.Discard)
|
|
cmd.SetArgs(tt.args)
|
|
|
|
// Locate the subcommand before execution (PersistentPreRunE receives it as cmd).
|
|
matched, _, _ := cmd.Find(tt.args)
|
|
|
|
err := cmd.Execute()
|
|
|
|
// PersistentPreRunE sets SilenceUsage on the matched subcommand, not the parent.
|
|
if matched != nil && matched != cmd && !matched.SilenceUsage {
|
|
t.Error("expected PersistentPreRunE to set SilenceUsage on matched subcommand")
|
|
}
|
|
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitValidation {
|
|
t.Errorf("exit code = %d, want %d", gotCode, output.ExitValidation)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestValidateInitLang covers the --lang contract: empty (omitted or explicit)
|
|
// is a no-op leaving Lang unset; a short code or Feishu locale canonicalizes to
|
|
// the same locale; an unrecognized value errors.
|
|
func TestValidateInitLang(t *testing.T) {
|
|
t.Run("empty is a no-op", func(t *testing.T) {
|
|
for _, explicit := range []bool{false, true} {
|
|
opts := &ConfigInitOptions{Lang: "", langExplicit: explicit}
|
|
if err := validateInitLang(opts); err != nil {
|
|
t.Fatalf("explicit=%v: expected nil error, got %v", explicit, err)
|
|
}
|
|
if opts.Lang != "" {
|
|
t.Errorf("explicit=%v: Lang = %q, want \"\" (unset)", explicit, opts.Lang)
|
|
}
|
|
}
|
|
})
|
|
t.Run("short and locale canonicalize alike", func(t *testing.T) {
|
|
for _, in := range []string{"ja", "ja_jp"} {
|
|
opts := &ConfigInitOptions{Lang: in, langExplicit: true}
|
|
if err := validateInitLang(opts); err != nil {
|
|
t.Fatalf("--lang %q: unexpected error %v", in, err)
|
|
}
|
|
if opts.Lang != string(i18n.LangJaJP) {
|
|
t.Errorf("--lang %q normalized to %q, want %q", in, opts.Lang, i18n.LangJaJP)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestPrintLangPreferenceConfirmation covers the confirmation helper: it prints
|
|
// to stderr only when --lang explicitly set a non-empty preference.
|
|
func TestPrintLangPreferenceConfirmation(t *testing.T) {
|
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
t.Run("explicit non-empty prints confirmation", func(t *testing.T) {
|
|
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
|
|
printLangPreferenceConfirmation(&ConfigInitOptions{Factory: f, Lang: "en_us", UILang: i18n.LangZhCN, langExplicit: true})
|
|
got := stderr.String()
|
|
if !strings.Contains(got, "语言偏好") || !strings.Contains(got, "en_us") {
|
|
t.Errorf("stderr = %q, want confirmation mentioning the preference and en_us", got)
|
|
}
|
|
})
|
|
t.Run("implicit prints nothing", func(t *testing.T) {
|
|
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
|
|
printLangPreferenceConfirmation(&ConfigInitOptions{Factory: f, Lang: "en_us", UILang: i18n.LangZhCN, langExplicit: false})
|
|
if got := stderr.String(); got != "" {
|
|
t.Errorf("stderr = %q, want empty when --lang is implicit", got)
|
|
}
|
|
})
|
|
t.Run("explicit empty prints nothing", func(t *testing.T) {
|
|
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
|
|
printLangPreferenceConfirmation(&ConfigInitOptions{Factory: f, Lang: "", UILang: i18n.LangZhCN, langExplicit: true})
|
|
if got := stderr.String(); got != "" {
|
|
t.Errorf("stderr = %q, want empty when --lang is empty", got)
|
|
}
|
|
})
|
|
}
|