diff --git a/AGENTS.md b/AGENTS.md index 87c6892b3e..0bfcd2093d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,9 +10,10 @@ ## Build & Test ```bash -make build # Build (runs fetch_meta first) -make unit-test # Required before PR (runs with -race where supported, e.g. amd64/arm64) -make test # Full: vet + unit + integration +make build # Build (runs fetch_meta first) +make unit-test # Required before PR (runs with -race where supported, e.g. amd64/arm64) +make live-skills-test # Opt-in real Skills CLI tests; runs with isolated user directories +make test # Full: vet + unit + integration ``` ## Notification Opt-Outs diff --git a/Makefile b/Makefile index aad7eccf25..7ed00483cf 100644 --- a/Makefile +++ b/Makefile @@ -23,7 +23,7 @@ PREFIX ?= /usr/local TEST_GOARCH := $(or $(GOARCH),$(shell go env GOARCH)) RACE_FLAG := $(if $(filter riscv64,$(TEST_GOARCH)),,-race) -.PHONY: all build vet fmt-check script-test test unit-test integration-test examples-build quality-gate install uninstall clean fetch_meta gitleaks +.PHONY: all build vet fmt-check script-test test unit-test live-skills-test integration-test examples-build quality-gate install uninstall clean fetch_meta gitleaks all: test @@ -58,6 +58,11 @@ unit-test: fetch_meta go test $(RACE_FLAG) -gcflags="all=-N -l" -count=1 \ ./cmd/... ./internal/... ./shortcuts/... ./extension/... +live-skills-test: fetch_meta + LARKSUITE_CLI_RUN_LIVE_SKILLS_TESTS=1 \ + go test -v -count=1 ./cmd/update \ + -run '^TestUpdateCommand_(RealSkillsSyncRewritesState|SkillsSyncColdStart)$$' + # examples-build keeps the shipped plugin-SDK examples compilable. If this # breaks, the plugin author guide's "go build ./..." path is broken. examples-build: diff --git a/cmd/api/api_test.go b/cmd/api/api_test.go index ae0628f951..c269bb9bb4 100644 --- a/cmd/api/api_test.go +++ b/cmd/api/api_test.go @@ -352,6 +352,9 @@ func TestApiCmd_OutputAndPageAllConflict(t *testing.T) { } func TestApiCmd_BinaryResponse_AutoSave(t *testing.T) { + dir := t.TempDir() + cmdutil.TestChdir(t, dir) + f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{ AppID: "test-app-bin", AppSecret: "test-secret-bin", Brand: core.BrandFeishu, }) @@ -371,8 +374,33 @@ func TestApiCmd_BinaryResponse_AutoSave(t *testing.T) { if !strings.Contains(stderr.String(), "binary response detected") { t.Error("expected binary response hint in stderr") } - if !strings.Contains(stdout.String(), "saved_path") { - t.Error("expected saved_path in output") + var got map[string]interface{} + if err := json.Unmarshal(stdout.Bytes(), &got); err != nil { + t.Fatalf("stdout is not JSON: %v\nstdout:\n%s", err, stdout.String()) + } + savedPath, _ := got["saved_path"].(string) + if savedPath == "" { + t.Fatalf("saved_path missing from output: %#v", got) + } + // The file must land inside the temporary cwd — this pins the isolation + // contract: rolling back TestChdir would leave download.bin in the repo. + wantDir, err := filepath.EvalSymlinks(dir) + if err != nil { + t.Fatal(err) + } + gotDir, err := filepath.EvalSymlinks(filepath.Dir(savedPath)) + if err != nil { + t.Fatalf("saved_path %q dir not resolvable: %v", savedPath, err) + } + if gotDir != wantDir { + t.Errorf("saved_path %q is outside temp cwd %q", savedPath, wantDir) + } + content, err := os.ReadFile(savedPath) + if err != nil { + t.Fatalf("read saved file: %v", err) + } + if string(content) != "fake-binary-content" { + t.Errorf("saved file content = %q, want %q", content, "fake-binary-content") } } diff --git a/cmd/auth/testmain_test.go b/cmd/auth/testmain_test.go new file mode 100644 index 0000000000..816e311786 --- /dev/null +++ b/cmd/auth/testmain_test.go @@ -0,0 +1,46 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "os" + "path/filepath" + "testing" + + "github.com/larksuite/cli/internal/registry/registrytest" +) + +// TestMain isolates auth command tests from the host machine: config, logs +// and the registry cache are redirected to a temp dir, then the registry is +// seeded from the tracked fixture and initialized eagerly. Domain-completion +// tests read the registry, so without seeding a clean checkout would either +// fail or trigger a remote metadata fetch. +// +// Note: os.Exit skips deferred functions, so cleanup runs explicitly after +// m.Run before exiting. +func TestMain(m *testing.M) { + root, err := os.MkdirTemp("", "lark-cli-cmd-auth-test-*") + if err != nil { + println("cmd/auth test setup: MkdirTemp failed:", err.Error()) + os.Exit(2) + } + if err := os.Setenv("LARKSUITE_CLI_CONFIG_DIR", filepath.Join(root, "config")); err != nil { + println("cmd/auth test setup: Setenv failed:", err.Error()) + os.RemoveAll(root) + os.Exit(2) + } + if err := os.Setenv("LARKSUITE_CLI_LOG_DIR", filepath.Join(root, "logs")); err != nil { + println("cmd/auth test setup: Setenv failed:", err.Error()) + os.RemoveAll(root) + os.Exit(2) + } + if err := registrytest.Seed(root); err != nil { + println("cmd/auth test setup: registrytest.Seed failed:", err.Error()) + os.RemoveAll(root) + os.Exit(2) + } + code := m.Run() + _ = os.RemoveAll(root) + os.Exit(code) +} diff --git a/cmd/root_integration_test.go b/cmd/root_integration_test.go index bfd1d126ef..11a6b24c84 100644 --- a/cmd/root_integration_test.go +++ b/cmd/root_integration_test.go @@ -371,10 +371,11 @@ func TestIntegration_StrictModeUser_ProfileOverride_ShortcutExplicitBotReturnsEn func TestIntegration_StrictModeBot_ProfileOverride_ServiceExplicitUserReturnsEnvelope(t *testing.T) { f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeBot) - rootCmd := buildStrictModeIntegrationRootCmd(t, f) + catalog := strictModeFixtureCatalog() + rootCmd := buildStrictModeIntegrationRootCmdWithCatalog(t, f, &catalog) code := executeRootIntegration(t, f, rootCmd, []string{ - "im", "chats", "get", "--params", `{"chat_id":"oc_test"}`, "--as", "user", "--dry-run", + "fixture", "things", "create", "--data", `{"name":"probe"}`, "--as", "user", "--dry-run", }) if code != output.ExitValidation { diff --git a/cmd/service/testmain_test.go b/cmd/service/testmain_test.go new file mode 100644 index 0000000000..2b5cff27da --- /dev/null +++ b/cmd/service/testmain_test.go @@ -0,0 +1,39 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package service + +import ( + "os" + "testing" + + "github.com/larksuite/cli/internal/registry/registrytest" +) + +// TestMain isolates service command tests from the host machine: config (and +// the registry cache under it) is redirected to a temp dir, then the registry +// is seeded from the tracked fixture and initialized eagerly. Tests pass on a +// clean checkout with no network, no `make fetch_meta`, and no user cache. +// +// Note: os.Exit skips deferred functions, so cleanup runs explicitly after +// m.Run before exiting. +func TestMain(m *testing.M) { + root, err := os.MkdirTemp("", "lark-cli-cmd-service-test-*") + if err != nil { + println("cmd/service test setup: MkdirTemp failed:", err.Error()) + os.Exit(2) + } + if err := os.Setenv("LARKSUITE_CLI_CONFIG_DIR", root); err != nil { + println("cmd/service test setup: Setenv failed:", err.Error()) + os.RemoveAll(root) + os.Exit(2) + } + if err := registrytest.Seed(root); err != nil { + println("cmd/service test setup: registrytest.Seed failed:", err.Error()) + os.RemoveAll(root) + os.Exit(2) + } + code := m.Run() + os.RemoveAll(root) + os.Exit(code) +} diff --git a/cmd/startup_brand_test.go b/cmd/startup_brand_test.go index dd7fe687f2..4458db0395 100644 --- a/cmd/startup_brand_test.go +++ b/cmd/startup_brand_test.go @@ -5,6 +5,7 @@ package cmd import ( "context" + "flag" "fmt" "os" "os/exec" @@ -12,11 +13,34 @@ import ( "strings" "testing" + "github.com/google/uuid" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/registry" ) +const startupBrandHelperEnv = "GO_TEST_STARTUP_BRAND_HELPER" + +var _ = flag.String("startup-brand-helper", "", "internal startup brand test helper nonce") + +func isStartupBrandHelper() bool { + return startupBrandHelperEnabled(os.Getenv(startupBrandHelperEnv), startupBrandHelperNonce(os.Args)) +} + +func startupBrandHelperEnabled(envNonce, argNonce string) bool { + return envNonce != "" && envNonce == argNonce +} + +func startupBrandHelperNonce(args []string) string { + const prefix = "-startup-brand-helper=" + for _, arg := range args { + if strings.HasPrefix(arg, prefix) { + return strings.TrimPrefix(arg, prefix) + } + } + return "" +} + func TestResolveStartupBrand_Precedence(t *testing.T) { tmp := t.TempDir() t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp) @@ -54,7 +78,7 @@ func TestResolveStartupBrand_Precedence(t *testing.T) { // sync.Once, so the brand must be injected before the first catalog access. // It runs in a subprocess because the registry is process-global. func TestStartupBrandReachesRegistry_RealStartupOrder(t *testing.T) { - if os.Getenv("GO_TEST_STARTUP_BRAND_HELPER") == "1" { + if isStartupBrandHelper() { // Helper: replicate Execute()'s build wiring with a lark config. buildInternal( context.Background(), cmdutil.InvocationContext{}, @@ -71,9 +95,11 @@ func TestStartupBrandReachesRegistry_RealStartupOrder(t *testing.T) { t.Fatal(err) } + nonce := uuid.NewString() + t.Setenv(startupBrandHelperEnv, nonce) cmd := exec.Command(os.Args[0], "-test.run", "TestStartupBrandReachesRegistry_RealStartupOrder") + cmd.Args = append(cmd.Args, "-startup-brand-helper="+nonce) cmd.Env = append(os.Environ(), - "GO_TEST_STARTUP_BRAND_HELPER=1", "LARKSUITE_CLI_CONFIG_DIR="+tmp, "LARKSUITE_CLI_REMOTE_META=off", // no network during the subprocess build ) @@ -85,3 +111,33 @@ func TestStartupBrandReachesRegistry_RealStartupOrder(t *testing.T) { t.Errorf("registry brand after real startup order = %s, want lark", out) } } + +func TestStartupBrandHelperRequiresMatchingCommandNonce(t *testing.T) { + for _, tt := range []struct { + name string + envNonce string + argNonce string + want bool + }{ + {name: "neither set"}, + {name: "ambient environment only", envNonce: "ambient"}, + {name: "command argument only", argNonce: "command"}, + {name: "mismatch", envNonce: "ambient", argNonce: "command"}, + {name: "matching", envNonce: "nonce", argNonce: "nonce", want: true}, + } { + t.Run(tt.name, func(t *testing.T) { + if got := startupBrandHelperEnabled(tt.envNonce, tt.argNonce); got != tt.want { + t.Fatalf("startupBrandHelperEnabled() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestStartupBrandHelperNonce(t *testing.T) { + if got := startupBrandHelperNonce([]string{"test", "-test.run", "brand"}); got != "" { + t.Fatalf("startupBrandHelperNonce() = %q, want empty", got) + } + if got := startupBrandHelperNonce([]string{"test", "-startup-brand-helper=nonce"}); got != "nonce" { + t.Fatalf("startupBrandHelperNonce() = %q, want nonce", got) + } +} diff --git a/cmd/testmain_test.go b/cmd/testmain_test.go new file mode 100644 index 0000000000..bad6f42464 --- /dev/null +++ b/cmd/testmain_test.go @@ -0,0 +1,46 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "os" + "testing" + + "github.com/larksuite/cli/internal/registry/registrytest" +) + +// TestMain isolates command-tree tests from the host machine: config (and the +// registry cache under it) is redirected to a temp dir, then the registry is +// seeded from the tracked fixture and initialized eagerly. Tests pass on a +// clean checkout with no network, no `make fetch_meta`, and no user cache. +// +// Note: os.Exit skips deferred functions, so cleanup runs explicitly after +// m.Run before exiting. +func TestMain(m *testing.M) { + if isStartupBrandHelper() { + // Re-exec helper subprocess (startup_brand_test.go): the parent test + // already provides an isolated config dir and disables remote metadata, + // and the helper must own the first registry Init to prove the startup + // order — do not seed or eagerly initialize here. + os.Exit(m.Run()) + } + root, err := os.MkdirTemp("", "lark-cli-cmd-test-*") + if err != nil { + println("cmd test setup: MkdirTemp failed:", err.Error()) + os.Exit(2) + } + if err := os.Setenv("LARKSUITE_CLI_CONFIG_DIR", root); err != nil { + println("cmd test setup: Setenv failed:", err.Error()) + os.RemoveAll(root) + os.Exit(2) + } + if err := registrytest.Seed(root); err != nil { + println("cmd test setup: registrytest.Seed failed:", err.Error()) + os.RemoveAll(root) + os.Exit(2) + } + code := m.Run() + os.RemoveAll(root) + os.Exit(code) +} diff --git a/cmd/update/testmain_test.go b/cmd/update/testmain_test.go new file mode 100644 index 0000000000..7fdaad15bd --- /dev/null +++ b/cmd/update/testmain_test.go @@ -0,0 +1,23 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdupdate + +import ( + "os" + "path/filepath" + "testing" +) + +func TestMain(m *testing.M) { + root, err := os.MkdirTemp("", "lark-cli-update-test-*") + if err != nil { + panic(err) + } + if err := os.Setenv("LARKSUITE_CLI_CONFIG_DIR", filepath.Join(root, "config")); err != nil { + panic(err) + } + code := m.Run() + _ = os.RemoveAll(root) + os.Exit(code) +} diff --git a/cmd/update/update_test.go b/cmd/update/update_test.go index a9bbd61028..3df74b040b 100644 --- a/cmd/update/update_test.go +++ b/cmd/update/update_test.go @@ -24,6 +24,8 @@ import ( "github.com/larksuite/cli/internal/skillscheck" ) +const runLiveSkillsTestsEnv = "LARKSUITE_CLI_RUN_LIVE_SKILLS_TESTS" + // newTestFactory creates a test factory with minimal config. func newTestFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer) { t.Helper() @@ -31,13 +33,17 @@ func newTestFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffe return f, stdout, stderr } -// mockDetect sets up newUpdater to return an Updater with the given DetectResult. +// mockDetect sets up newUpdater to return an Updater with the given DetectResult +// and fully mocked skills operations. Tests that only care about install-method +// detection must never fall through to the real npx skills CLI. func mockDetect(t *testing.T, result selfupdate.DetectResult) { t.Helper() origNew := newUpdater newUpdater = func() *selfupdate.Updater { u := selfupdate.New() u.DetectOverride = func() selfupdate.DetectResult { return result } + u.SkillsIndexFetchOverride = successfulSkillsIndexFetch() + u.SkillsCommandOverride = successfulSkillsCommand() return u } t.Cleanup(func() { newUpdater = origNew }) @@ -104,6 +110,18 @@ func successfulSkillsCommand() func(args ...string) *selfupdate.NpmResult { } } +func mockSkillsSync(t *testing.T) { + t.Helper() + origNew := newUpdater + newUpdater = func() *selfupdate.Updater { + u := selfupdate.New() + u.SkillsIndexFetchOverride = successfulSkillsIndexFetch() + u.SkillsCommandOverride = successfulSkillsCommand() + return u + } + t.Cleanup(func() { newUpdater = origNew }) +} + func TestUpdatePnpm_JSON(t *testing.T) { t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) f, stdout, _ := newTestFactory(t) @@ -228,6 +246,9 @@ func TestNormalizeVersion(t *testing.T) { } func TestUpdateAlreadyUpToDate_JSON(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + mockSkillsSync(t) + f, stdout, _ := newTestFactory(t) cmd := NewCmdUpdate(f) @@ -256,6 +277,9 @@ func TestUpdateAlreadyUpToDate_JSON(t *testing.T) { } func TestUpdateAlreadyUpToDate_Human(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + mockSkillsSync(t) + f, _, stderr := newTestFactory(t) cmd := NewCmdUpdate(f) @@ -281,6 +305,7 @@ func TestUpdateAlreadyUpToDate_Human(t *testing.T) { } func TestUpdateManual_JSON(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) f, stdout, _ := newTestFactory(t) cmd := NewCmdUpdate(f) cmd.SetArgs([]string{"--json"}) @@ -312,6 +337,7 @@ func TestUpdateManual_JSON(t *testing.T) { } func TestUpdateManual_Human(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) f, _, stderr := newTestFactory(t) cmd := NewCmdUpdate(f) cmd.SetArgs([]string{}) @@ -1161,6 +1187,7 @@ func TestRunSkillsAndState_DedupForceBypass(t *testing.T) { } called := false updater := &selfupdate.Updater{ + SkillsIndexFetchOverride: successfulSkillsIndexFetch(), SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult { called = true return successfulSkillsCommand()(args...) @@ -1177,7 +1204,10 @@ func TestRunSkillsAndState_DedupForceBypass(t *testing.T) { func TestRunSkillsAndState_SuccessWritesState(t *testing.T) { t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) - updater := &selfupdate.Updater{SkillsCommandOverride: successfulSkillsCommand()} + updater := &selfupdate.Updater{ + SkillsIndexFetchOverride: successfulSkillsIndexFetch(), + SkillsCommandOverride: successfulSkillsCommand(), + } got := runSkillsAndState(updater, newTestIO(), "1.0.21", false) if got == nil || got.Err != nil { t.Fatalf("runSkillsAndState() = %+v, want non-nil with nil Err", got) @@ -1197,6 +1227,7 @@ func TestRunSkillsAndState_FailureKeepsOldState(t *testing.T) { t.Fatal(err) } updater := &selfupdate.Updater{ + SkillsIndexFetchOverride: successfulSkillsIndexFetch(), SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult { r := &selfupdate.NpmResult{} r.Err = fmt.Errorf("npx failed") @@ -1513,28 +1544,133 @@ func TestEmitSkillsTextHints_Success(t *testing.T) { } } -// TestUpdateCommand_RealSkillsSyncRewritesState is a live integration test that -// verifies "lark-cli update" correctly triggers skills sync and rewrites the -// state file. It calls the real npx skills CLI, so the test is skipped when -// npx or the skills registry is unavailable (e.g. no network or fork PRs). -func TestUpdateCommand_RealSkillsSyncRewritesState(t *testing.T) { - // Phase 1: Verify the real npx skills CLI is available; skip otherwise. +// liveSkillsIsolationEnv is the single source of truth for the user-state +// directories a live skills test must redirect under the temporary home. It +// covers the CLI's own config, the agent homes the skills CLI installs into, +// the XDG dirs it derives paths from (XDG_STATE_HOME holds its global +// .skill-lock.json), and the npm/npx overrides that take precedence over +// HOME-derived defaults (both cases: npm reads npm_config_* case-insensitively). +func liveSkillsIsolationEnv(home string) map[string]string { + return map[string]string{ + "HOME": home, + "USERPROFILE": home, + "APPDATA": filepath.Join(home, "AppData", "Roaming"), + "LOCALAPPDATA": filepath.Join(home, "AppData", "Local"), + "XDG_CONFIG_HOME": filepath.Join(home, ".config"), + "XDG_DATA_HOME": filepath.Join(home, ".local", "share"), + "XDG_STATE_HOME": filepath.Join(home, ".local", "state"), + "CODEX_HOME": filepath.Join(home, ".codex"), + "CLAUDE_CONFIG_DIR": filepath.Join(home, ".claude"), + "LARKSUITE_CLI_CONFIG_DIR": filepath.Join(home, ".lark-cli"), + "npm_config_cache": filepath.Join(home, ".npm-cache"), + "NPM_CONFIG_CACHE": filepath.Join(home, ".npm-cache"), + "npm_config_prefix": filepath.Join(home, ".npm-global"), + "NPM_CONFIG_PREFIX": filepath.Join(home, ".npm-global"), + "npm_config_userconfig": filepath.Join(home, ".npmrc"), + "NPM_CONFIG_USERCONFIG": filepath.Join(home, ".npmrc"), + } +} + +func prepareLiveSkillsIntegration(t *testing.T) string { + t.Helper() + if os.Getenv(runLiveSkillsTestsEnv) != "1" { + t.Skipf("live skills integration test disabled; set %s=1 to run", runLiveSkillsTestsEnv) + } + + home := t.TempDir() + for key, value := range liveSkillsIsolationEnv(home) { + t.Setenv(key, value) + } + return home +} + +func TestPrepareLiveSkillsIntegration(t *testing.T) { + reachedAfterGate := false + t.Run("requires explicit opt-in", func(t *testing.T) { + t.Setenv(runLiveSkillsTestsEnv, "") + prepareLiveSkillsIntegration(t) + reachedAfterGate = true + }) + if reachedAfterGate { + t.Fatal("prepareLiveSkillsIntegration continued without explicit opt-in") + } + + t.Run("isolates user directories", func(t *testing.T) { + t.Setenv(runLiveSkillsTestsEnv, "1") + home := prepareLiveSkillsIntegration(t) + // Pin the isolation contract by key: removing a variable from + // liveSkillsIsolationEnv must fail this list, and every redirected + // value must live under the temporary home. + required := []string{ + "HOME", "USERPROFILE", "APPDATA", "LOCALAPPDATA", + "XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_STATE_HOME", + "CODEX_HOME", "CLAUDE_CONFIG_DIR", "LARKSUITE_CLI_CONFIG_DIR", + "npm_config_cache", "NPM_CONFIG_CACHE", + "npm_config_prefix", "NPM_CONFIG_PREFIX", + "npm_config_userconfig", "NPM_CONFIG_USERCONFIG", + } + env := liveSkillsIsolationEnv(home) + for _, key := range required { + expected, ok := env[key] + if !ok { + t.Errorf("liveSkillsIsolationEnv dropped required key %s", key) + continue + } + if !strings.HasPrefix(expected, home) { + t.Errorf("%s = %q escapes temporary home %q", key, expected, home) + } + if got := os.Getenv(key); got != expected { + t.Errorf("%s = %q, want %q", key, got, expected) + } + } + }) +} + +// seedLiveSkillsGlobal verifies the real npx skills CLI is reachable, installs +// lark-calendar into the isolated global skills dir, and returns the parsed +// global skills list. The caller opted in explicitly, so every missing +// precondition is a hard failure — skipping would report "nothing verified" +// as a green run. +func seedLiveSkillsGlobal(t *testing.T) []string { + t.Helper() if _, err := exec.LookPath("npx"); err != nil { - t.Skipf("npx not found in PATH: %v", err) + t.Fatalf("live skills tests opted in but npx not found in PATH: %v", err) } - ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) + // Three sequential npx runs against a cold cache (the isolated home starts + // empty) can be slow; with Fatal-on-timeout semantics the budget errs on + // the generous side. + ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second) defer cancel() if err := exec.CommandContext(ctx, "npx", "-y", "skills", "add", "https://open.feishu.cn", "--list").Run(); err != nil { - t.Skipf("real skills CLI unavailable: %v", err) + t.Fatalf("live skills tests opted in but real skills CLI unavailable: %v", err) + } + if err := exec.CommandContext(ctx, "npx", "-y", "skills", "add", "https://open.feishu.cn", "-s", "lark-calendar", "-g", "-y").Run(); err != nil { + t.Fatalf("failed to seed isolated global skills: %v", err) } globalOut, err := exec.CommandContext(ctx, "npx", "-y", "skills", "ls", "-g").Output() if err != nil { - t.Skipf("real global skills CLI unavailable: %v", err) + t.Fatalf("real global skills CLI unavailable: %v", err) } localSkills := skillscheck.ParseSkillsList(string(globalOut)) + if len(localSkills) == 0 { + t.Fatal("seeded lark-calendar but global skills list is empty") + } if err := ctx.Err(); err != nil { - t.Skipf("real skills CLI availability check timed out: %v", err) + t.Fatalf("real skills CLI availability check timed out: %v", err) } + return localSkills +} + +// TestUpdateCommand_RealSkillsSyncRewritesState is a live integration test that +// verifies "lark-cli update" correctly triggers skills sync and rewrites the +// state file. It calls the real npx skills CLI and only runs with explicit +// opt-in. All user directories are redirected to a temporary home. +func TestUpdateCommand_RealSkillsSyncRewritesState(t *testing.T) { + prepareLiveSkillsIntegration(t) + + // Phase 1: Verify the real npx skills CLI is available and seed the + // isolated global skills install. + localSkills := seedLiveSkillsGlobal(t) // Phase 2: Seed a previous sync state simulating an upgrade from v1.0.19. // lark-doc and lark-mail are recorded as skipped/deleted, meaning the user @@ -1630,26 +1766,17 @@ func TestUpdateCommand_RealSkillsSyncRewritesState(t *testing.T) { // not exist (cold start), the update command installs all official skills and // writes a fresh state file. No skill should appear in SkippedDeletedSkills // because there is no previous state to preserve user deletions from. -// This is a live integration test that calls the real npx skills CLI; it is -// skipped when npx or the skills registry is unavailable. +// This is a live integration test that calls the real npx skills CLI and only +// runs with explicit opt-in. All user directories are redirected to a temporary +// home. func TestUpdateCommand_SkillsSyncColdStart(t *testing.T) { - // Phase 1: Verify the real npx skills CLI is available; skip otherwise. - if _, err := exec.LookPath("npx"); err != nil { - t.Skipf("npx not found in PATH: %v", err) - } - ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) - defer cancel() - if err := exec.CommandContext(ctx, "npx", "-y", "skills", "add", "https://open.feishu.cn", "--list").Run(); err != nil { - t.Skipf("real skills CLI unavailable: %v", err) - } - globalOut, err := exec.CommandContext(ctx, "npx", "-y", "skills", "ls", "-g").Output() - if err != nil { - t.Skipf("real global skills CLI unavailable: %v", err) - } - localSkills := skillscheck.ParseSkillsList(string(globalOut)) - if err := ctx.Err(); err != nil { - t.Skipf("real skills CLI availability check timed out: %v", err) - } + prepareLiveSkillsIntegration(t) + + // Phase 1: Verify the real npx skills CLI is available and seed one known + // official skill into the isolated global install. Cold start means no + // skills-state.json — locally installed skills may still exist, and seeding + // one keeps the Phase 4 per-skill assertions from running zero times. + localSkills := seedLiveSkillsGlobal(t) // Phase 2: Use an isolated config dir with no pre-existing skills-state.json. t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) diff --git a/internal/auth/testmain_test.go b/internal/auth/testmain_test.go new file mode 100644 index 0000000000..ac2c8b0d4b --- /dev/null +++ b/internal/auth/testmain_test.go @@ -0,0 +1,23 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "os" + "path/filepath" + "testing" +) + +func TestMain(m *testing.M) { + root, err := os.MkdirTemp("", "lark-cli-internal-auth-test-*") + if err != nil { + panic(err) + } + if err := os.Setenv("LARKSUITE_CLI_LOG_DIR", filepath.Join(root, "logs")); err != nil { + panic(err) + } + code := m.Run() + _ = os.RemoveAll(root) + os.Exit(code) +} diff --git a/internal/cmdutil/testmain_test.go b/internal/cmdutil/testmain_test.go new file mode 100644 index 0000000000..814d323a98 --- /dev/null +++ b/internal/cmdutil/testmain_test.go @@ -0,0 +1,30 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdutil + +import ( + "os" + "path/filepath" + "testing" +) + +func TestMain(m *testing.M) { + // Default-factory tests initialize the registry and resolve config. Keep + // them deterministic: never read the developer's real ~/.lark-cli and + // prevent background remote-metadata refreshes from touching user state. + root, err := os.MkdirTemp("", "lark-cli-cmdutil-test-*") + if err != nil { + println("internal/cmdutil test setup: MkdirTemp failed:", err.Error()) + os.Exit(2) + } + if err := os.Setenv("LARKSUITE_CLI_CONFIG_DIR", filepath.Join(root, "config")); err != nil { + panic(err) + } + if err := os.Setenv("LARKSUITE_CLI_REMOTE_META", "off"); err != nil { + panic(err) + } + code := m.Run() + _ = os.RemoveAll(root) + os.Exit(code) +} diff --git a/internal/event/testmain_test.go b/internal/event/testmain_test.go new file mode 100644 index 0000000000..c9704b036c --- /dev/null +++ b/internal/event/testmain_test.go @@ -0,0 +1,23 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package event + +import ( + "os" + "path/filepath" + "testing" +) + +func TestMain(m *testing.M) { + root, err := os.MkdirTemp("", "lark-cli-event-test-*") + if err != nil { + panic(err) + } + if err := os.Setenv("LARKSUITE_CLI_CONFIG_DIR", filepath.Join(root, "config")); err != nil { + panic(err) + } + code := m.Run() + _ = os.RemoveAll(root) + os.Exit(code) +} diff --git a/internal/keychain/testmain_test.go b/internal/keychain/testmain_test.go new file mode 100644 index 0000000000..4d8afd9f23 --- /dev/null +++ b/internal/keychain/testmain_test.go @@ -0,0 +1,28 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package keychain + +import ( + "os" + "path/filepath" + "testing" +) + +func TestMain(m *testing.M) { + root, err := os.MkdirTemp("", "lark-cli-keychain-test-*") + if err != nil { + panic(err) + } + for key, value := range map[string]string{ + "LARKSUITE_CLI_DATA_DIR": filepath.Join(root, "data"), + "LARKSUITE_CLI_LOG_DIR": filepath.Join(root, "logs"), + } { + if err := os.Setenv(key, value); err != nil { + panic(err) + } + } + code := m.Run() + _ = os.RemoveAll(root) + os.Exit(code) +} diff --git a/internal/registry/registry_test.go b/internal/registry/registry_test.go index 8906e3f94e..9722928d71 100644 --- a/internal/registry/registry_test.go +++ b/internal/registry/registry_test.go @@ -101,6 +101,7 @@ func TestSelectRecommendedScope_Empty(t *testing.T) { } func TestComputeMinimumScopeSet(t *testing.T) { + ensureFreshRegistry(t) minSet := ComputeMinimumScopeSet("user") if len(minSet) == 0 { if len(ListFromMetaProjects()) == 0 { diff --git a/internal/registry/registrytest/fixture_meta.json b/internal/registry/registrytest/fixture_meta.json new file mode 100644 index 0000000000..b33f2d420e --- /dev/null +++ b/internal/registry/registrytest/fixture_meta.json @@ -0,0 +1,72 @@ +{ + "version": "0.0.1", + "services": [ + { + "name": "calendar", + "version": "v4", + "title": "Calendar API", + "servicePath": "/open-apis/calendar/v4", + "resources": { + "events": { + "methods": { + "create": { + "path": "calendars/{calendar_id}/events", + "httpMethod": "POST", + "risk": "write", + "scopes": [ + "calendar:calendar.event:create" + ], + "parameters": { + "calendar_id": { + "type": "string", + "location": "path", + "required": true + } + } + } + } + } + } + }, + { + "name": "im", + "version": "v1", + "title": "IM API", + "servicePath": "/open-apis/im/v1", + "resources": { + "chat.members": { + "methods": { + "create": { + "path": "chats/{chat_id}/members", + "httpMethod": "POST", + "risk": "write", + "scopes": [ + "im:chat", + "im:chat.members:write_only" + ], + "parameters": { + "chat_id": { + "type": "string", + "location": "path", + "required": true + }, + "member_id_type": { + "type": "string", + "location": "query", + "required": false + } + } + } + } + } + } + }, + { + "name": "task", + "version": "v2", + "title": "Task API", + "servicePath": "/open-apis/task/v2", + "resources": {} + } + ] +} diff --git a/internal/registry/registrytest/registrytest.go b/internal/registry/registrytest/registrytest.go new file mode 100644 index 0000000000..3b3512a45e --- /dev/null +++ b/internal/registry/registrytest/registrytest.go @@ -0,0 +1,146 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Package registrytest seeds the registry with a tracked metadata fixture so +// command-tree tests pass on a clean checkout — no `make fetch_meta`, no +// network, no user cache. TestMain funcs of packages that build service +// commands call Seed after redirecting LARKSUITE_CLI_CONFIG_DIR. +package registrytest + +import ( + _ "embed" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "time" + + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/registry" + "github.com/larksuite/cli/internal/vfs" +) + +// fixtureMetaJSON is a trimmed snapshot of the generated meta_data.json +// holding only the calendar, im and task services that registry-backed tests +// assert against. Its version is pinned to "0.0.1": newer than the empty +// embedded stub ("0.0.0") so it wins on a clean checkout, older than any real +// generated catalog ("1.0.0"+) so a `make fetch_meta` build keeps testing the +// full embedded data. +// +//go:embed fixture_meta.json +var fixtureMetaJSON []byte + +// Seed writes fixtureMetaJSON into the registry remote-meta cache under +// LARKSUITE_CLI_CONFIG_DIR and eagerly initializes the registry. testRoot must +// be the temporary root created by the caller's TestMain; Seed rejects a config +// directory outside it before performing any write. The cache +// meta is stamped fresh so Init never sync-fetches or background-refreshes +// over the network. Eager Init pins the catalog for the whole test process before +// any individual test can re-point LARKSUITE_CLI_CONFIG_DIR elsewhere. +// +// The caller's TestMain must set LARKSUITE_CLI_CONFIG_DIR beneath testRoot +// first; Seed refuses unset, mismatched, or escaping paths so it can never +// write into a developer's real ~/.lark-cli. +func Seed(testRoot string) error { + configDir := os.Getenv("LARKSUITE_CLI_CONFIG_DIR") + if err := validateConfigDir(testRoot, configDir); err != nil { + return err + } + + var fixture struct { + Version string `json:"version"` + } + if err := json.Unmarshal(fixtureMetaJSON, &fixture); err != nil { + return err + } + + cacheDir := filepath.Join(configDir, "cache") + if err := vfs.MkdirAll(cacheDir, 0o700); err != nil { + return err + } + if err := vfs.WriteFile(filepath.Join(cacheDir, "remote_meta.json"), fixtureMetaJSON, 0o644); err != nil { + return err + } + cacheMeta, err := json.Marshal(registry.CacheMeta{ + LastCheckAt: time.Now().Unix(), + Version: fixture.Version, + Brand: string(core.BrandFeishu), + }) + if err != nil { + return err + } + if err := vfs.WriteFile(filepath.Join(cacheDir, "remote_meta.meta.json"), cacheMeta, 0o644); err != nil { + return err + } + + // Neutralize ambient knobs that would defeat the seeding: an inherited + // LARKSUITE_CLI_REMOTE_META=off would stop Init from reading the seeded + // cache at all, and LARKSUITE_CLI_META_TTL=0 would expire the freshness + // stamp and start a background network refresh from inside unit tests. + if err := os.Unsetenv("LARKSUITE_CLI_REMOTE_META"); err != nil { + return err + } + if err := os.Unsetenv("LARKSUITE_CLI_META_TTL"); err != nil { + return err + } + + registry.Init() + + // Init is a sync.Once, so the seed is pinned for the whole test process. + // Turning remote metadata off afterwards cannot un-seed anything; it is a + // guard for any future post-Init code path that might consult the remote + // cache again after a test re-points LARKSUITE_CLI_CONFIG_DIR elsewhere. + if err := os.Setenv("LARKSUITE_CLI_REMOTE_META", "off"); err != nil { + return err + } + + // Self-check: both the fixture and any real generated catalog contain the + // im service. If it is missing, the cache seeding silently stopped working + // (e.g. the registry cache file names or freshness semantics changed) and + // every registry-backed test would fail confusingly — fail loudly here + // instead, pointing at this package. + merged, ok := registry.ServiceTyped("im") + if !ok { + return errors.New("registrytest.Seed: registry has no im service after seeding — " + + "the remote-cache format in internal/registry/remote.go may have changed; update registrytest to match") + } + + // Self-check: on a fetch_meta build the real embedded catalog must win over + // the 0.0.1 fixture. If the merged im service diverges from the embedded + // one, the version arbitration flipped (e.g. the generated catalog version + // stopped parsing as semver) and unit tests would silently run against the + // stale trimmed fixture instead of the fresh catalog. + for _, service := range registry.EmbeddedServicesTyped() { + if service.Name != "im" { + continue + } + if service.Version != merged.Version { + return errors.New("registrytest.Seed: the fixture shadowed the real embedded catalog — " + + "check the meta_data.json version against the fixture's \"0.0.1\" arbitration in this package") + } + break + } + return nil +} + +// validateConfigDir guards the one real hazard: a TestMain wiring mistake +// pointing LARKSUITE_CLI_CONFIG_DIR at a developer's real directory. Both +// paths come from the caller's own MkdirTemp, so a plain containment check +// is enough. +func validateConfigDir(testRoot, configDir string) error { + if testRoot == "" || configDir == "" { + return errors.New("registrytest.Seed: test root and config dir must be set") + } + if !filepath.IsAbs(testRoot) || !filepath.IsAbs(configDir) { + return errors.New("registrytest.Seed: test root and config dir must be absolute") + } + rel, err := filepath.Rel(filepath.Clean(testRoot), filepath.Clean(configDir)) + if err != nil { + return err + } + if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return errors.New("registrytest.Seed: config dir must stay inside the test root") + } + return nil +} diff --git a/internal/registry/registrytest/registrytest_test.go b/internal/registry/registrytest/registrytest_test.go new file mode 100644 index 0000000000..ea7da8b58e --- /dev/null +++ b/internal/registry/registrytest/registrytest_test.go @@ -0,0 +1,229 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package registrytest + +import ( + "net/http" + "os" + "path/filepath" + "slices" + "sort" + "testing" + + "github.com/larksuite/cli/internal/meta" + "github.com/larksuite/cli/internal/registry" +) + +func TestValidateConfigDir(t *testing.T) { + root := t.TempDir() + tests := []struct { + name string + testRoot string + configDir string + wantErr bool + }{ + {name: "equal", testRoot: root, configDir: root}, + {name: "child", testRoot: root, configDir: filepath.Join(root, "config")}, + { + name: "sibling", + testRoot: root, + configDir: filepath.Join(filepath.Dir(root), "outside"), + wantErr: true, + }, + {name: "empty root", configDir: root, wantErr: true}, + {name: "empty config", testRoot: root, wantErr: true}, + {name: "relative root", testRoot: "relative", configDir: root, wantErr: true}, + {name: "relative config", testRoot: root, configDir: "relative", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateConfigDir(tt.testRoot, tt.configDir) + if (err != nil) != tt.wantErr { + t.Fatalf("validateConfigDir() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestFixtureContract(t *testing.T) { + if len(fixtureMetaJSON) > 20<<10 { + t.Fatalf("fixture size = %d, want <= %d", len(fixtureMetaJSON), 20<<10) + } + reg, err := meta.Parse(fixtureMetaJSON) + if err != nil { + t.Fatalf("meta.Parse() error = %v", err) + } + if reg.Version != "0.0.1" { + t.Fatalf("fixture version = %q, want 0.0.1", reg.Version) + } + + gotNames := make([]string, 0, len(reg.Services)) + for _, service := range reg.Services { + gotNames = append(gotNames, service.Name) + } + sort.Strings(gotNames) + if !slices.Equal(gotNames, []string{"calendar", "im", "task"}) { + t.Fatalf("fixture services = %v, want [calendar im task]", gotNames) + } + + calendarCreate := fixtureMethod(t, reg, "calendar", "events", "create") + assertMethodContract(t, calendarCreate, "calendars/{calendar_id}/events", http.MethodPost) + calendarID, ok := calendarCreate.Parameters["calendar_id"] + if !ok || calendarID.Location != "path" || !calendarID.Required { + t.Fatalf("calendar_id = %+v, want required path parameter", calendarID) + } + if !slices.Contains(calendarCreate.Scopes, "calendar:calendar.event:create") { + t.Fatalf("calendar create scopes = %v, want calendar:calendar.event:create", calendarCreate.Scopes) + } + + imCreate := fixtureMethod(t, reg, "im", "chat.members", "create") + assertMethodContract(t, imCreate, "chats/{chat_id}/members", http.MethodPost) + chatID, ok := imCreate.Parameters["chat_id"] + if !ok || chatID.Location != "path" || !chatID.Required { + t.Fatalf("chat_id = %+v, want required path parameter", chatID) + } + memberIDType, ok := imCreate.Parameters["member_id_type"] + if !ok || memberIDType.Location != "query" || memberIDType.Required { + t.Fatalf("member_id_type = %+v, want optional query parameter", memberIDType) + } + if imCreate.Risk != "write" { + t.Fatalf("im create risk = %q, want write", imCreate.Risk) + } + for _, scope := range []string{"im:chat", "im:chat.members:write_only"} { + if !slices.Contains(imCreate.Scopes, scope) { + t.Fatalf("im create scopes = %v, want %s", imCreate.Scopes, scope) + } + } +} + +func fixtureMethod(t *testing.T, reg meta.Registry, serviceName, resourceName, methodName string) meta.Method { + t.Helper() + for _, service := range reg.Services { + if service.Name != serviceName { + continue + } + resource, ok := service.Resource(resourceName) + if !ok { + t.Fatalf("fixture service %s has no resource %s", serviceName, resourceName) + } + method, ok := resource.Method(methodName) + if !ok { + t.Fatalf("fixture resource %s.%s has no method %s", serviceName, resourceName, methodName) + } + return method + } + t.Fatalf("fixture has no service %s", serviceName) + return meta.Method{} +} + +func assertMethodContract(t *testing.T, method meta.Method, path, httpMethod string) { + t.Helper() + if method.Path != path || method.HTTPMethod != httpMethod { + t.Fatalf("method = %s %s, want %s %s", method.HTTPMethod, method.Path, httpMethod, path) + } +} + +// TestSeedRejectsUnsafeConfigDir pins Seed's guard: it must return before +// writing anything when LARKSUITE_CLI_CONFIG_DIR is unset or escapes the +// caller's test root, so a TestMain wiring mistake can never touch a +// developer's real ~/.lark-cli. +func TestSeedRejectsUnsafeConfigDir(t *testing.T) { + root := t.TempDir() + + t.Run("unset config dir", func(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", "") + if err := Seed(root); err == nil { + t.Fatal("Seed() error = nil, want unset config dir rejection") + } + }) + + t.Run("config dir outside test root", func(t *testing.T) { + outside := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", outside) + if err := Seed(root); err == nil { + t.Fatal("Seed() error = nil, want containment rejection") + } + if _, err := os.Stat(filepath.Join(outside, "cache")); err == nil { + t.Fatal("Seed wrote into the rejected config dir") + } + }) +} + +// TestSeedWritesFixtureAndInitializesRegistry covers the seeding happy path: +// cache files land under the config dir, the registry initializes from them, +// and both self-checks pass. +func TestSeedWritesFixtureAndInitializesRegistry(t *testing.T) { + root := t.TempDir() + configDir := filepath.Join(root, "config") + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir) + + if err := Seed(root); err != nil { + t.Fatalf("Seed() error = %v, want nil", err) + } + for _, name := range []string{"remote_meta.json", "remote_meta.meta.json"} { + if _, err := os.Stat(filepath.Join(configDir, "cache", name)); err != nil { + t.Errorf("cache file %s: %v", name, err) + } + } + if got := os.Getenv("LARKSUITE_CLI_REMOTE_META"); got != "off" { + t.Errorf("LARKSUITE_CLI_REMOTE_META = %q, want off after seeding", got) + } + for _, service := range []string{"calendar", "im", "task"} { + if _, ok := registry.ServiceTyped(service); !ok { + t.Errorf("registry missing service %s after seeding", service) + } + } +} + +// TestSeedPropagatesCacheSetupFailures pins that filesystem failures while +// materializing the cache surface as errors instead of leaving the registry +// silently unseeded. Each obstacle is a same-named file/directory in the +// way, which fails on every platform without permission tricks. +func TestSeedPropagatesCacheSetupFailures(t *testing.T) { + seedWith := func(t *testing.T, prepare func(root, configDir string)) error { + t.Helper() + root := t.TempDir() + configDir := filepath.Join(root, "config") + prepare(root, configDir) + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir) + return Seed(root) + } + + t.Run("cache dir creation fails", func(t *testing.T) { + err := seedWith(t, func(root, configDir string) { + // config is a regular file, so MkdirAll(config/cache) fails. + if err := os.WriteFile(configDir, nil, 0o600); err != nil { + t.Fatal(err) + } + }) + if err == nil { + t.Fatal("Seed() error = nil, want cache dir creation failure") + } + }) + + t.Run("fixture write fails", func(t *testing.T) { + err := seedWith(t, func(root, configDir string) { + // remote_meta.json is a directory, so WriteFile fails. + if err := os.MkdirAll(filepath.Join(configDir, "cache", "remote_meta.json"), 0o700); err != nil { + t.Fatal(err) + } + }) + if err == nil { + t.Fatal("Seed() error = nil, want fixture write failure") + } + }) + + t.Run("cache meta write fails", func(t *testing.T) { + err := seedWith(t, func(root, configDir string) { + // remote_meta.meta.json is a directory, so WriteFile fails. + if err := os.MkdirAll(filepath.Join(configDir, "cache", "remote_meta.meta.json"), 0o700); err != nil { + t.Fatal(err) + } + }) + if err == nil { + t.Fatal("Seed() error = nil, want cache meta write failure") + } + }) +} diff --git a/internal/registry/testmain_test.go b/internal/registry/testmain_test.go new file mode 100644 index 0000000000..357c9140fe --- /dev/null +++ b/internal/registry/testmain_test.go @@ -0,0 +1,27 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package registry + +import ( + "os" + "path/filepath" + "testing" +) + +func TestMain(m *testing.M) { + root, err := os.MkdirTemp("", "lark-cli-registry-test-*") + if err != nil { + panic(err) + } + if err := os.Setenv("LARKSUITE_CLI_CONFIG_DIR", filepath.Join(root, "config")); err != nil { + panic(err) + } + code := m.Run() + // A test that ran Init without a trailing resetInit can leave a background + // refresh goroutine alive; removing the temp root while it writes would + // let it recreate the directory after cleanup. Wait it out first. + waitBackgroundRefresh() + _ = os.RemoveAll(root) + os.Exit(code) +}