From 93d7e5a4c610fbbeacd83c86a438b4b00b6ad602 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Tue, 4 Aug 2026 00:55:22 +0800 Subject: [PATCH] refactor(app): consolidate command-framework seams and unify recovery errors Centralize dynamic-server command-key protection, profile runtime selection, and endpoint-resolution error construction; drop the dead dry-run catalog-miss branch; document host_compat stubs as edition-sync anchors; keep test fixtures out of the package dir via t.TempDir. --- AGENTS.md | 2 +- internal/app/coverage_edges_test.go | 22 ++++-- internal/app/host_compat.go | 7 ++ internal/app/recovery_command.go | 2 +- .../recovery_command_full_coverage_test.go | 2 +- internal/app/root.go | 59 ++++++++-------- internal/app/runner.go | 69 +++++++++---------- internal/app/runner_full_coverage_test.go | 8 +-- .../app/skill_command_full_coverage_test.go | 4 +- .../app/skill_setup_full_coverage_test.go | 8 +-- internal/app/upgrade_full_coverage_test.go | 2 +- 11 files changed, 102 insertions(+), 83 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8e4db0c5..f1b02e28 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -302,7 +302,7 @@ on the leaf: ```bash dws auth status # token_valid should be true -dws cache refresh # refresh discovery / tools cache +dws cache refresh # deprecated no-op: prints a retirement notice (discovery cache is gone; refreshes nothing) dws schema -f json # or CLI path: dws schema --cli-path "drive copy" -f json ``` diff --git a/internal/app/coverage_edges_test.go b/internal/app/coverage_edges_test.go index e8acec41..b9f17e21 100644 --- a/internal/app/coverage_edges_test.go +++ b/internal/app/coverage_edges_test.go @@ -287,8 +287,13 @@ func TestCrossPlatformCoverageRecoveryPureCoverage(t *testing.T) { if _, err := (*recoveryRuntime)(nil).CallToolDirect(context.Background(), "x", "y", nil); err == nil { t.Fatal("nil recovery runtime call succeeded") } - if _, err := (&recoveryRuntime{}).resolveEndpoint(context.Background(), "missing", "tool"); err == nil || !strings.Contains(err.Error(), "未找到服务 missing 的 endpoint") { + if _, err := (&recoveryRuntime{}).resolveEndpoint(context.Background(), "missing", "tool"); err == nil || !strings.Contains(err.Error(), `endpoint not resolved for product "missing" (tool "tool")`) { t.Fatalf("missing recovery endpoint error = %v", err) + } else { + var apiErr *apperrors.Error + if !errors.As(err, &apiErr) || apiErr.Category != apperrors.CategoryAPI || apiErr.Operation != "discovery.resolve" || apiErr.Reason != "endpoint_not_resolved" { + t.Fatalf("missing recovery endpoint classification = %#v", err) + } } t.Setenv("DINGTALK_OK_MCP_URL", " https://catalog.test ") runtime := &recoveryRuntime{} @@ -798,9 +803,12 @@ func TestCrossPlatformCoverageRuntimeRunnerRoutingCoverage(t *testing.T) { } t.Setenv("DINGTALK_PRODUCT_MCP_URL", "") + // Dry-run is an execution barrier enforced by Run before endpoint + // resolution, so a dry-run invocation returns a local preview and never + // reaches handleCatalogMiss or the fallback runner. r.globalFlags = &GlobalFlags{DryRun: true} - if got, err := r.runSingle(context.Background(), inv, false); err != nil || got.Response["fallback"] != true || !fallback.last.DryRun { - t.Fatalf("dry endpoint miss = %#v %v (invocation %#v)", got, err, fallback.last) + if got, err := r.Run(context.Background(), inv); err != nil || got.Response["dry_run"] != true || got.Response["fallback"] != nil { + t.Fatalf("dry endpoint miss = %#v %v", got, err) } r.globalFlags = &GlobalFlags{} if _, err := r.runSingle(context.Background(), inv, false); err == nil || !strings.Contains(err.Error(), "no dynamic endpoint registered for product or tool") { @@ -821,9 +829,11 @@ func TestCrossPlatformCoverageRuntimeRunnerRoutingCoverage(t *testing.T) { t.Fatalf("direct runtime route = %#v %v", got, err) } SetDynamicServers(nil) + // With no dynamic endpoint registered, a dry-run helper invocation is still + // stopped by the Run barrier before endpoint resolution. r.globalFlags = &GlobalFlags{DryRun: true} - if got, err := r.runSingle(context.Background(), directInv, false); err != nil || got.Response["fallback"] != true || !fallback.last.DryRun { - t.Fatalf("direct runtime dry miss = %#v %v (invocation %#v)", got, err, fallback.last) + if got, err := r.Run(context.Background(), directInv); err != nil || got.Response["dry_run"] != true || got.Response["fallback"] != nil { + t.Fatalf("direct runtime dry miss = %#v %v", got, err) } for _, result := range []executor.Result{{}, {Response: map[string]any{"content": "value"}}, {Response: map[string]any{"value": 1}}} { @@ -934,7 +944,7 @@ func TestCrossPlatformCoverageExecuteInvocationCoverage(t *testing.T) { t.Fatalf("stdio dry-run = %#v %v", got, err) } stdioInv.DryRun = false - if _, err := r.executeStdioInvocation(context.Background(), stdioInv); err == nil { + if _, err := r.executeStdioInvocationAtEndpoint(context.Background(), "", stdioInv); err == nil { t.Fatal("missing stdio client succeeded") } } diff --git a/internal/app/host_compat.go b/internal/app/host_compat.go index 824d3374..016ea331 100644 --- a/internal/app/host_compat.go +++ b/internal/app/host_compat.go @@ -11,6 +11,13 @@ // See the License for the specific language governing permissions and // limitations under the License. +// This file holds deliberate no-op stubs that act as edition-sync anchors for +// the private wukong edition overlay. The overlay replaces these function +// bodies with real host-compatibility configuration while the open-source +// build keeps them empty; both editions therefore share identical call sites +// (access_token_resolve.go, auth_command.go, doctor_command.go, +// force_refresh.go). These stubs are sync seams for the edition overlay, not +// dead code: do not delete them or their call sites during cleanup. package app import ( diff --git a/internal/app/recovery_command.go b/internal/app/recovery_command.go index b1906c20..ad7df69c 100644 --- a/internal/app/recovery_command.go +++ b/internal/app/recovery_command.go @@ -354,7 +354,7 @@ func (r *recoveryRuntime) resolveEndpoint(_ context.Context, productID, toolName if endpoint, ok := directRuntimeEndpoint(productID, toolName); ok { return endpoint, nil } - return "", fmt.Errorf("未找到服务 %s 的 endpoint", productID) + return "", endpointNotResolvedError(productID, toolName, "no dynamic endpoint registered for product or tool") } func recoveryRuntimeToken(flags *GlobalFlags) string { diff --git a/internal/app/recovery_command_full_coverage_test.go b/internal/app/recovery_command_full_coverage_test.go index c891f128..f391737b 100644 --- a/internal/app/recovery_command_full_coverage_test.go +++ b/internal/app/recovery_command_full_coverage_test.go @@ -131,7 +131,7 @@ func TestCrossPlatformCoverageRecoveryExecutionAndRuntimeRemainingCoverage(t *te transport: transport.NewClient(nil), flags: &GlobalFlags{Token: "token"}, } - if _, err := runtime.CallToolDirect(context.Background(), "missing", "tool", nil); err == nil || !strings.Contains(err.Error(), "未找到服务 missing 的 endpoint") { + if _, err := runtime.CallToolDirect(context.Background(), "missing", "tool", nil); err == nil || !strings.Contains(err.Error(), `endpoint not resolved for product "missing" (tool "tool")`) { t.Fatalf("direct resolution error = %v", err) } if got, err := runtime.Search(context.Background(), "query", recovery.RecoveryContext{}); err == nil || got.DocSearch.Status != "error" { diff --git a/internal/app/root.go b/internal/app/root.go index 1ab8ecbb..908ee9bd 100644 --- a/internal/app/root.go +++ b/internal/app/root.go @@ -693,27 +693,6 @@ func newVersionCommand() *cobra.Command { // stay hidden. func hideNonDirectRuntimeCommands(root *cobra.Command) { allowedProducts := resolveVisibleProducts() - staticCommands := map[string]bool{ - "auth": true, - "api": true, - "audit": true, - "cache": true, - "config": true, - "dev": true, - "doctor": true, - "event": true, - "completion": true, - "skill": true, - "plugin": true, - "profile": true, - "version": true, - "help": true, - "markdown": true, - "recovery": true, - "schema": true, - "mcp": true, - "upgrade": true, - } for _, cmd := range root.Commands() { name := cmd.Name() if cmd.Hidden { @@ -729,16 +708,40 @@ func hideNonDirectRuntimeCommands(root *cobra.Command) { } } +// builtinCommandNames is the shared base set of built-in command names. Both +// staticCommands (the visibility allow-list used by +// hideNonDirectRuntimeCommands) and reservedCommands (the plugin-override +// blocklist) derive from this single set so they cannot drift apart. +var builtinCommandNames = map[string]bool{ + "auth": true, "api": true, "audit": true, "cache": true, "config": true, + "doctor": true, "event": true, "completion": true, "skill": true, + "plugin": true, "profile": true, "version": true, "help": true, + "recovery": true, "schema": true, "mcp": true, "upgrade": true, +} + +// commandNameSet returns a new set containing every name in base plus extras. +func commandNameSet(base map[string]bool, extras ...string) map[string]bool { + set := make(map[string]bool, len(base)+len(extras)) + for name := range base { + set[name] = true + } + for _, extra := range extras { + set[extra] = true + } + return set +} + +// staticCommands is the set of built-in commands that stay visible even when +// they are not backed by a static endpoint product. Asymmetry with +// reservedCommands is intentional: dev/markdown stay visible but are not +// plugin-reserved, while login/logout are plugin-reserved but are not static +// top-level commands. +var staticCommands = commandNameSet(builtinCommandNames, "dev", "markdown") + // reservedCommands is the set of built-in command names that plugins must // not override. This protects core CLI functionality from being hijacked // by a malicious or misconfigured plugin. -var reservedCommands = map[string]bool{ - "auth": true, "api": true, "audit": true, "login": true, "logout": true, - "plugin": true, "profile": true, "skill": true, "cache": true, - "config": true, "doctor": true, "event": true, "completion": true, - "recovery": true, "upgrade": true, "version": true, - "schema": true, "mcp": true, "help": true, -} +var reservedCommands = commandNameSet(builtinCommandNames, "login", "logout") var replaceablePluginFallbacks = map[string]bool{ "conference": true, diff --git a/internal/app/runner.go b/internal/app/runner.go index 791418bf..a6ef75d4 100644 --- a/internal/app/runner.go +++ b/internal/app/runner.go @@ -142,7 +142,6 @@ func newCommandRunnerWithFlags(flags *GlobalFlags) executor.Runner { return &runtimeRunner{ transport: transportClient, globalFlags: flags, - fallback: executor.EchoRunner{}, scanner: newRuntimeContentScanner(), enforceContentScan: runtimeFlagEnabled(os.Getenv(runtimeContentScanEnforceEnv), false), includeScanReport: runtimeFlagEnabled(os.Getenv(runtimeContentScanReportOutputEnv), false), @@ -208,13 +207,7 @@ func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation) if profile == nil { return executor.Result{}, apperrors.NewValidation(fmt.Sprintf("profile %q not found", rawProfile)) } - resolvedSelector := authpkg.ProfileSelector(*profile) - if strings.TrimSpace(profile.UserID) == "" { - // Preserve a unique local-name selector for an unresolved account. - // Reducing it to corpId can select a different exact account through - // the organization's current-account pointer. - resolvedSelector = rawProfile - } + resolvedSelector := profileRuntimeSelector(*profile, rawProfile) authpkg.SetRuntimeProfile(resolvedSelector) defer authpkg.SetRuntimeProfile(rawProfile) } @@ -275,6 +268,18 @@ func (r *runtimeRunner) runSingle(ctx context.Context, invocation executor.Invoc return r.handleCatalogMiss(ctx, invocation, "no dynamic endpoint registered for product or tool") } +// profileRuntimeSelector resolves the runtime profile selector for an +// invocation. It preserves a unique local-name selector for an unresolved +// account (empty UserID): reducing it to corpId can select a different exact +// account through the organization's current-account pointer. Shared by the +// single-profile path in Run and the multi-profile path in runMultiProfile. +func profileRuntimeSelector(profile authpkg.Profile, rawSelector string) string { + if strings.TrimSpace(profile.UserID) == "" { + return rawSelector + } + return authpkg.ProfileSelector(profile) +} + type multiProfileSelection struct { Selector string Profile authpkg.Profile @@ -326,10 +331,7 @@ func (r *runtimeRunner) runMultiProfile(ctx context.Context, invocation executor failed := 0 for _, selection := range selections { - resolvedSelector := authpkg.ProfileSelector(selection.Profile) - if strings.TrimSpace(selection.Profile.UserID) == "" { - resolvedSelector = selection.Selector - } + resolvedSelector := profileRuntimeSelector(selection.Profile, selection.Selector) authpkg.SetRuntimeProfile(resolvedSelector) result, err := r.runSingle(ctx, cloneInvocation(invocation), false) @@ -429,36 +431,40 @@ func multiProfileErrorPayload(err error) map[string]any { // marshals to `null`, surfacing as `{"Content": null}` at the CLI. Users had no // signal that endpoint resolution failed — see the fix-wukong-discovery-missing-servers plan (Phase 3) for the full trace. // -// New contract: -// - Dry-run (invocation.DryRun or globalFlags.DryRun): keep EchoRunner so -// `--dry-run` still prints the planned payload without real execution. -// - Otherwise: return an explicit apperrors.NewAPI("endpoint_not_resolved") -// with the offending product/tool attached. This fails fast to stderr and -// makes missing envelopes / supplement gaps immediately visible. -func (r *runtimeRunner) handleCatalogMiss(ctx context.Context, invocation executor.Invocation, detail string) (executor.Result, error) { - dryRun := invocation.DryRun || (r.globalFlags != nil && r.globalFlags.DryRun) - if dryRun { - invocation.DryRun = true - return r.fallback.Run(ctx, invocation) - } +// Contract: return an explicit apperrors.NewAPI("endpoint_not_resolved") +// with the offending product/tool attached. This fails fast to stderr and +// makes missing envelopes / supplement gaps immediately visible. Dry-run +// invocations never reach this path in production: Run enforces the dry-run +// barrier before endpoint resolution, and runSingle is only re-entered via +// Run (multi-profile and PAT retry both route back through it). +func (r *runtimeRunner) handleCatalogMiss(_ context.Context, invocation executor.Invocation, detail string) (executor.Result, error) { + return executor.Result{}, endpointNotResolvedError(invocation.CanonicalProduct, invocation.Tool, detail) +} + +// endpointNotResolvedError builds the shared terminal error for a dynamic +// server registry miss. Both the runtime runner (handleCatalogMiss) and the +// recovery runtime (resolveEndpoint) use it so endpoint misses classify +// identically: CategoryAPI, operation "discovery.resolve", reason +// "endpoint_not_resolved", with the product ID as the server key. +func endpointNotResolvedError(productID, toolName, detail string) error { hint := "当前命令已注册,但静态端点目录中缺少对应 product/server endpoint。这通常是服务发现下线后的同步产物缺口,不是参数错误;请不要通过反复调整 flag 重试。" actions := []string{ "确认 internal/syncdata.StaticServers() 是否包含该 product/server", "运行 sync-oss 重新生成静态端点与路由", "若该能力已下线,请在 skill 与 --help 中标记 unavailable 并提供替代命令", } - if strings.TrimSpace(invocation.CanonicalProduct) == devappProductID { + if strings.TrimSpace(productID) == devappProductID { hint = "dev app(product id: devapp)是 helper-only 产品,命令树不依赖服务发现;真实调用需要通过 StaticServers/SupplementServers 注入 MCP endpoint,或本地调试临时设置 DINGTALK_DEVAPP_MCP_URL。" actions = []string{ "检查 StaticServers/SupplementServers 是否包含 devapp endpoint", "本地调试可临时设置 DINGTALK_DEVAPP_MCP_URL 后重试", } } - return executor.Result{}, apperrors.NewAPI( - fmt.Sprintf("endpoint not resolved for product %q (tool %q): %s", invocation.CanonicalProduct, invocation.Tool, detail), + return apperrors.NewAPI( + fmt.Sprintf("endpoint not resolved for product %q (tool %q): %s", productID, toolName, detail), apperrors.WithOperation("discovery.resolve"), apperrors.WithReason("endpoint_not_resolved"), - apperrors.WithServerKey(invocation.CanonicalProduct), + apperrors.WithServerKey(productID), apperrors.WithHint(hint), apperrors.WithActions(actions...), ) @@ -744,13 +750,6 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string, return executor.Result{Invocation: invocation, Response: response}, nil } -// executeStdioInvocation dispatches a tool call through a local StdioClient -// subprocess instead of the HTTP transport. This is used for plugin stdio -// servers whose endpoints use the stdio:// scheme. -func (r *runtimeRunner) executeStdioInvocation(ctx context.Context, invocation executor.Invocation) (executor.Result, error) { - return r.executeStdioInvocationAtEndpoint(ctx, "", invocation) -} - func (r *runtimeRunner) executeStdioInvocationAtEndpoint( ctx context.Context, endpoint string, diff --git a/internal/app/runner_full_coverage_test.go b/internal/app/runner_full_coverage_test.go index ba628079..34e1b0ba 100644 --- a/internal/app/runner_full_coverage_test.go +++ b/internal/app/runner_full_coverage_test.go @@ -306,7 +306,7 @@ func TestCrossPlatformCoverageRunnerRemainingStdioAuthAndHeadersCoverage(t *test inv := executor.Invocation{CanonicalProduct: "stdio-product", Tool: "tool"} wantErr := errors.New("stdio failed") runnerStdioEnsureInitialized = func(*transport.StdioClient, context.Context) error { return wantErr } - if _, err := r.executeStdioInvocation(context.Background(), inv); err == nil || !strings.Contains(err.Error(), "stdio initialize failed") { + if _, err := r.executeStdioInvocationAtEndpoint(context.Background(), "", inv); err == nil || !strings.Contains(err.Error(), "stdio initialize failed") { t.Fatalf("stdio initialize error = %v", err) } runnerStdioEnsureInitialized = func(*transport.StdioClient, context.Context) error { return nil } @@ -321,19 +321,19 @@ func TestCrossPlatformCoverageRunnerRemainingStdioAuthAndHeadersCoverage(t *test runnerStdioCallTool = func(*transport.StdioClient, context.Context, string, map[string]any) (transport.ToolCallResult, error) { return transport.ToolCallResult{}, wantErr } - if _, err := r.executeStdioInvocation(context.Background(), inv); err == nil || !strings.Contains(err.Error(), "stdio failed") { + if _, err := r.executeStdioInvocationAtEndpoint(context.Background(), "", inv); err == nil || !strings.Contains(err.Error(), "stdio failed") { t.Fatalf("stdio call error = %v", err) } runnerStdioCallTool = func(*transport.StdioClient, context.Context, string, map[string]any) (transport.ToolCallResult, error) { return transport.ToolCallResult{IsError: true, Content: map[string]any{"message": "tool failed"}}, nil } - if _, err := r.executeStdioInvocation(context.Background(), inv); err == nil || !strings.Contains(err.Error(), "tool failed") { + if _, err := r.executeStdioInvocationAtEndpoint(context.Background(), "", inv); err == nil || !strings.Contains(err.Error(), "tool failed") { t.Fatalf("stdio tool error = %v", err) } runnerStdioCallTool = func(*transport.StdioClient, context.Context, string, map[string]any) (transport.ToolCallResult, error) { return transport.ToolCallResult{Content: map[string]any{"ok": true}}, nil } - if got, err := r.executeStdioInvocation(context.Background(), inv); err != nil || !got.Invocation.Implemented { + if got, err := r.executeStdioInvocationAtEndpoint(context.Background(), "", inv); err != nil || !got.Invocation.Implemented { t.Fatalf("stdio success = %#v, %v", got, err) } RegisterStdioClient("plugin/server-key", client) diff --git a/internal/app/skill_command_full_coverage_test.go b/internal/app/skill_command_full_coverage_test.go index e686867b..a46ac8a7 100644 --- a/internal/app/skill_command_full_coverage_test.go +++ b/internal/app/skill_command_full_coverage_test.go @@ -110,7 +110,7 @@ func TestCrossPlatformCoverageSkillCommandHighLevelRemainingCoverage(t *testing. if err := runSkillAdd(cmd, []string{"id", "target"}); err == nil { t.Fatal("invalid skill target should fail") } - skillResolveTargetPath = func(string) (string, error) { return "dest", nil } + skillResolveTargetPath = func(string) (string, error) { return filepath.Join(t.TempDir(), "dest"), nil } skillLoadAccessToken = func(context.Context) (string, error) { return "", fail } if err := runSkillAdd(cmd, []string{"id", "target"}); !errors.Is(err, fail) { t.Fatalf("skill add auth error = %v", err) @@ -280,7 +280,7 @@ func TestCrossPlatformCoverageSkillCommandLowLevelRemainingCoverage(t *testing.T } skillMkdirAll = func(string, os.FileMode) error { return fail } - if err := extractSkillZip("missing", "dest"); err == nil { + if err := extractSkillZip("missing", filepath.Join(t.TempDir(), "dest")); err == nil { t.Fatal("zip destination mkdir failure should propagate") } zipPath := filepath.Join(t.TempDir(), "files.zip") diff --git a/internal/app/skill_setup_full_coverage_test.go b/internal/app/skill_setup_full_coverage_test.go index a7bace0f..619ce03a 100644 --- a/internal/app/skill_setup_full_coverage_test.go +++ b/internal/app/skill_setup_full_coverage_test.go @@ -61,7 +61,7 @@ func TestCrossPlatformCoverageSkillSetupHighLevelRemainingCoverage(t *testing.T) fail := errors.New("failure") skillSetupResolveMode = func(mode string, _ bool, _ io.Writer) (string, error) { return mode, nil } skillSetupResolveSource = func(string, string) (string, func(), error) { return "source", func() {}, nil } - skillSetupResolveTargets = func(string, string) ([]string, error) { return []string{"dest"}, nil } + skillSetupResolveTargets = func(string, string) ([]string, error) { return []string{filepath.Join(t.TempDir(), "dest")}, nil } skillSetupFilterMulti = func(all, _, _ []string) ([]string, error) { return all, nil } skillSetupListMulti = func(string) ([]string, error) { return nil, fail } @@ -231,18 +231,18 @@ func TestCrossPlatformCoverageSkillSetupLowLevelRemainingCoverage(t *testing.T) } skillSetupMkdirAll = func(string, os.FileMode) error { return fail } - _, skipped, _ = installMultiSkillToHomes("src", []string{"one", "two"}, []string{"dest"}, &out, &errOut) + _, skipped, _ = installMultiSkillToHomes("src", []string{"one", "two"}, []string{filepath.Join(t.TempDir(), "dest")}, &out, &errOut) if skipped != 2 { t.Fatal("multi mkdir failure count mismatch") } skillSetupMkdirAll = func(string, os.FileMode) error { return nil } skillSetupRemoveAll = func(string) error { return fail } - _, skipped, _ = installMultiSkillToHomes("src", []string{"one"}, []string{"dest"}, &out, &errOut) + _, skipped, _ = installMultiSkillToHomes("src", []string{"one"}, []string{filepath.Join(t.TempDir(), "dest")}, &out, &errOut) if skipped != 1 { t.Fatal("multi remove failure count mismatch") } skillSetupRemoveAll = func(string) error { return nil } - _, skipped, _ = installMultiSkillToHomes("src", []string{"one"}, []string{"dest"}, &out, &errOut) + _, skipped, _ = installMultiSkillToHomes("src", []string{"one"}, []string{filepath.Join(t.TempDir(), "dest")}, &out, &errOut) if skipped != 1 { t.Fatal("multi copy failure count mismatch") } diff --git a/internal/app/upgrade_full_coverage_test.go b/internal/app/upgrade_full_coverage_test.go index d5efef13..6711b437 100644 --- a/internal/app/upgrade_full_coverage_test.go +++ b/internal/app/upgrade_full_coverage_test.go @@ -429,7 +429,7 @@ func TestCrossPlatformCoverageUpgradeBinaryHelpersFailureCoverage(t *testing.T) } upgradeMkdirAll = func(string, os.FileMode) error { return nil } upgradeCommandOutput = func(string, ...string) ([]byte, error) { return []byte("tar failed"), fail } - if err := extractTarGz("archive", "dest"); err == nil || !strings.Contains(err.Error(), "tar failed") { + if err := extractTarGz("archive", filepath.Join(t.TempDir(), "dest")); err == nil || !strings.Contains(err.Error(), "tar failed") { t.Fatalf("tar error = %v", err) } upgradeUserHomeDir = func() (string, error) { return "", fail }