diff --git a/.github/badges/coverage.svg b/.github/badges/coverage.svg index 9d8502ef..6b5246fe 100644 --- a/.github/badges/coverage.svg +++ b/.github/badges/coverage.svg @@ -1 +1 @@ -coverage: 54.2%coverage54.2% \ No newline at end of file +coverage: 100.0%coverage100.0% diff --git a/internal/app/access_token_resolve.go b/internal/app/access_token_resolve.go index 99a78ad2..f2e0cda7 100644 --- a/internal/app/access_token_resolve.go +++ b/internal/app/access_token_resolve.go @@ -26,13 +26,29 @@ import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" ) +type legacyTokenGetter interface { + GetToken() (string, string, error) +} + +var ( + newAccessTokenProvider = func(configDir string) accessTokenGetter { + disc := slog.New(slog.NewTextHandler(io.Discard, nil)) + provider := authpkg.NewOAuthProvider(configDir, disc) + configureOAuthProviderCompatibility(provider, configDir) + return provider + } + newLegacyTokenManager = func(configDir string) legacyTokenGetter { + manager := authpkg.NewManager(configDir, nil) + configureLegacyAuthManagerCompatibility(manager) + return manager + } +) + // resolveAccessTokenFromDir loads OAuth then legacy token from configDir, applying // the same host compatibility hooks as MCP. It mirrors the former body of // getCachedRuntimeToken (excluding process-level cache and timing). func resolveAccessTokenFromDir(ctx context.Context, configDir string) (string, error) { - disc := slog.New(slog.NewTextHandler(io.Discard, nil)) - provider := authpkg.NewOAuthProvider(configDir, disc) - configureOAuthProviderCompatibility(provider, configDir) + provider := newAccessTokenProvider(configDir) token, tokenErr := provider.GetAccessToken(ctx) if tokenErr == nil && strings.TrimSpace(token) != "" { return strings.TrimSpace(token), nil @@ -40,8 +56,7 @@ func resolveAccessTokenFromDir(ctx context.Context, configDir string) (string, e if tokenErr != nil && errors.Is(tokenErr, authpkg.ErrTokenDecryption) { return "", tokenErr } - manager := authpkg.NewManager(configDir, nil) - configureLegacyAuthManagerCompatibility(manager) + manager := newLegacyTokenManager(configDir) if leg, _, err := manager.GetToken(); err == nil && strings.TrimSpace(leg) != "" { return strings.TrimSpace(leg), nil } diff --git a/internal/app/api_command.go b/internal/app/api_command.go index a217a029..89074d28 100644 --- a/internal/app/api_command.go +++ b/internal/app/api_command.go @@ -38,6 +38,19 @@ type apiFlags struct { baseURL string } +type appTokenGetter interface { + GetToken(context.Context) (string, error) +} + +var newAppTokenProvider = func(configDir, appKey, appSecret string) appTokenGetter { + return &authpkg.AppTokenProvider{ConfigDir: configDir, AppKey: appKey, AppSecret: appSecret} +} + +var ( + apiClientID = authpkg.ClientID + apiClientSecret = authpkg.ClientSecret +) + // newAPICommand creates the `dws api` subcommand for raw DingTalk OpenAPI calls. func newAPICommand(flags *GlobalFlags) *cobra.Command { af := &apiFlags{} @@ -271,10 +284,7 @@ func parseQueryStringToJSON(rawQuery string) string { return "{}" } - data, err := json.Marshal(paramsMap) - if err != nil { - return "{}" - } + data, _ := json.Marshal(paramsMap) return string(data) } @@ -289,8 +299,8 @@ func resolveRawAPIToken(ctx context.Context, explicitToken string) (string, erro } // Resolve app credentials (clientID/clientSecret). - appKey := authpkg.ClientID() - appSecret := authpkg.ClientSecret() + appKey := apiClientID() + appSecret := apiClientSecret() if appKey == "" || appSecret == "" || strings.HasPrefix(appKey, "<") || strings.HasPrefix(appSecret, "<") { return "", apperrors.NewAuth( @@ -308,11 +318,7 @@ func resolveRawAPIToken(ctx context.Context, explicitToken string) (string, erro // Use AppTokenProvider for automatic caching and refresh. configDir := defaultConfigDir() - provider := &authpkg.AppTokenProvider{ - ConfigDir: configDir, - AppKey: appKey, - AppSecret: appSecret, - } + provider := newAppTokenProvider(configDir, appKey, appSecret) token, err := provider.GetToken(ctx) if err != nil { return "", apperrors.NewAuth(fmt.Sprintf("获取应用级访问令牌失败: %v", err)) diff --git a/internal/app/app_small_coverage_test.go b/internal/app/app_small_coverage_test.go new file mode 100644 index 00000000..2da0c32c --- /dev/null +++ b/internal/app/app_small_coverage_test.go @@ -0,0 +1,390 @@ +package app + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" + "github.com/spf13/cobra" +) + +type appFailWriter struct{ err error } + +func (w appFailWriter) Write([]byte) (int, error) { return 0, w.err } + +type fakeAccessTokenGetter struct { + token string + err error +} + +func (g fakeAccessTokenGetter) GetAccessToken(context.Context) (string, error) { + return g.token, g.err +} + +type fakeLegacyTokenGetter struct { + token string + err error +} + +func (g fakeLegacyTokenGetter) GetToken() (string, string, error) { + return g.token, "test", g.err +} + +type fakeAppTokenGetter struct { + token string + err error +} + +func (g fakeAppTokenGetter) GetToken(context.Context) (string, error) { return g.token, g.err } + +type fakeSkillDirEntry struct{ dir bool } + +func (e fakeSkillDirEntry) Name() string { return "entry" } +func (e fakeSkillDirEntry) IsDir() bool { return e.dir } +func (e fakeSkillDirEntry) Type() os.FileMode { return 0 } +func (e fakeSkillDirEntry) Info() (os.FileInfo, error) { return nil, nil } + +func docPreflightServer(t *testing.T, result map[string]any) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var request struct { + ID int `json:"id"` + } + _ = json.NewDecoder(r.Body).Decode(&request) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "jsonrpc": "2.0", + "id": request.ID, + "result": result, + }) + })) +} + +func TestDocDownloadPreflightCoverage(t *testing.T) { + runner := &runtimeRunner{} + base := executor.Invocation{CanonicalProduct: "doc", Tool: "download_file", Params: map[string]any{"nodeId": " node "}} + if err := runner.preflightDocDownload(context.Background(), transport.NewClient(nil), "", executor.Invocation{}); err != nil { + t.Fatal(err) + } + if err := runner.preflightDocDownload(context.Background(), transport.NewClient(nil), "", executor.Invocation{CanonicalProduct: "DOC", Tool: "download_file"}); err != nil { + t.Fatal(err) + } + if !isDocDownloadInvocation(base) || docDownloadNodeID(map[string]any{"node": " n "}) != "n" || docDownloadNodeID(map[string]any{"dentryUuid": " d "}) != "d" || docDownloadNodeID(map[string]any{"nodeId": 1}) != "" { + t.Fatal("doc download invocation helpers returned unexpected values") + } + if documentInfoExtension(map[string]any{"data": map[string]any{"extension": " doc "}}) != "doc" || + documentInfoExtension(map[string]any{"extension": " pdf "}) != "pdf" || + stringAtPath(map[string]any{"x": "value"}, "x", "nested") != "" || + stringAtPath(map[string]any{"x": 1}, "x") != "" { + t.Fatal("document extension helpers returned unexpected values") + } + if unsupportedAXLSDownloadError() == nil { + t.Fatal("missing AXLS validation error") + } + + oldEdition := edition.Get() + t.Cleanup(func() { edition.Override(oldEdition) }) + hookErr := errors.New("classified") + for _, tc := range []struct { + name string + result map[string]any + hooks *edition.Hooks + want string + }{ + {name: "ok", result: map[string]any{"content": map[string]any{"result": map[string]any{"extension": "docx"}}}, hooks: &edition.Hooks{}}, + {name: "edition classifier", result: map[string]any{"content": map[string]any{}}, hooks: &edition.Hooks{ClassifyToolResult: func(map[string]any) error { return hookErr }}, want: "classified"}, + {name: "pat", result: map[string]any{"content": map[string]any{"errorCode": "PAT_NO_PERMISSION"}}, hooks: &edition.Hooks{}, want: "PAT_NO_PERMISSION"}, + {name: "mcp error", result: map[string]any{"isError": true, "content": []map[string]any{{"type": "text", "text": "mcp failed"}}}, hooks: &edition.Hooks{}, want: "mcp failed"}, + {name: "business error", result: map[string]any{"content": map[string]any{"success": false, "errorMsg": "business failed"}}, hooks: &edition.Hooks{}, want: "business failed"}, + {name: "axls", result: map[string]any{"content": map[string]any{"data": map[string]any{"extension": "AXLS"}}}, hooks: &edition.Hooks{}, want: "extension=axls"}, + } { + t.Run(tc.name, func(t *testing.T) { + edition.Override(tc.hooks) + server := docPreflightServer(t, tc.result) + defer server.Close() + client := transport.NewClient(nil) + client.TrustedDomains = []string{"127.0.0.1"} + err := runner.preflightDocDownload(context.Background(), client, server.URL, base) + if tc.want == "" && err != nil { + t.Fatalf("preflight error = %v", err) + } + if tc.want != "" && (err == nil || !strings.Contains(err.Error(), tc.want)) { + t.Fatalf("preflight error = %v, want %q", err, tc.want) + } + }) + } + server := docPreflightServer(t, map[string]any{}) + endpoint := server.URL + server.Close() + client := transport.NewClient(nil) + client.TrustedDomains = []string{"127.0.0.1"} + client.MaxRetries = 0 + if err := runner.preflightDocDownload(context.Background(), client, endpoint, base); err == nil { + t.Fatal("network preflight failure succeeded") + } +} + +func TestRootHelpRemainingCoverage(t *testing.T) { + configureRootHelp(nil) + renderRootGlobalFlags(nil) + if visiblePersistentFlags(nil) != nil || formatRootFlag(nil) != "" || commandShort(nil) != "" || visibleMCPRootCommands(nil) != nil || visibleUtilityRootCommands(nil) != nil { + t.Fatal("nil root helper contract changed") + } + + oldEdition := edition.Get() + edition.Override(&edition.Hooks{VisibleProducts: func() []string { return []string{"service"} }}) + t.Cleanup(func() { edition.Override(oldEdition); SetDynamicServers(nil) }) + root := &cobra.Command{Use: "root", Long: "long help"} + root.SetOut(io.Discard) + root.PersistentFlags().StringP("value", "x", "", "value") + root.PersistentFlags().Bool("hidden", false, "hidden") + _ = root.PersistentFlags().MarkHidden("hidden") + root.AddCommand(&cobra.Command{Use: "service", Short: "service"}, &cobra.Command{Use: "utility", Short: "utility"}) + configureRootHelp(root) + if err := root.Commands()[0].Help(); err != nil { + t.Fatal(err) + } + root.SetArgs([]string{"help", "missing"}) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + root.SetArgs([]string{"help", "utility"}) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + if got := formatRootFlag(root.PersistentFlags().Lookup("value")); !strings.Contains(got, "-x") { + t.Fatalf("formatted flag = %q", got) + } + if got := formatRootFlag(root.PersistentFlags().Lookup("hidden")); !strings.Contains(got, "--hidden") { + t.Fatalf("formatted long flag = %q", got) + } + _ = commandShort(&cobra.Command{Use: "help", Short: "Help about any command"}) + renderRootGlobalFlags(&cobra.Command{Use: "no-flags"}) + edition.Override(&edition.Hooks{}) + SetDynamicServers(nil) + _ = visibleMCPRootCommands(root) +} + +func TestConfigAndTokenSeamsCoverage(t *testing.T) { + oldHome, oldExe, oldEval := userHomeDir, executablePath, evaluateSymlink + oldEdition := edition.Get() + t.Cleanup(func() { + userHomeDir, executablePath, evaluateSymlink = oldHome, oldExe, oldEval + edition.Override(oldEdition) + }) + t.Setenv("DWS_CONFIG_DIR", "") + edition.Override(&edition.Hooks{}) + userHomeDir = func() (string, error) { return "", errors.New("home") } + executablePath = func() (string, error) { return "", errors.New("exe") } + if got := defaultConfigDir(); got != ".dws" { + t.Fatalf("fallback config dir = %q", got) + } + executablePath = func() (string, error) { return filepath.Join("", "tmp", "dws"), nil } + evaluateSymlink = func(string) (string, error) { return "", errors.New("link") } + if got := exeRelativeConfigDir(); !strings.HasSuffix(got, filepath.Join("tmp", ".dws")) { + t.Fatalf("executable config dir = %q", got) + } + userHomeDir = func() (string, error) { return "/home/test", nil } + if got := defaultConfigDir(); got != filepath.Join("/home/test", ".dws") { + t.Fatalf("home config dir = %q", got) + } + edition.Override(&edition.Hooks{ConfigDir: func() string { return "/edition" }}) + if got := defaultConfigDir(); got != "/edition" { + t.Fatalf("edition config dir = %q", got) + } + + oldProvider, oldManager := newAccessTokenProvider, newLegacyTokenManager + t.Cleanup(func() { newAccessTokenProvider, newLegacyTokenManager = oldProvider, oldManager }) + newAccessTokenProvider = func(string) accessTokenGetter { return fakeAccessTokenGetter{err: authpkg.ErrTokenDecryption} } + if _, err := resolveAccessTokenFromDir(context.Background(), "unused"); !errors.Is(err, authpkg.ErrTokenDecryption) { + t.Fatalf("decryption error = %v", err) + } + newAccessTokenProvider = func(string) accessTokenGetter { return fakeAccessTokenGetter{err: errors.New("missing")} } + newLegacyTokenManager = func(string) legacyTokenGetter { return fakeLegacyTokenGetter{token: " legacy "} } + if got, err := resolveAccessTokenFromDir(context.Background(), "unused"); err != nil || got != "legacy" { + t.Fatalf("legacy token = %q, %v", got, err) + } + newAccessTokenProvider = func(string) accessTokenGetter { return fakeAccessTokenGetter{err: errors.New("load")} } + newLegacyTokenManager = func(string) legacyTokenGetter { return fakeLegacyTokenGetter{err: errors.New("missing")} } + edition.Override(&edition.Hooks{}) + other := filepath.Join(t.TempDir(), "other") + if _, err := ResolveAuxiliaryAccessToken(context.Background(), other, ""); err == nil { + t.Fatal("auxiliary provider failure succeeded") + } + newAccessTokenProvider = func(string) accessTokenGetter { return fakeAccessTokenGetter{err: authpkg.ErrTokenDecryption} } + if _, err := ResolveAuxiliaryAccessToken(context.Background(), other, ""); !errors.Is(err, authpkg.ErrTokenDecryption) { + t.Fatalf("auxiliary decryption error = %v", err) + } + t.Setenv("DWS_CONFIG_DIR", other) + ResetRuntimeTokenCache() + if _, err := ResolveAuxiliaryAccessToken(context.Background(), other, ""); err == nil { + t.Fatal("current config without credentials succeeded") + } + edition.Override(&edition.Hooks{IsEmbedded: true}) + if !strings.Contains(noCredentialsError().Error(), "认证") { + t.Fatal("embedded credentials error changed") + } +} + +func TestForceRefreshAndStdioFailureCoverage(t *testing.T) { + oldMark, oldFactory := markAccessTokenStale, newRefreshProvider + oldStop := stopStdio + t.Cleanup(func() { + markAccessTokenStale, newRefreshProvider = oldMark, oldFactory + stopStdio = oldStop + stdioMu.Lock() + stdioClients = make(map[string]*transport.StdioClient) + stdioMu.Unlock() + }) + fail := errors.New("failure") + _ = oldFactory(t.TempDir()) + markAccessTokenStale = func(string) error { return fail } + if _, err := ForceRefreshAccessToken(context.Background(), "config"); !errors.Is(err, fail) { + t.Fatalf("mark stale error = %v", err) + } + markAccessTokenStale = func(string) error { return nil } + for _, tc := range []struct { + getter fakeAccessTokenGetter + want string + }{ + {getter: fakeAccessTokenGetter{err: fail}, want: "failure"}, + {getter: fakeAccessTokenGetter{token: " "}, want: "empty"}, + {getter: fakeAccessTokenGetter{token: " refreshed "}}, + } { + newRefreshProvider = func(string) accessTokenGetter { return tc.getter } + got, err := ForceRefreshAccessToken(context.Background(), "config") + if tc.want != "" && (err == nil || !strings.Contains(err.Error(), tc.want)) { + t.Fatalf("refresh error = %v, want %q", err, tc.want) + } + if tc.want == "" && (err != nil || got != "refreshed") { + t.Fatalf("refreshed token = %q, %v", got, err) + } + } + + stopStdio = func(*transport.StdioClient) error { return fail } + RegisterStdioClient("all", transport.NewStdioClient("unused", nil, nil)) + StopAllStdioClients() + RegisterStdioClient("one", transport.NewStdioClient("unused", nil, nil)) + if !StopStdioClient("one") { + t.Fatal("registered stdio client not stopped") + } + RegisterStdioClient("plugin/server", transport.NewStdioClient("unused", nil, nil)) + if got := StopStdioClientsByPlugin("plugin"); got != 1 { + t.Fatalf("stopped plugin clients = %d", got) + } +} + +func TestOverlayRecoveryHostAndHelperRemainingCoverage(t *testing.T) { + root := t.TempDir() + writeOverlay := filepath.Join(root, "overlay.json") + if err := os.WriteFile(writeOverlay, []byte(`{"toolOverrides":{"tool":{}}}`), 0o600); err != nil { + t.Fatal(err) + } + for _, raw := range []json.RawMessage{ + json.RawMessage(`"missing.json"`), + json.RawMessage(`"unterminated`), + json.RawMessage(`{`), + json.RawMessage(`"overlay.json"`), + json.RawMessage(`{"id":"","command":"","toolOverrides":{"tool":{}}}`), + } { + p := &plugin.Plugin{Root: root, Manifest: plugin.Manifest{Name: "plugin", Description: "description", MCPServers: map[string]*plugin.MCPServer{"server": {CLI: raw}}}} + overlay := resolveStdioOverlay(p, plugin.StdioServerClient{Key: "server", Client: transport.NewStdioClient("unused", nil, nil)}) + if overlay.ID == "" || overlay.Command == "" { + t.Fatalf("overlay defaults missing: %#v", overlay) + } + } + p := &plugin.Plugin{Root: root, Manifest: plugin.Manifest{Name: "plugin", Description: "description", MCPServers: map[string]*plugin.MCPServer{"server": {CLI: json.RawMessage(`{}`)}}}} + if descriptor := registerStdioServerFromManifest(p, plugin.StdioServerClient{Key: "server"}); descriptor.Endpoint == "" { + t.Fatalf("empty overlay descriptor = %#v", descriptor) + } + p.Manifest.MCPServers["server"].CLI = json.RawMessage(`{"toolOverrides":{"tool":{}}}`) + if descriptor := registerStdioServerFromManifest(p, plugin.StdioServerClient{Key: "server", Client: transport.NewStdioClient("unused", nil, nil)}); descriptor.Endpoint == "" { + t.Fatalf("stdio overlay registration = %#v", descriptor) + } + + oldEdition := edition.Get() + t.Cleanup(func() { edition.Override(oldEdition); SetDynamicServers(nil) }) + edition.Override(&edition.Hooks{ConfigDir: func() string { return "" }}) + captureRuntimeFailure(executor.Invocation{}, nil, nil) + captureRuntimeFailure(executor.Invocation{}, errors.New("raw"), nil) + oldArgs := os.Args + os.Args = []string{"dws", "doc", "download", "--node", "n"} + if got := runtimeCommandPath(executor.Invocation{}); len(got) != 2 { + t.Fatalf("runtime command path = %#v", got) + } + os.Args = oldArgs + + t.Setenv(authpkg.AgentCodeEnv, "") + if hostControlProviderFromEnv() != "" { + t.Fatal("host control enabled without agent code") + } + t.Setenv(authpkg.AgentCodeEnv, "agent") + edition.Override(&edition.Hooks{MergeHeaders: func(headers map[string]string) map[string]string { return headers }}) + if got := hostControlProviderFromEnv(); got != edition.DefaultOSSClawType { + t.Fatalf("default claw type = %q", got) + } + edition.Override(&edition.Hooks{MergeHeaders: func(map[string]string) map[string]string { return map[string]string{"claw-type": "custom"} }}) + if got := effectiveClawType(); got != "custom" { + t.Fatalf("custom claw type = %q", got) + } +} + +func TestConfigAndCacheCommandRemainingCoverage(t *testing.T) { + for _, command := range []*cobra.Command{newConfigCommand(), newCacheCommand()} { + command.SetOut(io.Discard) + if err := command.RunE(command, nil); err != nil { + t.Fatal(err) + } + } + t.Setenv("DWS_CONFIG_DIR", "configured") + configCmd := &cobra.Command{Use: "config"} + var configOut bytes.Buffer + configCmd.SetOut(&configOut) + if err := writeConfigJSON(configCmd, filterVisible(nil), true); err != nil { + t.Fatal(err) + } + list := newConfigListCommand() + list.SetOut(io.Discard) + _ = list.Flags().Set("category", "core") + _ = list.Flags().Set("show-values", "true") + _ = list.Flags().Set("show-hidden", "true") + _ = list.Flags().Set("json", "true") + if err := runConfigList(list, nil); err != nil { + t.Fatal(err) + } + + cacheRoot := &cobra.Command{Use: "root"} + cacheRoot.PersistentFlags().String("format", "", "") + cacheCmd := &cobra.Command{Use: "cache"} + cacheRoot.AddCommand(cacheCmd) + for _, format := range []string{"json", "pretty", "table"} { + _ = cacheRoot.PersistentFlags().Set("format", format) + cacheCmd.SetOut(io.Discard) + if err := printCacheCompatNotice(cacheCmd, "status"); err != nil { + t.Fatal(err) + } + } + fail := errors.New("write") + cacheCmd.SetOut(appFailWriter{err: fail}) + _ = cacheRoot.PersistentFlags().Set("format", "pretty") + if err := printCacheCompatNotice(cacheCmd, "status"); !errors.Is(err, fail) { + t.Fatalf("pretty write error = %v", err) + } + _ = cacheRoot.PersistentFlags().Set("format", "table") + if err := printCacheCompatNotice(cacheCmd, "status"); !errors.Is(err, fail) { + t.Fatalf("table write error = %v", err) + } +} diff --git a/internal/app/app_tiny_coverage_test.go b/internal/app/app_tiny_coverage_test.go new file mode 100644 index 00000000..81e86799 --- /dev/null +++ b/internal/app/app_tiny_coverage_test.go @@ -0,0 +1,282 @@ +package app + +import ( + "context" + "errors" + "io" + "io/fs" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/apiclient" + authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes" + "github.com/spf13/cobra" +) + +func TestAPIAndTimingRemainingCoverage(t *testing.T) { + oldProvider := newAppTokenProvider + oldClientID, oldClientSecret := apiClientID, apiClientSecret + oldMarshal, oldMkdir := timingMarshalIndent, timingMkdirAll + oldWrite, oldRemove, oldRename := timingWriteFile, timingRemove, timingRename + oldRead, oldHome := timingReadFile, timingUserHomeDir + t.Cleanup(func() { + newAppTokenProvider = oldProvider + apiClientID, apiClientSecret = oldClientID, oldClientSecret + timingMarshalIndent, timingMkdirAll = oldMarshal, oldMkdir + timingWriteFile, timingRemove, timingRename = oldWrite, oldRemove, oldRename + timingReadFile, timingUserHomeDir = oldRead, oldHome + authpkg.SetClientID("") + authpkg.SetClientSecret("") + }) + fail := errors.New("failure") + apiClientID = func() string { return "" } + apiClientSecret = func() string { return "" } + if _, err := resolveRawAPIToken(context.Background(), ""); err == nil { + t.Fatal("missing raw API credentials succeeded") + } + apiClientID = func() string { return "" } + apiClientSecret = func() string { return "secret" } + if _, err := resolveRawAPIToken(context.Background(), ""); err == nil { + t.Fatal("placeholder raw API credentials succeeded") + } + apiClientID, apiClientSecret = authpkg.ClientID, authpkg.ClientSecret + authpkg.SetClientID("app-key") + authpkg.SetClientSecret("app-secret") + for _, tc := range []struct { + getter fakeAppTokenGetter + want string + }{ + {getter: fakeAppTokenGetter{err: fail}, want: "failure"}, + {getter: fakeAppTokenGetter{token: " "}, want: "为空"}, + {getter: fakeAppTokenGetter{token: " token "}}, + } { + newAppTokenProvider = func(string, string, string) appTokenGetter { return tc.getter } + got, err := resolveRawAPIToken(context.Background(), "") + if tc.want != "" && (err == nil || !containsText(err.Error(), tc.want)) { + t.Fatalf("raw token error = %v, want %q", err, tc.want) + } + if tc.want == "" && (err != nil || got != "token") { + t.Fatalf("raw token = %q, %v", got, err) + } + } + server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + endpoint := server.URL + server.Close() + apiclient.AllowedHosts["127.0.0.1"] = true + t.Cleanup(func() { delete(apiclient.AllowedHosts, "127.0.0.1") }) + cmd := &cobra.Command{Use: "api"} + cmd.SetContext(context.Background()) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + if err := runAPI(cmd, []string{"GET", "/path"}, &GlobalFlags{Token: "token", Timeout: 1}, &apiFlags{baseURL: endpoint}); err == nil { + t.Fatal("closed raw API endpoint succeeded") + } + + collector := NewTimingCollector() + collector.Print(io.Discard) + t.Setenv(PerfReportEnv, t.TempDir()+"/report.json") + timingMarshalIndent = func(any, string, string) ([]byte, error) { return nil, fail } + collector.WriteReportIfEnabled("v", "cmd") + timingMarshalIndent = oldMarshal + timingUserHomeDir = func() (string, error) { return "", fail } + t.Setenv(PerfReportEnv, "auto") + collector.WriteReportIfEnabled("v", "cmd") + if defaultPerfReportPath() != "" { + t.Fatal("home-dir failure produced a report path") + } + t.Setenv(PerfReportEnv, t.TempDir()+"/report.json") + timingMkdirAll = func(string, os.FileMode) error { return fail } + collector.WriteReportIfEnabled("v", "cmd") + timingMkdirAll = oldMkdir + timingWriteFile = func(string, []byte, os.FileMode) error { return fail } + removed := false + timingRemove = func(string) error { removed = true; return nil } + collector.WriteReportIfEnabled("v", "cmd") + if !removed { + t.Fatal("failed temporary report was not removed") + } + timingWriteFile = oldWrite + timingRemove = oldRemove + renamed := false + timingRename = func(string, string) error { renamed = true; return fail } + collector.WriteReportIfEnabled("v", "cmd") + if !renamed { + t.Fatal("report rename was not attempted") + } + timingReadFile = func(string) ([]byte, error) { return []byte("{"), nil } + if _, err := LoadLatestReport(); err == nil { + t.Fatal("malformed performance report succeeded") + } +} + +func TestDirectRuntimeRemainingCoverage(t *testing.T) { + oldEdition := edition.Get() + t.Cleanup(func() { + edition.Override(oldEdition) + SetDynamicServers(nil) + }) + configDir := t.TempDir() + t.Setenv("DWS_CONFIG_DIR", configDir) + for _, raw := range []string{ + "not a url", + "https://mcp.dingtalk.com/path?q=1#fragment", + "https://pre-mcp.example.test:8443/path/", + "https://mcp.example.test/path/", + } { + if err := os.WriteFile(filepath.Join(configDir, "mcp_url"), []byte(raw), 0o600); err != nil { + t.Fatal(err) + } + if got := defaultPATGatewayBaseURL(); got == "" { + t.Fatalf("gateway for %q is blank", raw) + } + } + + endpoints := map[string]string{} + products := map[string]bool{} + aliases := map[string]string{} + tools := map[string]string{} + registerDynamicServer(mcptypes.ServerDescriptor{CLI: mcptypes.CLIOverlay{Skip: true}}, endpoints, products, aliases, tools) + registerDynamicServer(mcptypes.ServerDescriptor{ + Endpoint: "https://server.test", + CLI: mcptypes.CLIOverlay{ + ID: "id", Command: "command", Aliases: []string{"alias", " "}, + Tools: []mcptypes.CLITool{{Name: "tool"}, {Name: " "}}, + ToolOverrides: map[string]mcptypes.CLIToolOverride{"override": {}, " ": {}}, + }, + }, endpoints, products, aliases, tools) + if endpoints["command"] == "" || aliases["alias"] != "id" || tools["override"] == "" { + t.Fatalf("registered dynamic server = %#v %#v %#v", endpoints, aliases, tools) + } + + SetDynamicServers(nil) + dynamicMu.Lock() + dynamicEndpoints = map[string]string{} + dynamicProducts = map[string]bool{} + dynamicAliases = map[string]string{} + dynamicToolEndpoints = map[string]string{} + dynamicMu.Unlock() + if got, ok := directRuntimeEndpoint(defaultPATProductID, ""); !ok || got == "" { + t.Fatal("cold-start PAT fallback did not resolve") + } + SetDynamicServers(nil) + if _, ok := directRuntimeEndpoint(" ", " "); ok { + t.Fatal("blank runtime endpoint resolved") + } + t.Setenv("DINGTALK_CUSTOM_MCP_URL", "https://override.test") + if got, ok := directRuntimeEndpoint("custom", ""); !ok || got != "https://override.test" { + t.Fatalf("environment runtime endpoint = %q, %v", got, ok) + } + if got, ok := directRuntimeEndpoint(devappProductID, ""); !ok || got == "" { + t.Fatal("devapp fallback did not resolve") + } + if got, ok := directRuntimeEndpoint(defaultPATProductID, ""); !ok || got == "" { + t.Fatal("PAT fallback did not resolve") + } + edition.Override(&edition.Hooks{ + StaticServers: func() []edition.ServerInfo { return []edition.ServerInfo{{ID: "other", Endpoint: ""}} }, + SupplementServers: func() []edition.ServerInfo { + return []edition.ServerInfo{{ID: "other", Endpoint: "https://other.test", Prefixes: []string{" ", "wanted"}}} + }, + }) + if got, ok := directRuntimeEndpoint("wanted", ""); !ok || got != "https://other.test" { + t.Fatalf("edition runtime endpoint = %q, %v", got, ok) + } + + dynamicMu.Lock() + dynamicEndpoints, dynamicProducts, dynamicAliases, dynamicToolEndpoints = nil, nil, nil, nil + dynamicMu.Unlock() + AppendDynamicServer(mcptypes.ServerDescriptor{ + Endpoint: "https://append.test", + CLI: mcptypes.CLIOverlay{ + ID: "append", Command: "append-command", Aliases: []string{"append-alias"}, + Tools: []mcptypes.CLITool{{Name: "append-tool"}}, + ToolOverrides: map[string]mcptypes.CLIToolOverride{ + "append-override": {}, "skip": {ServerOverride: "other"}, " ": {}, + }, + }, + }) + if got, ok := directRuntimeToolEndpoint("append-override"); !ok || got != "https://append.test" { + t.Fatalf("append override endpoint = %q, %v", got, ok) + } +} + +func containsText(value, substring string) bool { + for i := 0; i+len(substring) <= len(value); i++ { + if value[i:i+len(substring)] == substring { + return true + } + } + return false +} + +func TestEmbeddedSkillAndTinyCommandsRemainingCoverage(t *testing.T) { + oldStat, oldTemp, oldRemove := embeddedSkillStat, embeddedSkillMkdirTemp, embeddedSkillRemoveAll + oldWalk, oldRead := embeddedSkillWalkDir, embeddedSkillReadFile + oldMkdir, oldWrite := embeddedSkillMkdirAll, embeddedSkillWriteFile + t.Cleanup(func() { + embeddedSkillStat, embeddedSkillMkdirTemp, embeddedSkillRemoveAll = oldStat, oldTemp, oldRemove + embeddedSkillWalkDir, embeddedSkillReadFile = oldWalk, oldRead + embeddedSkillMkdirAll, embeddedSkillWriteFile = oldMkdir, oldWrite + }) + fail := errors.New("failure") + embeddedSkillStat = func(string) (os.FileInfo, error) { return nil, nil } + embeddedSkillMkdirTemp = func(string, string) (string, error) { return "", fail } + if _, _, err := materializeEmbeddedSkillSource("codex"); !errors.Is(err, fail) { + t.Fatalf("embedded mkdir error = %v", err) + } + embeddedSkillMkdirTemp = func(string, string) (string, error) { return t.TempDir(), nil } + removed := false + embeddedSkillRemoveAll = func(string) error { removed = true; return nil } + embeddedSkillWalkDir = func(_ string, fn fs.WalkDirFunc) error { + return fn("entry", nil, fail) + } + if _, _, err := materializeEmbeddedSkillSource("codex"); !errors.Is(err, fail) || !removed { + t.Fatalf("embedded walk error = %v, removed=%v", err, removed) + } + embeddedSkillWalkDir = func(_ string, fn fs.WalkDirFunc) error { + return fn("skills/codex/file", fakeSkillDirEntry{}, nil) + } + embeddedSkillReadFile = func(string) ([]byte, error) { return nil, fail } + if _, _, err := materializeEmbeddedSkillSource("codex"); !errors.Is(err, fail) { + t.Fatalf("embedded read error = %v", err) + } + embeddedSkillReadFile = func(string) ([]byte, error) { return []byte("skill"), nil } + embeddedSkillMkdirAll = func(string, os.FileMode) error { return fail } + if _, _, err := materializeEmbeddedSkillSource("codex"); !errors.Is(err, fail) { + t.Fatalf("embedded nested mkdir error = %v", err) + } + embeddedSkillWalkDir = func(_ string, fn fs.WalkDirFunc) error { + return fn("skills/codex/dir", fakeSkillDirEntry{dir: true}, nil) + } + if _, _, err := materializeEmbeddedSkillSource("codex"); !errors.Is(err, fail) { + t.Fatalf("embedded directory mkdir error = %v", err) + } + embeddedSkillWalkDir = func(_ string, fn fs.WalkDirFunc) error { + return fn("skills/codex/file", fakeSkillDirEntry{}, nil) + } + embeddedSkillMkdirAll = func(string, os.FileMode) error { return nil } + embeddedSkillWriteFile = func(string, []byte, os.FileMode) error { return fail } + if _, _, err := materializeEmbeddedSkillSource("codex"); !errors.Is(err, fail) { + t.Fatalf("embedded write error = %v", err) + } + + merged := mergeTopLevelCommands([]*cobra.Command{nil, {}}) + if len(merged) != 0 { + t.Fatalf("empty legacy commands = %#v", merged) + } + root := &cobra.Command{Use: "root"} + completion := newCompletionCommand(root) + if err := completion.RunE(completion, []string{"other"}); err != nil { + t.Fatal(err) + } + catalog := newCatalogCommand(nil) + catalog.SetOut(io.Discard) + if err := catalog.RunE(catalog, nil); err != nil { + t.Fatal(err) + } +} diff --git a/internal/app/auth_command.go b/internal/app/auth_command.go index d61d630c..b4c107c3 100644 --- a/internal/app/auth_command.go +++ b/internal/app/auth_command.go @@ -136,7 +136,7 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command { AccessToken: cfg.Token, ExpiresAt: time.Now().Add(config.ManualTokenExpiry), } - if err := authpkg.SaveTokenData(configDir, tokenData); err != nil { + if err := authSaveTokenData(configDir, tokenData); err != nil { return apperrors.NewInternal(fmt.Sprintf("failed to persist auth token: %v", err)) } case cfg.Device: @@ -146,7 +146,7 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command { provider := authpkg.NewDeviceFlowProvider(configDir, nil) provider.Output = cmd.ErrOrStderr() provider.NoBrowser, _ = cmd.Flags().GetBool("no-browser") - tokenData, err = provider.Login(loginCtx) + tokenData, err = authDeviceLogin(provider, loginCtx) if err != nil { return apperrors.NewAuth(fmt.Sprintf("device authorization failed: %v", err)) } @@ -159,7 +159,7 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command { provider.NoBrowser, _ = cmd.Flags().GetBool("no-browser") provider.TargetCorpID = cfg.TargetCorpID configureOAuthProviderCompatibility(provider, configDir) - tokenData, err = provider.Login(loginCtx, authLoginForcesAuthorization(cfg)) + tokenData, err = authOAuthLogin(provider, loginCtx, authLoginForcesAuthorization(cfg)) if err != nil { return apperrors.NewAuth(fmt.Sprintf("dingtalk login failed: %v", err)) } @@ -182,7 +182,7 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command { var initialPlan *pat.LoginRecommendPlan if postLoginTUIMode { var planErr error - initialPlan, planErr = pat.PlanLoginRecommendAuthorization(cmd.Context(), patCaller) + initialPlan, planErr = authPlanLoginRecommend(cmd.Context(), patCaller) if planErr != nil { return planErr } @@ -207,11 +207,11 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command { retryFormat = "table" } run := func(ctx context.Context) error { - return pat.RunLoginRecommendAuthorizationWithOptions(ctx, patCaller, cmd.ErrOrStderr(), opts) + return authRunLoginRecommend(ctx, patCaller, cmd.ErrOrStderr(), opts) } err := run(cmd.Context()) if patErr := apperrors.AsPatAuthCheckError(err); patErr != nil { - return runDirectPATAuthCheckWaitOnly( + return authRunDirectPATWait( cmd.Context(), &GlobalFlags{Format: retryFormat}, patErr, @@ -234,11 +234,7 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command { return err } fmt.Fprintln(w) - if !cfg.Device && tokenData != nil && tokenData.IsAccessTokenValid() && !authLoginForcesAuthorization(cfg) { - fmt.Fprintln(w, authLoginStatusLine("Token 有效,无需重新登录")) - } else { - fmt.Fprintln(w, authLoginStatusLine("登录成功!")) - } + fmt.Fprintln(w, authLoginStatusLine("登录成功!")) if tokenData != nil { if tokenData.CorpName != "" { fmt.Fprintln(w, authLoginInfoLine("企业", tokenData.CorpName)) @@ -286,6 +282,42 @@ var ( loginRecommendProductSelector = selectLoginRecommendProducts authLoginInteractiveTerminal = isInteractiveTerminal migrateKeychainToFileDEK = authpkg.MigrateKeychainToFileDEK + authRunForm = (*huh.Form).Run + authSaveTokenData = authpkg.SaveTokenData + authSaveAppConfig = authpkg.SaveAppConfig + authDeviceLogin = func(provider *authpkg.DeviceFlowProvider, ctx context.Context) (*authpkg.TokenData, error) { + return provider.Login(ctx) + } + authOAuthLogin = func(provider *authpkg.OAuthProvider, ctx context.Context, force bool) (*authpkg.TokenData, error) { + return provider.Login(ctx, force) + } + authOAuthStatus = func(provider *authpkg.OAuthProvider) (*authpkg.TokenData, error) { return provider.Status() } + authOAuthAccessToken = func(provider *authpkg.OAuthProvider, ctx context.Context) (string, error) { + return provider.GetAccessToken(ctx) + } + authOAuthExchange = func(provider *authpkg.OAuthProvider, ctx context.Context, code, uid string) (*authpkg.TokenData, error) { + return provider.ExchangeAuthCode(ctx, code, uid) + } + authPlanLoginRecommend = pat.PlanLoginRecommendAuthorization + authRunLoginRecommend = pat.RunLoginRecommendAuthorizationWithOptions + authRunDirectPATWait = runDirectPATAuthCheckWaitOnly + authResolveProfile = authpkg.ResolveProfile + authRevokeToken = authpkg.RevokeTokenRemote + authDeleteProfileToken = authpkg.DeleteTokenDataForProfile + authEnsureProfilesMigration = authpkg.EnsureProfilesMigration + authLoadProfiles = authpkg.LoadProfiles + authDeleteAllTokenData = authpkg.DeleteAllTokenData + authDeleteTokenData = authpkg.DeleteTokenData + authMarkProfileStatus = authpkg.MarkProfileStatus + authPortableExportSupported = authpkg.PortableExportSupported + authPortableSourceReady = authpkg.PortableAuthSourceReady + authPortableTargetPopulated = authpkg.PortableAuthTargetPopulated + authExportPortableBundle = authpkg.ExportPortableAuthBundle + authImportPortableBundle = authpkg.ImportPortableAuthBundle + authAtomicWrite = helpers.AtomicWrite + authReadFile = os.ReadFile + authRemove = os.Remove + authDeleteAppConfig = authpkg.DeleteAppConfig ) func selectAuthLoginGuideAction() (authLoginGuideAction, error) { @@ -302,7 +334,7 @@ func selectAuthLoginGuideAction() (authLoginGuideAction, error) { Value(&choice), ), ).WithTheme(authLoginHuhTheme()) - if err := form.Run(); err != nil { + if err := authRunForm(form); err != nil { return "", fmt.Errorf("使用引导选择中止: %w", err) } return choice, nil @@ -322,7 +354,7 @@ func applyAuthLoginGuideAction(cmd *cobra.Command, configDir string, action auth } authpkg.SetClientID(clientID) authpkg.SetClientSecret(clientSecret) - if err := authpkg.SaveAppConfig(configDir, &authpkg.AppConfig{ + if err := authSaveAppConfig(configDir, &authpkg.AppConfig{ ClientID: clientID, ClientSecret: authpkg.PlainSecret(clientSecret), }); err != nil { @@ -336,33 +368,34 @@ func applyAuthLoginGuideAction(cmd *cobra.Command, configDir string, action auth func promptAuthLoginManualCredentials() (string, string, error) { var clientID, clientSecret string - nonEmpty := func(label string) func(string) error { - return func(value string) error { - if strings.TrimSpace(value) == "" { - return fmt.Errorf("%s 不能为空", label) - } - return nil - } - } form := huh.NewForm( huh.NewGroup( huh.NewInput(). Title("输入 AppKey"). Value(&clientID). - Validate(nonEmpty("AppKey")), + Validate(authLoginNonEmpty("AppKey")), huh.NewInput(). Title("输入 AppSecret"). EchoMode(huh.EchoModePassword). Value(&clientSecret). - Validate(nonEmpty("AppSecret")), + Validate(authLoginNonEmpty("AppSecret")), ), ).WithTheme(authLoginHuhTheme()) - if err := form.Run(); err != nil { + if err := authRunForm(form); err != nil { return "", "", fmt.Errorf("应用凭证输入中止: %w", err) } return strings.TrimSpace(clientID), strings.TrimSpace(clientSecret), nil } +func authLoginNonEmpty(label string) func(string) error { + return func(value string) error { + if strings.TrimSpace(value) == "" { + return fmt.Errorf("%s 不能为空", label) + } + return nil + } +} + func selectLoginRecommendScopeMode() (pat.LoginRecommendScopeMode, error) { choice := pat.LoginRecommendScopeRecommended form := huh.NewForm( @@ -377,7 +410,7 @@ func selectLoginRecommendScopeMode() (pat.LoginRecommendScopeMode, error) { Value(&choice), ), ).WithTheme(authLoginHuhTheme()) - if err := form.Run(); err != nil { + if err := authRunForm(form); err != nil { return "", fmt.Errorf("授权范围选择中止: %w", err) } return choice, nil @@ -452,21 +485,21 @@ func newAuthStatusCommand() *cobra.Command { var statusErr error provider := authpkg.NewOAuthProvider(configDir, nil) configureOAuthProviderCompatibility(provider, configDir) - if data, err := provider.Status(); err == nil { + if data, err := authOAuthStatus(provider); err == nil { tokenData = data if !data.IsAccessTokenValid() && data.IsRefreshTokenValid() { refreshCtx, cancel := context.WithTimeout(cmd.Context(), 15*time.Second) - _, refreshErr := provider.GetAccessToken(refreshCtx) + _, refreshErr := authOAuthAccessToken(provider, refreshCtx) cancel() if refreshErr == nil { - if updatedData, statusErr := provider.Status(); statusErr == nil { + if updatedData, statusErr := authOAuthStatus(provider); statusErr == nil { tokenData = updatedData refreshed = true } } else if edition.Get().AutoPurgeToken { - _ = authpkg.DeleteTokenData(configDir) + _ = authDeleteTokenData(configDir) } else if tokenData != nil { - _ = authpkg.MarkProfileStatus(configDir, tokenData.CorpID, authpkg.ProfileStatusExpired) + _ = authMarkProfileStatus(configDir, tokenData.CorpID, authpkg.ProfileStatusExpired) } } if authStatusAuthenticated(tokenData) { @@ -584,36 +617,36 @@ func newAuthMigrateKeychainCommand() *cobra.Command { } func logoutOneProfile(_ *cobra.Command, ctx context.Context, configDir, selector string) error { - if _, err := authpkg.ResolveProfile(configDir, selector); err != nil { + if _, err := authResolveProfile(configDir, selector); err != nil { return apperrors.NewValidation(err.Error()) } restoreProfile := pushRuntimeProfile(selector) defer restoreProfile() - _ = authpkg.RevokeTokenRemote(ctx) - if err := authpkg.DeleteTokenDataForProfile(configDir, selector); err != nil { + _ = authRevokeToken(ctx) + if err := authDeleteProfileToken(configDir, selector); err != nil { return apperrors.NewInternal(fmt.Sprintf("failed to clear token data: %v", err)) } return nil } func logoutAllProfiles(_ *cobra.Command, ctx context.Context, configDir string) error { - if err := authpkg.EnsureProfilesMigration(configDir); err != nil { + if err := authEnsureProfilesMigration(configDir); err != nil { return apperrors.NewInternal(fmt.Sprintf("failed to migrate profiles: %v", err)) } - cfg, err := authpkg.LoadProfiles(configDir) + cfg, err := authLoadProfiles(configDir) if err != nil { return apperrors.NewInternal(fmt.Sprintf("failed to load profiles: %v", err)) } if cfg == nil || len(cfg.Profiles) == 0 { - _ = authpkg.RevokeTokenRemote(ctx) + _ = authRevokeToken(ctx) } else { for _, profile := range cfg.Profiles { restoreProfile := pushRuntimeProfile(profile.CorpID) - _ = authpkg.RevokeTokenRemote(ctx) + _ = authRevokeToken(ctx) restoreProfile() } } - if err := authpkg.DeleteAllTokenData(configDir); err != nil { + if err := authDeleteAllTokenData(configDir); err != nil { return apperrors.NewInternal(fmt.Sprintf("failed to clear token data: %v", err)) } return nil @@ -656,18 +689,18 @@ func newAuthExportCommand() *cobra.Command { if !asBase64 && output == "" { return apperrors.NewValidation("--output is required unless --base64 is used") } - if !authpkg.PortableExportSupported() { + if !authPortableExportSupported() { return apperrors.NewValidation(fmt.Sprintf( "macOS 导出认证包需要 file-DEK 模式;请先设置 %s=1 并运行 dws auth status 验证,只有提示密钥不匹配且确认可丢弃旧登录态时,才执行 dws auth reset 后重新登录", keychain.DisableKeychainEnv, )) } - if !authpkg.PortableAuthSourceReady() { + if !authPortableSourceReady() { return apperrors.NewValidation("尚未登录,请先运行 dws auth login --recommend") } var bundle bytes.Buffer - if err := authpkg.ExportPortableAuthBundle(defaultConfigDir(), &bundle); err != nil { + if err := authExportPortableBundle(defaultConfigDir(), &bundle); err != nil { return apperrors.NewInternal(fmt.Sprintf("failed to export auth bundle: %v", err)) } @@ -677,7 +710,7 @@ func newAuthExportCommand() *cobra.Command { _, err := cmd.OutOrStdout().Write(payload) return err } - if err := helpers.AtomicWrite(output, payload, config.FilePerm); err != nil { + if err := authAtomicWrite(output, payload, config.FilePerm); err != nil { return apperrors.NewInternal(fmt.Sprintf("failed to write auth bundle: %v", err)) } fmt.Fprintf(cmd.OutOrStdout(), "[OK] 已导出认证包: %s\n", output) @@ -685,7 +718,7 @@ func newAuthExportCommand() *cobra.Command { return nil } - if err := helpers.AtomicWrite(output, bundle.Bytes(), config.FilePerm); err != nil { + if err := authAtomicWrite(output, bundle.Bytes(), config.FilePerm); err != nil { return apperrors.NewInternal(fmt.Sprintf("failed to write auth bundle: %v", err)) } fmt.Fprintf(cmd.OutOrStdout(), "[OK] 已导出认证包: %s\n", output) @@ -729,11 +762,11 @@ func newAuthImportCommand() *cobra.Command { } configDir := defaultConfigDir() - if !force && authpkg.PortableAuthTargetPopulated(configDir) { + if !force && authPortableTargetPopulated(configDir) { return apperrors.NewValidation("检测到已有登录态,请使用 --force 确认覆盖") } - payload, err := os.ReadFile(input) + payload, err := authReadFile(input) if err != nil { return apperrors.NewInternal(fmt.Sprintf("failed to read auth bundle: %v", err)) } @@ -743,7 +776,7 @@ func newAuthImportCommand() *cobra.Command { return apperrors.NewValidation(fmt.Sprintf("invalid base64 auth bundle: %v", err)) } } - report, err := authpkg.ImportPortableAuthBundle(configDir, bytes.NewReader(payload)) + report, err := authImportPortableBundle(configDir, bytes.NewReader(payload)) if err != nil { return apperrors.NewInternal(fmt.Sprintf("failed to import auth bundle: %v", err)) } @@ -788,7 +821,7 @@ func newAuthExchangeCommand() *cobra.Command { configureOAuthProviderCompatibility(provider, configDir) exchangeCtx, cancel := context.WithTimeout(cmd.Context(), time.Minute) defer cancel() - tokenData, err := provider.ExchangeAuthCode(exchangeCtx, code, strings.TrimSpace(uid)) + tokenData, err := authOAuthExchange(provider, exchangeCtx, code, strings.TrimSpace(uid)) if err != nil { return apperrors.NewAuth(fmt.Sprintf("failed to exchange authorization code: %v", err)) } @@ -827,12 +860,12 @@ func newAuthResetCommand() *cobra.Command { DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, args []string) error { configDir := defaultConfigDir() - if err := authpkg.DeleteAllTokenData(configDir); err != nil { + if err := authDeleteAllTokenData(configDir); err != nil { return apperrors.NewInternal(fmt.Sprintf("failed to reset token data: %v", err)) } - _ = os.Remove(filepath.Join(configDir, "mcp_url")) - _ = os.Remove(filepath.Join(configDir, "token")) - _ = authpkg.DeleteAppConfig(configDir) + _ = authRemove(filepath.Join(configDir, "mcp_url")) + _ = authRemove(filepath.Join(configDir, "token")) + _ = authDeleteAppConfig(configDir) ResetRuntimeTokenCache() clearCompatCache() w := cmd.OutOrStdout() @@ -908,20 +941,22 @@ func selectLoginRecommendProducts(products []pat.LoginRecommendProduct) ([]strin Options(options...). Height(height). Value(&selected). - Validate(func(values []string) error { - if len(values) == 0 { - return fmt.Errorf("至少选择一个授权业务域") - } - return nil - }), + Validate(authLoginProductsNonEmpty), ), ).WithTheme(authLoginHuhTheme()) - if err := form.Run(); err != nil { + if err := authRunForm(form); err != nil { return nil, fmt.Errorf("授权业务域选择中止: %w", err) } return selected, nil } +func authLoginProductsNonEmpty(values []string) error { + if len(values) == 0 { + return fmt.Errorf("至少选择一个授权业务域") + } + return nil +} + func authLoginHuhTheme() *huh.Theme { t := huh.ThemeBase() @@ -1124,7 +1159,7 @@ func resolveAuthLoginTargetCorpID(configDir, selector string) (string, error) { if selector == "" { return "", nil } - if profile, err := authpkg.ResolveProfile(configDir, selector); err == nil && profile != nil { + if profile, err := authResolveProfile(configDir, selector); err == nil && profile != nil { return strings.TrimSpace(profile.CorpID), nil } if strings.HasPrefix(selector, "ding") { @@ -1183,7 +1218,7 @@ func enrichAuthLoginProfileFromContact(ctx context.Context, configDir string, ca if updated.CorpName == data.CorpName && updated.UserID == data.UserID && updated.UserName == data.UserName { return nil } - if err := authpkg.SaveTokenData(configDir, &updated); err != nil { + if err := authSaveTokenData(configDir, &updated); err != nil { return err } *data = updated diff --git a/internal/app/auth_command_full_coverage_test.go b/internal/app/auth_command_full_coverage_test.go new file mode 100644 index 00000000..afb0d80e --- /dev/null +++ b/internal/app/auth_command_full_coverage_test.go @@ -0,0 +1,788 @@ +package app + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "os" + "strings" + "testing" + "time" + + authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" + "github.com/charmbracelet/huh" + "github.com/spf13/cobra" +) + +type authCoverageCaller struct { + result *edition.ToolResult + err error +} + +func (c *authCoverageCaller) CallTool(context.Context, string, string, map[string]any) (*edition.ToolResult, error) { + return c.result, c.err +} +func (*authCoverageCaller) Format() string { return "json" } +func (*authCoverageCaller) DryRun() bool { return false } +func (*authCoverageCaller) Fields() string { return "" } +func (*authCoverageCaller) JQ() string { return "" } + +func authCoverageRoot(child *cobra.Command, format string, yes bool) (*cobra.Command, *bytes.Buffer, *bytes.Buffer) { + root := &cobra.Command{Use: "dws"} + root.SetContext(context.Background()) + child.SetContext(context.Background()) + root.PersistentFlags().String("format", format, "") + root.PersistentFlags().Bool("yes", yes, "") + root.PersistentFlags().String("profile", "", "") + root.AddCommand(child) + out := &bytes.Buffer{} + errOut := &bytes.Buffer{} + root.SetOut(out) + root.SetErr(errOut) + return root, out, errOut +} + +func authCoverageRunLogin(t *testing.T, caller edition.ToolCaller, format string, yes bool, flags map[string]string) (string, string, error) { + t.Helper() + cmd := newAuthLoginCommand(caller) + root, out, errOut := authCoverageRoot(cmd, format, yes) + for name, value := range flags { + flagSet := cmd.Flags() + if root.PersistentFlags().Lookup(name) != nil { + flagSet = root.PersistentFlags() + } + if err := flagSet.Set(name, value); err != nil { + t.Fatalf("set %s: %v", name, err) + } + } + err := cmd.RunE(cmd, nil) + return out.String(), errOut.String(), err +} + +func TestAuthCoverageFormsParentAndTargets(t *testing.T) { + oldEdition := edition.Get() + oldRunForm := authRunForm + oldPrompt := authLoginManualCredentialsPrompt + oldSaveConfig := authSaveAppConfig + oldResolve := authResolveProfile + t.Cleanup(func() { + edition.Override(oldEdition) + authRunForm = oldRunForm + authLoginManualCredentialsPrompt = oldPrompt + authSaveAppConfig = oldSaveConfig + authResolveProfile = oldResolve + }) + + edition.Override(&edition.Hooks{}) + parent := buildAuthCommand(nil) + if parent.CommandPath() == "" || parent.RunE(parent, nil) != nil { + t.Fatal("auth parent should render help") + } + edition.Override(&edition.Hooks{HideAuthLogin: true}) + if got := buildAuthCommand(nil).Commands(); len(got) != 7 { + t.Fatalf("hidden-login subcommands = %d, want 7", len(got)) + } + + authRunForm = func(*huh.Form) error { return nil } + if choice, err := selectAuthLoginGuideAction(); err != nil || choice != authLoginGuideDirectCLI { + t.Fatalf("guide choice = %q, %v", choice, err) + } + if id, secret, err := promptAuthLoginManualCredentials(); err != nil || id != "" || secret != "" { + t.Fatalf("manual prompt = %q/%q, %v", id, secret, err) + } + if mode, err := selectLoginRecommendScopeMode(); err != nil || mode != pat.LoginRecommendScopeRecommended { + t.Fatalf("scope mode = %q, %v", mode, err) + } + products := []pat.LoginRecommendProduct{{ProductCode: "doc"}, {ProductCode: ""}} + if selected, err := selectLoginRecommendProducts(products); err != nil || len(selected) != 1 || selected[0] != "doc" { + t.Fatalf("selected products = %#v, %v", selected, err) + } + if selected, err := selectLoginRecommendProducts(nil); err != nil || selected != nil { + t.Fatalf("empty selected products = %#v, %v", selected, err) + } + if selected, err := selectLoginRecommendProducts([]pat.LoginRecommendProduct{{}}); err != nil || selected != nil { + t.Fatalf("blank selected products = %#v, %v", selected, err) + } + many := make([]pat.LoginRecommendProduct, 16) + for i := range many { + many[i].ProductCode = fmt.Sprintf("p%d", i) + } + if selected, err := selectLoginRecommendProducts(many); err != nil || len(selected) != 16 { + t.Fatalf("many selected products = %d, %v", len(selected), err) + } + if authLoginProductsNonEmpty(nil) == nil || authLoginProductsNonEmpty([]string{"doc"}) != nil { + t.Fatal("product validator mismatch") + } + + authRunForm = func(*huh.Form) error { return errors.New("cancel") } + if _, err := selectAuthLoginGuideAction(); err == nil { + t.Fatal("guide cancellation should fail") + } + if _, _, err := promptAuthLoginManualCredentials(); err == nil { + t.Fatal("credential cancellation should fail") + } + if _, err := selectLoginRecommendScopeMode(); err == nil { + t.Fatal("scope cancellation should fail") + } + if _, err := selectLoginRecommendProducts(products); err == nil { + t.Fatal("product cancellation should fail") + } + if authLoginNonEmpty("field")(" ") == nil || authLoginNonEmpty("field")("value") != nil { + t.Fatal("non-empty validator mismatch") + } + + cmd := &cobra.Command{} + cmd.SetErr(io.Discard) + if err := applyAuthLoginGuideAction(cmd, t.TempDir(), authLoginGuideDirectCLI); err != nil { + t.Fatal(err) + } + if err := applyAuthLoginGuideAction(cmd, t.TempDir(), authLoginGuideConfigureAgentApp); err != nil { + t.Fatal(err) + } + if err := applyAuthLoginGuideAction(cmd, t.TempDir(), "unknown"); err == nil { + t.Fatal("unknown guide action should fail") + } + authLoginManualCredentialsPrompt = func() (string, string, error) { return "", "", errors.New("cancel") } + if err := applyAuthLoginGuideAction(cmd, t.TempDir(), authLoginGuideManualCredentials); err == nil { + t.Fatal("manual prompt error should propagate") + } + authLoginManualCredentialsPrompt = func() (string, string, error) { return "id", "secret", nil } + authSaveAppConfig = func(string, *authpkg.AppConfig) error { return errors.New("save") } + if err := applyAuthLoginGuideAction(cmd, t.TempDir(), authLoginGuideManualCredentials); err == nil { + t.Fatal("app-config save error should propagate") + } + authSaveAppConfig = func(string, *authpkg.AppConfig) error { return nil } + if err := applyAuthLoginGuideAction(cmd, t.TempDir(), authLoginGuideManualCredentials); err != nil { + t.Fatal(err) + } + + authResolveProfile = func(string, string) (*authpkg.Profile, error) { + return &authpkg.Profile{CorpID: " ding-profile "}, nil + } + if got, err := resolveAuthLoginTargetCorpID("cfg", "name"); err != nil || got != "ding-profile" { + t.Fatalf("resolved target = %q, %v", got, err) + } + authResolveProfile = func(string, string) (*authpkg.Profile, error) { return nil, errors.New("missing") } + for selector, want := range map[string]struct { + value string + err bool + }{"": {"", false}, "ding-direct": {"ding-direct", false}, "other": {"", true}} { + got, err := resolveAuthLoginTargetCorpID("cfg", selector) + if got != want.value || (err != nil) != want.err { + t.Fatalf("target %q = %q, %v", selector, got, err) + } + } + badToken := &cobra.Command{} + badToken.Flags().Bool("token", false, "") + if _, err := resolveAuthLoginConfig(badToken); err == nil { + t.Fatal("invalid token flag should fail") + } + badDevice := &cobra.Command{} + badDevice.Flags().String("token", "", "") + badDevice.Flags().String("device", "", "") + if _, err := resolveAuthLoginConfig(badDevice); err == nil { + t.Fatal("invalid device flag should fail") + } + badForce := &cobra.Command{} + badForce.Flags().String("token", "", "") + badForce.Flags().Bool("device", false, "") + badForce.Flags().String("force", "", "") + if _, err := resolveAuthLoginConfig(badForce); err == nil { + t.Fatal("invalid force flag should fail") + } + badRecommend := &cobra.Command{} + badRecommend.Flags().String("token", "", "") + badRecommend.Flags().Bool("device", false, "") + badRecommend.Flags().Bool("force", false, "") + badRecommend.Flags().String("recommend", "", "") + if _, err := resolveAuthLoginConfig(badRecommend); err == nil { + t.Fatal("invalid recommend flag should fail") + } +} + +func TestAuthCoverageLoginFlows(t *testing.T) { + t.Setenv("DWS_CONFIG_DIR", t.TempDir()) + oldSave := authSaveTokenData + oldDevice := authDeviceLogin + oldOAuth := authOAuthLogin + oldRunRecommend := authRunLoginRecommend + oldRunWait := authRunDirectPATWait + oldPlan := authPlanLoginRecommend + oldScope := loginRecommendScopeModeSelector + oldProducts := loginRecommendProductSelector + oldInteractive := authLoginInteractiveTerminal + oldResolve := authResolveProfile + t.Cleanup(func() { + authSaveTokenData = oldSave + authDeviceLogin = oldDevice + authOAuthLogin = oldOAuth + authRunLoginRecommend = oldRunRecommend + authRunDirectPATWait = oldRunWait + authPlanLoginRecommend = oldPlan + loginRecommendScopeModeSelector = oldScope + loginRecommendProductSelector = oldProducts + authLoginInteractiveTerminal = oldInteractive + authResolveProfile = oldResolve + }) + authInteractiveFalse := func() bool { return false } + authLoginInteractiveTerminal = authInteractiveFalse + authResolveProfile = func(string, string) (*authpkg.Profile, error) { return nil, errors.New("missing") } + if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"profile": "bad"}); err == nil { + t.Fatal("invalid login profile should fail") + } + + authSaveTokenData = func(string, *authpkg.TokenData) error { return errors.New("save") } + if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"token": "token"}); err == nil { + t.Fatal("token save should fail") + } + authSaveTokenData = func(string, *authpkg.TokenData) error { return nil } + if out, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"token": " token "}); err != nil || !strings.Contains(out, "登录成功") { + t.Fatalf("token login = %q, %v", out, err) + } + if out, _, err := authCoverageRunLogin(t, nil, "json", true, map[string]string{"token": "token"}); err != nil || !strings.Contains(out, `"token_valid": true`) { + t.Fatalf("json token login = %q, %v", out, err) + } + + authDeviceLogin = func(*authpkg.DeviceFlowProvider, context.Context) (*authpkg.TokenData, error) { + return nil, errors.New("device") + } + if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"device": "true"}); err == nil { + t.Fatal("device error should propagate") + } + authDeviceLogin = func(*authpkg.DeviceFlowProvider, context.Context) (*authpkg.TokenData, error) { + return &authpkg.TokenData{AccessToken: "a", ExpiresAt: time.Now().Add(time.Hour)}, nil + } + if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"device": "true", "no-browser": "true"}); err != nil { + t.Fatal(err) + } + + authOAuthLogin = func(*authpkg.OAuthProvider, context.Context, bool) (*authpkg.TokenData, error) { + return nil, errors.New("oauth") + } + if _, _, err := authCoverageRunLogin(t, nil, "table", true, nil); err == nil { + t.Fatal("oauth error should propagate") + } + authOAuthLogin = func(*authpkg.OAuthProvider, context.Context, bool) (*authpkg.TokenData, error) { + return &authpkg.TokenData{ + AccessToken: "a", ExpiresAt: time.Now().Add(time.Hour), RefreshToken: "r", RefreshExpAt: time.Now().Add(48 * time.Hour), + CorpName: "Corp", CorpID: "ding1", UserName: "User", UserID: "u", + }, nil + } + caller := &authCoverageCaller{result: &edition.ToolResult{Content: []edition.ContentBlock{{Text: `{}`}}}} + if out, _, err := authCoverageRunLogin(t, caller, "table", true, map[string]string{"no-browser": "true"}); err != nil || !strings.Contains(out, "Corp") { + t.Fatalf("oauth success = %q, %v", out, err) + } + + authRunLoginRecommend = func(context.Context, edition.ToolCaller, io.Writer, pat.LoginRecommendOptions) error { + return errors.New("recommend") + } + if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"token": "x", "recommend": "true"}); err == nil { + t.Fatal("recommend error should propagate") + } + if _, _, err := authCoverageRunLogin(t, nil, "json", true, map[string]string{"token": "x", "recommend": "true"}); err == nil { + t.Fatal("JSON recommend error should propagate") + } + authRunLoginRecommend = func(context.Context, edition.ToolCaller, io.Writer, pat.LoginRecommendOptions) error { + return &apperrors.PATError{RawJSON: `{"code":"PAT_SCOPE_AUTH_REQUIRED"}`} + } + waited := false + authRunDirectPATWait = func(context.Context, *GlobalFlags, *apperrors.PATError, io.Writer) error { + waited = true + return nil + } + if _, _, err := authCoverageRunLogin(t, nil, "json", true, map[string]string{"token": "x", "recommend": "true"}); err != nil || !waited { + t.Fatalf("PAT wait = %v, waited=%v", err, waited) + } + + authLoginInteractiveTerminal = func() bool { return true } + authPlanLoginRecommend = func(context.Context, edition.ToolCaller) (*pat.LoginRecommendPlan, error) { + return nil, errors.New("plan") + } + if _, _, err := authCoverageRunLogin(t, nil, "table", false, map[string]string{"token": "x"}); err == nil { + t.Fatal("plan error should propagate") + } + authPlanLoginRecommend = func(context.Context, edition.ToolCaller) (*pat.LoginRecommendPlan, error) { + return &pat.LoginRecommendPlan{AllGranted: true}, nil + } + if _, stderr, err := authCoverageRunLogin(t, nil, "table", false, map[string]string{"token": "x"}); err != nil || !strings.Contains(stderr, "全部授权") { + t.Fatalf("all-granted plan = %q, %v", stderr, err) + } + authPlanLoginRecommend = func(context.Context, edition.ToolCaller) (*pat.LoginRecommendPlan, error) { + return &pat.LoginRecommendPlan{Scopes: []string{"scope"}, Products: []pat.LoginRecommendProduct{{ProductCode: "doc"}}}, nil + } + loginRecommendScopeModeSelector = func() (pat.LoginRecommendScopeMode, error) { return "", errors.New("scope") } + if _, _, err := authCoverageRunLogin(t, nil, "table", false, map[string]string{"token": "x"}); err == nil { + t.Fatal("scope selector error should propagate") + } + loginRecommendScopeModeSelector = func() (pat.LoginRecommendScopeMode, error) { return pat.LoginRecommendScopeAll, nil } + loginRecommendProductSelector = func([]pat.LoginRecommendProduct) ([]string, error) { return []string{"doc"}, nil } + selected := false + authRunLoginRecommend = func(_ context.Context, _ edition.ToolCaller, _ io.Writer, opts pat.LoginRecommendOptions) error { + if opts.ProductSelector != nil { + _, err := opts.ProductSelector(opts.InitialPlan.Products) + selected = err == nil + } + return nil + } + if _, _, err := authCoverageRunLogin(t, nil, "table", false, map[string]string{"token": "x"}); err != nil || !selected { + t.Fatalf("interactive recommendation = %v, selected=%v", err, selected) + } +} + +func TestAuthCoverageContactEnrichment(t *testing.T) { + oldSave := authSaveTokenData + t.Cleanup(func() { authSaveTokenData = oldSave }) + ctx := context.Background() + if err := enrichAuthLoginProfileFromContact(ctx, "cfg", nil, nil); err != nil { + t.Fatal(err) + } + if err := enrichAuthLoginProfileFromContact(ctx, "cfg", &authCoverageCaller{}, &authpkg.TokenData{}); err != nil { + t.Fatal(err) + } + complete := &authpkg.TokenData{CorpID: "ding", CorpName: "Corp", UserID: "u", UserName: "User"} + if err := enrichAuthLoginProfileFromContact(ctx, "cfg", &authCoverageCaller{}, complete); err != nil { + t.Fatal(err) + } + if err := enrichAuthLoginProfileFromContact(ctx, "cfg", &authCoverageCaller{err: errors.New("call")}, &authpkg.TokenData{CorpID: "ding"}); err == nil { + t.Fatal("caller error should propagate") + } + for _, text := range []string{"", "not-json", `{"result":[]}`, `{"result":[{"orgEmployeeModel":{}}]}`} { + caller := &authCoverageCaller{result: &edition.ToolResult{Content: []edition.ContentBlock{{Text: text}}}} + if err := enrichAuthLoginProfileFromContact(ctx, "cfg", caller, &authpkg.TokenData{CorpID: "ding"}); err != nil { + t.Fatalf("invalid contact %q: %v", text, err) + } + } + mismatch := &authCoverageCaller{result: &edition.ToolResult{Content: []edition.ContentBlock{{Text: `{"result":[{"orgEmployeeModel":{"corpId":"other"}}]}`}}}} + if err := enrichAuthLoginProfileFromContact(ctx, "cfg", mismatch, &authpkg.TokenData{CorpID: "ding"}); err == nil { + t.Fatal("corp mismatch should fail") + } + same := &authCoverageCaller{result: &edition.ToolResult{Content: []edition.ContentBlock{{Text: `{"result":[{"orgEmployeeModel":{"corpId":"ding","orgName":"Corp","userid":"u","name":"User"}}]}`}}}} + if err := enrichAuthLoginProfileFromContact(ctx, "cfg", same, complete); err != nil { + t.Fatal(err) + } + unchangedPartial := &authCoverageCaller{result: &edition.ToolResult{Content: []edition.ContentBlock{{Text: `{"result":[{"orgEmployeeModel":{"corpId":"ding","orgName":"Corp","userid":"u"}}]}`}}}} + if err := enrichAuthLoginProfileFromContact(ctx, "cfg", unchangedPartial, &authpkg.TokenData{CorpID: "ding", CorpName: "Corp", UserID: "u"}); err != nil { + t.Fatal(err) + } + authSaveTokenData = func(string, *authpkg.TokenData) error { return errors.New("save") } + if err := enrichAuthLoginProfileFromContact(ctx, "cfg", same, &authpkg.TokenData{CorpID: "ding"}); err == nil { + t.Fatal("enrichment save should fail") + } + authSaveTokenData = func(string, *authpkg.TokenData) error { return nil } + data := &authpkg.TokenData{CorpID: "ding"} + if err := enrichAuthLoginProfileFromContact(ctx, "cfg", same, data); err != nil || data.CorpName != "Corp" || data.UserID != "u" { + t.Fatalf("enriched = %#v, %v", data, err) + } + if _, ok := contactProfileIdentityFromToolResult(nil); ok { + t.Fatal("nil result should not parse") + } + if got := firstNonEmptyString(" ", " value ", "later"); got != "value" { + t.Fatalf("first non-empty = %q", got) + } +} + +func TestAuthCoverageDefaultSeamClosures(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + configDir := t.TempDir() + device := authpkg.NewDeviceFlowProvider(configDir, nil) + _, _ = authDeviceLogin(device, ctx) + oauth := authpkg.NewOAuthProvider(configDir, nil) + _, _ = authOAuthStatus(oauth) + _, _ = authOAuthAccessToken(oauth, ctx) + _, _ = authOAuthLogin(oauth, ctx, true) + _, _ = authOAuthExchange(oauth, ctx, "code", "uid") + _ = fmt.Sprintf("%v", os.ErrNotExist) +} + +func TestAuthCoverageStatusAndLogout(t *testing.T) { + t.Setenv("DWS_CONFIG_DIR", t.TempDir()) + oldEdition := edition.Get() + oldStatus := authOAuthStatus + oldAccess := authOAuthAccessToken + oldDelete := authDeleteTokenData + oldMark := authMarkProfileStatus + oldResolve := authResolveProfile + oldRevoke := authRevokeToken + oldDeleteProfile := authDeleteProfileToken + oldMigrate := authEnsureProfilesMigration + oldLoadProfiles := authLoadProfiles + oldDeleteAll := authDeleteAllTokenData + t.Cleanup(func() { + edition.Override(oldEdition) + authOAuthStatus = oldStatus + authOAuthAccessToken = oldAccess + authDeleteTokenData = oldDelete + authMarkProfileStatus = oldMark + authResolveProfile = oldResolve + authRevokeToken = oldRevoke + authDeleteProfileToken = oldDeleteProfile + authEnsureProfilesMigration = oldMigrate + authLoadProfiles = oldLoadProfiles + authDeleteAllTokenData = oldDeleteAll + }) + + badStatus := newAuthStatusCommand() + bad := &cobra.Command{} + bad.Flags().Bool("profile", false, "") + if err := badStatus.RunE(bad, nil); err == nil { + t.Fatal("invalid profile flag should fail") + } + + runStatus := func(format string) (string, error) { + cmd := newAuthStatusCommand() + _, out, _ := authCoverageRoot(cmd, format, false) + err := cmd.RunE(cmd, nil) + return out.String(), err + } + authOAuthStatus = func(*authpkg.OAuthProvider) (*authpkg.TokenData, error) { return nil, nil } + edition.Override(&edition.Hooks{}) + if out, err := runStatus("table"); err != nil || !strings.Contains(out, "auth login") { + t.Fatalf("plain unauthenticated status = %q, %v", out, err) + } + authOAuthStatus = func(*authpkg.OAuthProvider) (*authpkg.TokenData, error) { + return nil, keychain.NewUnavailableError("read", errors.New("status")) + } + edition.Override(&edition.Hooks{}) + if out, err := runStatus("table"); err != nil || !strings.Contains(out, "未登录") { + t.Fatalf("status error = %q, %v", out, err) + } + if out, err := runStatus("json"); err != nil || !strings.Contains(out, `"authenticated": false`) { + t.Fatalf("json status error = %q, %v", out, err) + } + + now := time.Now() + valid := &authpkg.TokenData{AccessToken: "a", ExpiresAt: now.Add(time.Hour), CorpID: "ding", CorpName: "Corp"} + authOAuthStatus = func(*authpkg.OAuthProvider) (*authpkg.TokenData, error) { return valid, nil } + if out, err := runStatus("table"); err != nil || !strings.Contains(out, "已登录") || !strings.Contains(out, "缺失或已过期") { + t.Fatalf("valid access status = %q, %v", out, err) + } + valid.RefreshToken = "r" + valid.RefreshExpAt = now.Add(time.Hour) + if out, err := runStatus("table"); err != nil || !strings.Contains(out, "Refresh Token:") { + t.Fatalf("valid refresh status = %q, %v", out, err) + } + + expired := &authpkg.TokenData{AccessToken: "a", ExpiresAt: now.Add(-time.Hour), RefreshToken: "r", RefreshExpAt: now.Add(time.Hour), CorpID: "ding"} + updated := &authpkg.TokenData{AccessToken: "new", ExpiresAt: now.Add(time.Hour), RefreshToken: "r", RefreshExpAt: now.Add(time.Hour), CorpID: "ding"} + calls := 0 + authOAuthStatus = func(*authpkg.OAuthProvider) (*authpkg.TokenData, error) { + calls++ + if calls == 1 { + return expired, nil + } + return updated, nil + } + authOAuthAccessToken = func(*authpkg.OAuthProvider, context.Context) (string, error) { return "new", nil } + if out, err := runStatus("table"); err != nil || !strings.Contains(out, "自动刷新") { + t.Fatalf("refreshed status = %q, %v", out, err) + } + calls = 0 + authOAuthStatus = func(*authpkg.OAuthProvider) (*authpkg.TokenData, error) { + calls++ + if calls == 1 { + return expired, nil + } + return nil, errors.New("second status") + } + if _, err := runStatus("table"); err != nil { + t.Fatal(err) + } + + authOAuthStatus = func(*authpkg.OAuthProvider) (*authpkg.TokenData, error) { return expired, nil } + authOAuthAccessToken = func(*authpkg.OAuthProvider, context.Context) (string, error) { return "", errors.New("refresh") } + deleted := false + marked := false + authDeleteTokenData = func(string) error { deleted = true; return errors.New("ignored") } + authMarkProfileStatus = func(string, string, string) error { marked = true; return errors.New("ignored") } + edition.Override(&edition.Hooks{AutoPurgeToken: true}) + if _, err := runStatus("table"); err != nil || !deleted { + t.Fatalf("auto-purge = %v, deleted=%v", err, deleted) + } + edition.Override(&edition.Hooks{}) + if _, err := runStatus("table"); err != nil || !marked { + t.Fatalf("mark-expired = %v, marked=%v", err, marked) + } + + authResolveProfile = func(string, string) (*authpkg.Profile, error) { return nil, errors.New("missing") } + if err := logoutOneProfile(nil, context.Background(), "cfg", "x"); err == nil { + t.Fatal("missing profile should fail") + } + authResolveProfile = func(string, string) (*authpkg.Profile, error) { return &authpkg.Profile{CorpID: "ding"}, nil } + authRevokeToken = func(context.Context) error { return errors.New("ignored") } + authDeleteProfileToken = func(string, string) error { return errors.New("delete") } + if err := logoutOneProfile(nil, context.Background(), "cfg", "x"); err == nil { + t.Fatal("profile delete should fail") + } + authDeleteProfileToken = func(string, string) error { return nil } + if err := logoutOneProfile(nil, context.Background(), "cfg", "x"); err != nil { + t.Fatal(err) + } + + authEnsureProfilesMigration = func(string) error { return errors.New("migrate") } + if err := logoutAllProfiles(nil, context.Background(), "cfg"); err == nil { + t.Fatal("migration should fail") + } + authEnsureProfilesMigration = func(string) error { return nil } + authLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) { return nil, errors.New("load") } + if err := logoutAllProfiles(nil, context.Background(), "cfg"); err == nil { + t.Fatal("load profiles should fail") + } + revokes := 0 + authRevokeToken = func(context.Context) error { revokes++; return nil } + authLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) { return nil, nil } + authDeleteAllTokenData = func(string) error { return nil } + if err := logoutAllProfiles(nil, context.Background(), "cfg"); err != nil || revokes != 1 { + t.Fatalf("empty profiles = %v, revokes=%d", err, revokes) + } + authLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) { + return &authpkg.ProfilesConfig{Profiles: []authpkg.Profile{{CorpID: "a"}, {CorpID: "b"}}}, nil + } + if err := logoutAllProfiles(nil, context.Background(), "cfg"); err != nil || revokes != 3 { + t.Fatalf("profile revokes = %v, revokes=%d", err, revokes) + } + authDeleteAllTokenData = func(string) error { return errors.New("delete all") } + if err := logoutAllProfiles(nil, context.Background(), "cfg"); err == nil { + t.Fatal("delete-all should fail") + } + logoutFailure := newAuthLogoutCommand() + _, _, _ = authCoverageRoot(logoutFailure, "table", false) + if err := logoutFailure.RunE(logoutFailure, nil); err == nil { + t.Fatal("logout-all command failure should propagate") + } + logoutFailure = newAuthLogoutCommand() + _, _, _ = authCoverageRoot(logoutFailure, "table", false) + _ = logoutFailure.Flags().Set("profile", "ding") + authDeleteProfileToken = func(string, string) error { return errors.New("delete") } + if err := logoutFailure.RunE(logoutFailure, nil); err == nil { + t.Fatal("logout-one command failure should propagate") + } + + logout := newAuthLogoutCommand() + badLogout := &cobra.Command{} + badLogout.Flags().Bool("profile", false, "") + badLogout.SetContext(context.Background()) + if err := logout.RunE(badLogout, nil); err == nil { + t.Fatal("invalid logout profile flag should fail") + } + authDeleteAllTokenData = func(string) error { return nil } + authLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) { return &authpkg.ProfilesConfig{}, nil } + _, out, _ := authCoverageRoot(logout, "table", false) + if err := logout.RunE(logout, nil); err != nil || !strings.Contains(out.String(), "重新登录") { + t.Fatalf("logout = %q, %v", out.String(), err) + } + edition.Override(&edition.Hooks{IsEmbedded: true}) + authDeleteProfileToken = func(string, string) error { return nil } + logout = newAuthLogoutCommand() + _, out, _ = authCoverageRoot(logout, "table", false) + if err := logout.Flags().Set("profile", "ding"); err != nil { + t.Fatal(err) + } + if err := logout.RunE(logout, nil); err != nil || strings.Contains(out.String(), "重新登录") { + t.Fatalf("embedded logout = %q, %v", out.String(), err) + } +} + +func TestAuthCoveragePortableExchangeAndReset(t *testing.T) { + t.Setenv("DWS_CONFIG_DIR", t.TempDir()) + oldEdition := edition.Get() + oldSupported := authPortableExportSupported + oldReady := authPortableSourceReady + oldTarget := authPortableTargetPopulated + oldExport := authExportPortableBundle + oldImport := authImportPortableBundle + oldAtomic := authAtomicWrite + oldRead := authReadFile + oldExchange := authOAuthExchange + oldDeleteAll := authDeleteAllTokenData + oldRemove := authRemove + oldDeleteConfig := authDeleteAppConfig + t.Cleanup(func() { + edition.Override(oldEdition) + authPortableExportSupported = oldSupported + authPortableSourceReady = oldReady + authPortableTargetPopulated = oldTarget + authExportPortableBundle = oldExport + authImportPortableBundle = oldImport + authAtomicWrite = oldAtomic + authReadFile = oldRead + authOAuthExchange = oldExchange + authDeleteAllTokenData = oldDeleteAll + authRemove = oldRemove + authDeleteAppConfig = oldDeleteConfig + }) + + export := newAuthExportCommand() + badString := &cobra.Command{} + badString.Flags().Bool("output", false, "") + if err := export.RunE(badString, nil); err == nil { + t.Fatal("invalid output flag should fail") + } + badBool := &cobra.Command{} + badBool.Flags().String("output", "x", "") + badBool.Flags().String("base64", "", "") + if err := export.RunE(badBool, nil); err == nil { + t.Fatal("invalid base64 flag should fail") + } + _, _, _ = authCoverageRoot(export, "table", false) + if err := export.RunE(export, nil); err == nil { + t.Fatal("missing export output should fail") + } + authPortableExportSupported = func() bool { return false } + _ = export.Flags().Set("output", "out") + if err := export.RunE(export, nil); err == nil { + t.Fatal("unsupported export should fail") + } + authPortableExportSupported = func() bool { return true } + authPortableSourceReady = func() bool { return false } + if err := export.RunE(export, nil); err == nil { + t.Fatal("unready export should fail") + } + authPortableSourceReady = func() bool { return true } + authExportPortableBundle = func(string, io.Writer) error { return errors.New("export") } + if err := export.RunE(export, nil); err == nil { + t.Fatal("export failure should propagate") + } + authExportPortableBundle = func(_ string, w io.Writer) error { _, _ = io.WriteString(w, "bundle"); return nil } + authAtomicWrite = func(string, []byte, os.FileMode) error { return errors.New("write") } + if err := export.RunE(export, nil); err == nil { + t.Fatal("raw write should fail") + } + authAtomicWrite = func(string, []byte, os.FileMode) error { return nil } + if err := export.RunE(export, nil); err != nil { + t.Fatal(err) + } + export = newAuthExportCommand() + _, out, _ := authCoverageRoot(export, "table", false) + _ = export.Flags().Set("base64", "true") + export.SetOut(&appFailWriter{err: errors.New("stdout")}) + if err := export.RunE(export, nil); err == nil { + t.Fatal("stdout failure should propagate") + } + export.SetOut(out) + _ = export.Flags().Set("output", "encoded") + authAtomicWrite = func(string, []byte, os.FileMode) error { return errors.New("write") } + if err := export.RunE(export, nil); err == nil { + t.Fatal("base64 write should fail") + } + authAtomicWrite = func(string, []byte, os.FileMode) error { return nil } + if err := export.RunE(export, nil); err != nil { + t.Fatal(err) + } + + importCmd := newAuthImportCommand() + badInput := &cobra.Command{} + badInput.Flags().Bool("input", false, "") + if err := importCmd.RunE(badInput, nil); err == nil { + t.Fatal("invalid input flag should fail") + } + badImportBase64 := &cobra.Command{} + badImportBase64.Flags().String("input", "x", "") + badImportBase64.Flags().String("base64", "", "") + if err := importCmd.RunE(badImportBase64, nil); err == nil { + t.Fatal("invalid import base64 flag should fail") + } + badForce := &cobra.Command{} + badForce.Flags().String("input", "x", "") + badForce.Flags().Bool("base64", false, "") + badForce.Flags().String("force", "", "") + if err := importCmd.RunE(badForce, nil); err == nil { + t.Fatal("invalid force flag should fail") + } + _, out, _ = authCoverageRoot(importCmd, "table", false) + if err := importCmd.RunE(importCmd, nil); err == nil { + t.Fatal("missing input should fail") + } + _ = importCmd.Flags().Set("input", "bundle") + authPortableTargetPopulated = func(string) bool { return true } + if err := importCmd.RunE(importCmd, nil); err == nil { + t.Fatal("populated target should require force") + } + authPortableTargetPopulated = func(string) bool { return false } + authReadFile = func(string) ([]byte, error) { return nil, errors.New("read") } + if err := importCmd.RunE(importCmd, nil); err == nil { + t.Fatal("read failure should propagate") + } + authReadFile = func(string) ([]byte, error) { return []byte("%%%"), nil } + _ = importCmd.Flags().Set("base64", "true") + if err := importCmd.RunE(importCmd, nil); err == nil { + t.Fatal("invalid base64 should fail") + } + authReadFile = func(string) ([]byte, error) { return []byte("YnVuZGxl"), nil } + authImportPortableBundle = func(string, io.Reader) (authpkg.PortableImportReport, error) { + return authpkg.PortableImportReport{}, errors.New("import") + } + if err := importCmd.RunE(importCmd, nil); err == nil { + t.Fatal("import failure should propagate") + } + authImportPortableBundle = func(string, io.Reader) (authpkg.PortableImportReport, error) { + return authpkg.PortableImportReport{BundleOS: "other", OSMismatch: true}, nil + } + if err := importCmd.RunE(importCmd, nil); err != nil { + t.Fatal(err) + } + authImportPortableBundle = func(string, io.Reader) (authpkg.PortableImportReport, error) { + return authpkg.PortableImportReport{}, nil + } + if err := importCmd.RunE(importCmd, nil); err != nil { + t.Fatal(err) + } + + exchange := newAuthExchangeCommand() + badCode := &cobra.Command{} + badCode.Flags().Bool("code", false, "") + if err := exchange.RunE(badCode, nil); err == nil { + t.Fatal("invalid code flag should fail") + } + badUID := &cobra.Command{} + badUID.Flags().String("code", "code", "") + badUID.Flags().Bool("uid", false, "") + if err := exchange.RunE(badUID, nil); err == nil { + t.Fatal("invalid uid flag should fail") + } + _, out, _ = authCoverageRoot(exchange, "table", false) + if err := exchange.RunE(exchange, nil); err == nil { + t.Fatal("missing code should fail") + } + _ = exchange.Flags().Set("code", "code") + authOAuthExchange = func(*authpkg.OAuthProvider, context.Context, string, string) (*authpkg.TokenData, error) { + return nil, errors.New("exchange") + } + if err := exchange.RunE(exchange, nil); err == nil { + t.Fatal("exchange error should propagate") + } + authOAuthExchange = func(*authpkg.OAuthProvider, context.Context, string, string) (*authpkg.TokenData, error) { + return &authpkg.TokenData{CorpID: "ding", ExpiresAt: time.Now().Add(time.Hour)}, nil + } + _ = exchange.Flags().Set("uid", " user ") + if err := exchange.RunE(exchange, nil); err != nil || !strings.Contains(out.String(), "ding") { + t.Fatalf("exchange = %q, %v", out.String(), err) + } + + reset := newAuthResetCommand() + _, out, _ = authCoverageRoot(reset, "table", false) + authDeleteAllTokenData = func(string) error { return errors.New("reset") } + if err := reset.RunE(reset, nil); err == nil { + t.Fatal("reset delete should fail") + } + removed := 0 + authDeleteAllTokenData = func(string) error { return nil } + authRemove = func(string) error { removed++; return errors.New("ignored") } + authDeleteAppConfig = func(string) error { removed++; return errors.New("ignored") } + edition.Override(&edition.Hooks{}) + if err := reset.RunE(reset, nil); err != nil || removed != 3 || !strings.Contains(out.String(), "重新登录") { + t.Fatalf("reset = %q, %v, removed=%d", out.String(), err, removed) + } + edition.Override(&edition.Hooks{IsEmbedded: true}) + reset = newAuthResetCommand() + _, out, _ = authCoverageRoot(reset, "table", false) + if err := reset.RunE(reset, nil); err != nil || strings.Contains(out.String(), "重新登录") { + t.Fatalf("embedded reset = %q, %v", out.String(), err) + } +} diff --git a/internal/app/cache_command.go b/internal/app/cache_command.go index c15c93e7..50f40007 100644 --- a/internal/app/cache_command.go +++ b/internal/app/cache_command.go @@ -65,10 +65,8 @@ func printCacheCompatNotice(cmd *cobra.Command, command string) error { case "", "json": return json.NewEncoder(cmd.OutOrStdout()).Encode(notice) case "pretty": - data, err := json.MarshalIndent(notice, "", " ") - if err != nil { - return err - } + data, _ := json.MarshalIndent(notice, "", " ") + var err error _, err = fmt.Fprintln(cmd.OutOrStdout(), string(data)) return err default: diff --git a/internal/app/config.go b/internal/app/config.go index 6606dc84..bcf1a394 100644 --- a/internal/app/config.go +++ b/internal/app/config.go @@ -33,8 +33,11 @@ func init() { // Build-time variables injected via ldflags when available. var ( - buildTime = "unknown" - gitCommit = "unknown" + buildTime = "unknown" + gitCommit = "unknown" + userHomeDir = os.UserHomeDir + executablePath = os.Executable + evaluateSymlink = filepath.EvalSymlinks ) func defaultConfigDir() string { @@ -44,7 +47,7 @@ func defaultConfigDir() string { if fn := edition.Get().ConfigDir; fn != nil { return fn() } - homeDir, err := os.UserHomeDir() + homeDir, err := userHomeDir() if err != nil { return exeRelativeConfigDir() } @@ -52,11 +55,11 @@ func defaultConfigDir() string { } func exeRelativeConfigDir() string { - exePath, err := os.Executable() + exePath, err := executablePath() if err != nil { return ".dws" } - realPath, err := filepath.EvalSymlinks(exePath) + realPath, err := evaluateSymlink(exePath) if err != nil { realPath = exePath } diff --git a/internal/app/coverage_edges_test.go b/internal/app/coverage_edges_test.go index 4c1d1a96..4805ab0e 100644 --- a/internal/app/coverage_edges_test.go +++ b/internal/app/coverage_edges_test.go @@ -73,6 +73,7 @@ func TestToolCallerAdapterCoverage(t *testing.T) { if adapter.Format() != "raw" || !adapter.DryRun() || adapter.Fields() != "a" || adapter.JQ() != ".a" { t.Fatal("adapter flags were not forwarded") } + adapter.flags.DryRun = false runner.err = errors.New("runner failure") if _, err := adapter.CallTool(context.Background(), "doc", "get", map[string]any{"x": 1}); !errors.Is(err, runner.err) { @@ -1514,7 +1515,7 @@ func TestPersonalEventPureCoverage(t *testing.T) { } args := personalBusSpawnArgs(identity, "custom", "https://ticket") - if len(args) != 8 || len(personalBusSpawnArgs(identity, "", "")) != 4 { + if len(args) != 10 || len(personalBusSpawnArgs(identity, "", "")) != 6 { t.Fatalf("personal bus args = %#v", args) } if personalEventControlBaseURL(" https://control/ ", "") != "https://control" || personalEventStreamTicketURL(" https://ticket/ ", "") != "https://ticket" { diff --git a/internal/app/coverage_more_test.go b/internal/app/coverage_more_test.go index 4850fef9..bf0332d1 100644 --- a/internal/app/coverage_more_test.go +++ b/internal/app/coverage_more_test.go @@ -7,7 +7,6 @@ import ( "io" "net/http" "net/http/httptest" - "os" "path/filepath" "strings" "testing" @@ -15,11 +14,7 @@ import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/apiclient" authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth" - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain" - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin" - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport" - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes" "github.com/spf13/cobra" ) @@ -65,132 +60,13 @@ func appRPCServer(t *testing.T, initOK, listOK bool) *httptest.Server { })) } -func TestDynamicServerAndHelperFetcherCoverage(t *testing.T) { - p := &plugin.Plugin{Manifest: plugin.Manifest{Name: "demo-plugin", Description: "demo"}} - timeouts := pluginColdTimeouts{httpNoAuth: time.Second, httpAuth: time.Second, stdio: time.Second} - runner := &coverageRunner{} - - for _, tc := range []struct { - initOK bool - listOK bool - want int - }{ - {false, true, 0}, - {true, false, 0}, - {true, true, 1}, - } { - srv := appRPCServer(t, tc.initOK, tc.listOK) - desc := mcptypes.ServerDescriptor{Key: "server", Endpoint: srv.URL, CLI: mcptypes.CLIOverlay{ID: "demo"}} - client := transport.NewClient(nil) - client.TrustedDomains = []string{"127.0.0.1"} - tools := discoverHTTPTools(p, desc, client, timeouts) - if len(tools) != tc.want { - t.Fatalf("HTTP tools = %d, want %d", len(tools), tc.want) - } - _ = registerHTTPServer(p, desc, client, runner, timeouts) - srv.Close() - } - if got := buildHTTPCommandsFromTools(mcptypes.ServerDescriptor{}, nil, runner); got != nil { - t.Fatalf("removed HTTP command builder = %#v", got) - } - base := transport.NewClient(nil) - if buildPluginAuthClient(base, mcptypes.ServerDescriptor{AuthHeaders: map[string]string{"X": "Y"}}) != base { - t.Fatal("client without bearer token was copied") - } - authClient := buildPluginAuthClient(base, mcptypes.ServerDescriptor{Endpoint: "https://api.example.test", AuthHeaders: map[string]string{"Authorization": "Bearer token", "X": "Y"}}) - if authClient == base || authClient.AuthToken != "token" || len(authClient.TrustedDomains) != 2 || authClient.ExtraHeaders["X"] != "Y" { - t.Fatalf("plugin auth client = %#v", authClient) - } +func TestPluginAuthCoverage(t *testing.T) { registerPluginAuthFromHeaders(mcptypes.ServerDescriptor{Key: "fallback", Endpoint: "%", AuthHeaders: map[string]string{"Authorization": "token"}}) registerPluginAuthFromHeaders(mcptypes.ServerDescriptor{Key: "server", Endpoint: "https://x.test", CLI: mcptypes.CLIOverlay{ID: "cli"}, AuthHeaders: map[string]string{"Authorization": "Bearer token", "X": "Y"}}) registerPluginAuthFromHeaders(mcptypes.ServerDescriptor{Key: "none"}) if got, ok := LookupPluginAuth("cli"); !ok || got == nil || got.Token != "token" { t.Fatalf("registered plugin auth = %#v, %v", got, ok) } - - server := appRPCServer(t, true, true) - oldEdition := edition.Get() - edition.Override(&edition.Hooks{StaticServers: func() []edition.ServerInfo { - return []edition.ServerInfo{{ID: "devdoc", Endpoint: server.URL}} - }}) - t.Cleanup(func() { edition.Override(oldEdition); server.Close() }) - fetcher := newHelperToolFetcher() - for range 2 { - schemas, err := fetcher(context.Background(), "devdoc") - if err != nil || schemas["tool"].Name != "tool" { - t.Fatalf("helper schemas = %#v, %v", schemas, err) - } - } - if _, err := fetcher(context.Background(), "missing"); err == nil { - t.Fatal("missing helper source succeeded") - } - if _, err := helperSourceEndpoint("op-app"); err != nil { - t.Fatal(err) - } - if got := inputSchemaProperties(nil); got == nil || len(got) != 0 { - t.Fatalf("nil properties = %#v", got) - } - if got := inputSchemaProperties(map[string]any{"properties": "bad"}); got == nil || len(got) != 0 { - t.Fatalf("bad properties = %#v", got) - } - if got := inputSchemaRequired(nil); got != nil { - t.Fatalf("nil required = %#v", got) - } - if got := inputSchemaRequired(map[string]any{"required": "bad"}); got != nil { - t.Fatalf("bad required = %#v", got) - } -} - -func TestStdioDiscoveryAndRegistryCoverage(t *testing.T) { - p := &plugin.Plugin{Manifest: plugin.Manifest{Name: "stdio-plugin", Description: "demo"}} - timeouts := pluginColdTimeouts{stdio: 2 * time.Second} - notStarted := transport.NewStdioClient("ignored", nil, nil) - if got := discoverStdioTools(p, plugin.StdioServerClient{Key: "bad", Client: notStarted}, timeouts); got != nil { - t.Fatalf("unstarted stdio tools = %#v", got) - } - - script := filepath.Join(t.TempDir(), "server.sh") - body := `#!/bin/sh -while IFS= read -r line; do - case "$line" in - *initialize*) printf '%s\n' '{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-03-26"}}' ;; - *tools/list*) printf '%s\n' '{"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"echo","description":"echo","inputSchema":{}}]}}' ;; - esac -done -` - if err := os.WriteFile(script, []byte(body), 0o700); err != nil { - t.Fatal(err) - } - client := transport.NewStdioClient(script, nil, nil) - if err := client.Start(context.Background()); err != nil { - t.Fatal(err) - } - defer client.Stop() - sc := plugin.StdioServerClient{Key: "local", Client: client} - tools := discoverStdioTools(p, sc, timeouts) - if len(tools) != 1 { - t.Fatalf("stdio tools = %#v", tools) - } - if got := registerStdioServer(p, sc, &coverageRunner{}, timeouts); got != nil { - t.Fatalf("stdio commands = %#v", got) - } - if got := buildStdioCommands(p, sc, nil, &coverageRunner{}); got != nil { - t.Fatalf("empty stdio commands = %#v", got) - } - if got, ok := LookupStdioClient("local"); !ok || got != client { - t.Fatalf("stdio suffix lookup = %#v, %v", got, ok) - } - if !StopStdioClient("stdio-plugin/local") || StopStdioClient("missing") { - t.Fatal("stdio stop result mismatch") - } - RegisterStdioClient("one", transport.NewStdioClient("ignored", nil, nil)) - RegisterStdioClient("two", transport.NewStdioClient("ignored", nil, nil)) - StopStdioClientsByPlugin("missing") - StopStdioClientsByPlugin("") - StopAllStdioClients() - if newPipelineEngine() == nil { - t.Fatal("pipeline engine is nil") - } } func TestRawAPIAndTokenCoverage(t *testing.T) { @@ -333,11 +209,6 @@ func TestRootUtilityAndTimingCoverage(t *testing.T) { if !argsChanged([]string{"a"}, []string{"b"}) || argsChanged([]string{"a"}, []string{"a"}) { t.Fatal("argsChanged mismatch") } - for _, raw := range []string{"", "bad", "0", "15ms"} { - t.Setenv(cli.PluginColdTimeoutEnv, raw) - _ = resolvePluginColdTimeouts() - } - cmd := &cobra.Command{Use: "root"} cmd.SetContext(context.Background()) cmd.Flags().String("output", "", "") diff --git a/internal/app/direct_runtime.go b/internal/app/direct_runtime.go index 0396c2a5..8372a1f6 100644 --- a/internal/app/direct_runtime.go +++ b/internal/app/direct_runtime.go @@ -309,9 +309,6 @@ func editionServerEndpoint(productID string) (string, bool) { return "", false } hooks := edition.Get() - if hooks == nil { - return "", false - } if endpoint, ok := endpointFromEditionServers(productID, hooks.StaticServers); ok { return endpoint, true } diff --git a/internal/app/doctor_command.go b/internal/app/doctor_command.go index 209f6248..a99bb635 100644 --- a/internal/app/doctor_command.go +++ b/internal/app/doctor_command.go @@ -30,7 +30,14 @@ import ( "github.com/spf13/cobra" ) -var doctorKeychainDiagnose = keychain.Diagnose +var ( + doctorKeychainDiagnose = keychain.Diagnose + doctorAuthStatus = (*authpkg.OAuthProvider).Status + doctorAuthAccessToken = (*authpkg.OAuthProvider).GetAccessToken + doctorHTTPDo = (*http.Client).Do + doctorFetchLatestRelease = func() (*upgrade.ReleaseInfo, error) { return upgrade.NewClient().FetchLatestRelease() } + doctorNeedsUpgrade = upgrade.NeedsUpgrade +) // checkStatus represents the outcome of a single doctor check. type checkStatus string @@ -136,7 +143,7 @@ func doctorCheckAuth(ctx context.Context, w io.Writer, jsonOut bool) checkResult provider := authpkg.NewOAuthProvider(configDir, nil) configureOAuthProviderCompatibility(provider, configDir) - data, err := provider.Status() + data, err := doctorAuthStatus(provider) if err != nil || data == nil { if diagnostic := authStatusDiagnosticFromError(err); diagnostic != nil { r := checkResult{ @@ -164,7 +171,7 @@ func doctorCheckAuth(ctx context.Context, w io.Writer, jsonOut bool) checkResult if data.IsAccessTokenValid() || data.IsRefreshTokenValid() { if !data.IsAccessTokenValid() { refreshCtx, cancel := context.WithTimeout(ctx, 15*time.Second) - _, refreshErr := provider.GetAccessToken(refreshCtx) + _, refreshErr := doctorAuthAccessToken(provider, refreshCtx) cancel() if refreshErr != nil { r := checkResult{ @@ -263,7 +270,7 @@ func doctorCheckNetwork(ctx context.Context, w io.Writer, jsonOut bool, timeout return r } - resp, err := httpClient.Do(req) + resp, err := doctorHTTPDo(httpClient, req) latency := time.Since(start) if err != nil { r := checkResult{ @@ -317,8 +324,7 @@ func doctorCheckVersion(w io.Writer, jsonOut bool, timeout time.Duration) checkR currentVer := version - client := upgrade.NewClient() - latest, err := client.FetchLatestRelease() + latest, err := doctorFetchLatestRelease() if err != nil { r := checkResult{ Name: "version", @@ -332,7 +338,7 @@ func doctorCheckVersion(w io.Writer, jsonOut bool, timeout time.Duration) checkR return r } - if upgrade.NeedsUpgrade(currentVer, latest.Version) { + if doctorNeedsUpgrade(currentVer, latest.Version) { r := checkResult{ Name: "version", Status: statusWarn, diff --git a/internal/app/doctor_full_coverage_test.go b/internal/app/doctor_full_coverage_test.go new file mode 100644 index 00000000..fc5ee350 --- /dev/null +++ b/internal/app/doctor_full_coverage_test.go @@ -0,0 +1,133 @@ +package app + +import ( + "bytes" + "context" + "errors" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + "time" + + authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain" + upgradepkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" +) + +func TestDoctorRemainingCoverage(t *testing.T) { + oldEdition := edition.Get() + oldDiagnose := doctorKeychainDiagnose + oldStatus := doctorAuthStatus + oldAccess := doctorAuthAccessToken + oldHTTP := doctorHTTPDo + oldLatest := doctorFetchLatestRelease + oldNeeds := doctorNeedsUpgrade + oldRead := timingReadFile + t.Cleanup(func() { + edition.Override(oldEdition) + doctorKeychainDiagnose = oldDiagnose + doctorAuthStatus = oldStatus + doctorAuthAccessToken = oldAccess + doctorHTTPDo = oldHTTP + doctorFetchLatestRelease = oldLatest + doctorNeedsUpgrade = oldNeeds + timingReadFile = oldRead + }) + edition.Override(&edition.Hooks{}) + doctorKeychainDiagnose = func() keychain.Diagnostic { return keychain.Diagnostic{OK: true, Message: "ok"} } + buf := &bytes.Buffer{} + + doctorAuthStatus = func(*authpkg.OAuthProvider) (*authpkg.TokenData, error) { return nil, nil } + if got := doctorCheckAuth(context.Background(), buf, false); got.Status != statusFail || got.Hint == "" { + t.Fatalf("missing auth = %#v", got) + } + edition.Override(&edition.Hooks{IsEmbedded: true}) + if got := doctorCheckAuth(context.Background(), io.Discard, true); got.Status != statusFail || got.Hint != "" { + t.Fatalf("embedded missing auth = %#v", got) + } + edition.Override(&edition.Hooks{}) + now := time.Now() + valid := &authpkg.TokenData{AccessToken: "a", ExpiresAt: now.Add(time.Hour)} + doctorAuthStatus = func(*authpkg.OAuthProvider) (*authpkg.TokenData, error) { return valid, nil } + if got := doctorCheckAuth(context.Background(), buf, false); got.Status != statusPass { + t.Fatalf("valid auth = %#v", got) + } + refresh := &authpkg.TokenData{RefreshToken: "r", RefreshExpAt: now.Add(time.Hour)} + doctorAuthStatus = func(*authpkg.OAuthProvider) (*authpkg.TokenData, error) { return refresh, nil } + doctorAuthAccessToken = func(*authpkg.OAuthProvider, context.Context) (string, error) { return "", errors.New("refresh") } + if got := doctorCheckAuth(context.Background(), buf, false); got.Status != statusWarn { + t.Fatalf("refresh failure = %#v", got) + } + doctorAuthAccessToken = func(*authpkg.OAuthProvider, context.Context) (string, error) { return "a", nil } + if got := doctorCheckAuth(context.Background(), buf, false); got.Status != statusPass { + t.Fatalf("refresh success = %#v", got) + } + expired := &authpkg.TokenData{AccessToken: "a", ExpiresAt: now.Add(-time.Hour)} + doctorAuthStatus = func(*authpkg.OAuthProvider) (*authpkg.TokenData, error) { return expired, nil } + if got := doctorCheckAuth(context.Background(), buf, false); got.Status != statusFail || got.Hint == "" { + t.Fatalf("expired auth = %#v", got) + } + + configDir := t.TempDir() + t.Setenv("DWS_CONFIG_DIR", configDir) + if err := os.WriteFile(filepath.Join(configDir, "mcp_url"), []byte(":"), 0o600); err != nil { + t.Fatal(err) + } + if got := doctorCheckNetwork(context.Background(), buf, false, time.Second); got.Status != statusFail { + t.Fatalf("invalid network URL = %#v", got) + } + if err := os.WriteFile(filepath.Join(configDir, "mcp_url"), []byte("https://example.test"), 0o600); err != nil { + t.Fatal(err) + } + doctorHTTPDo = func(*http.Client, *http.Request) (*http.Response, error) { return nil, errors.New("network") } + if got := doctorCheckNetwork(context.Background(), buf, false, time.Second); got.Status != statusFail { + t.Fatalf("network failure = %#v", got) + } + doctorHTTPDo = func(*http.Client, *http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("ok"))}, nil + } + if got := doctorCheckNetwork(context.Background(), buf, false, time.Second); got.Status != statusPass { + t.Fatalf("network success = %#v", got) + } + + doctorFetchLatestRelease = func() (*upgradepkg.ReleaseInfo, error) { return nil, errors.New("latest") } + if got := doctorCheckVersion(buf, false, time.Second); got.Status != statusFail { + t.Fatalf("version failure = %#v", got) + } + doctorFetchLatestRelease = func() (*upgradepkg.ReleaseInfo, error) { return &upgradepkg.ReleaseInfo{Version: "99.0.0"}, nil } + doctorNeedsUpgrade = func(string, string) bool { return true } + if got := doctorCheckVersion(buf, false, time.Second); got.Status != statusWarn { + t.Fatalf("version warning = %#v", got) + } + doctorNeedsUpgrade = func(string, string) bool { return false } + if got := doctorCheckVersion(buf, false, time.Second); got.Status != statusPass { + t.Fatalf("version pass = %#v", got) + } + + doctorAuthStatus = func(*authpkg.OAuthProvider) (*authpkg.TokenData, error) { return valid, nil } + doctorFetchLatestRelease = func() (*upgradepkg.ReleaseInfo, error) { return &upgradepkg.ReleaseInfo{Version: version}, nil } + timingReadFile = func(string) ([]byte, error) { + return []byte(`{"command":"test","timestamp":"2026-01-01T00:00:00Z","phases":[]}`), nil + } + doctor := newDoctorCommand() + doctor.SetContext(context.Background()) + doctor.SetOut(io.Discard) + _ = doctor.Flags().Set("json", "true") + _ = doctor.Flags().Set("perf", "true") + _ = doctor.Flags().Set("timeout", "0") + if err := doctor.RunE(doctor, nil); err != nil { + t.Fatal(err) + } + + doctorAuthStatus = func(*authpkg.OAuthProvider) (*authpkg.TokenData, error) { return nil, errors.New("not logged in") } + doctor = newDoctorCommand() + doctor.SetContext(context.Background()) + doctor.SetOut(io.Discard) + if err := doctor.RunE(doctor, nil); err == nil { + t.Fatal("doctor failures should return an error") + } +} diff --git a/internal/app/event_command.go b/internal/app/event_command.go index f73b8176..ae090358 100644 --- a/internal/app/event_command.go +++ b/internal/app/event_command.go @@ -44,6 +44,31 @@ import ( "github.com/spf13/cobra" ) +var ( + eventRunPersonalConsume = runPersonalEventConsume + eventRunPersonalList = runPersonalEventList + eventRunPersonalStatus = runPersonalEventStatus + eventRunPersonalStop = runPersonalEventStop + eventNormalizeAs = normalizeEventAs + eventResolveCredentials = resolveEventCredentials + eventConsumeRun = consume.Run + eventRunForeground = runForegroundBus + eventNewEventSource = newEventSource + eventNewDingtalkSource = source.New + eventResolveAccessToken = ResolveAuxiliaryAccessToken + eventBusRun = bus.Run + eventReadyFDFromEnv = busctl.ReadyFDFromEnv + eventResolvePersonal = resolvePersonalEventIdentity + eventNewPersonalSource = newPersonalStreamSource + eventMkdirAll = os.MkdirAll + eventOpenFile = os.OpenFile + eventEnumerateBuses = busctl.EnumerateBuses + eventFindBus = busctl.FindBusByClientID + eventQueryEntry = busctl.QueryEntry + eventStopBus = busctl.Stop + eventResolveAppCredentials = authpkg.ResolveAppCredentialsStrict +) + // newEventCommand returns the `event` parent command and all its subcommands. // Wired into root.go's utilityCommands list. func newEventCommand() *cobra.Command { @@ -112,7 +137,7 @@ SIGTERM、关 stdin,或先用 dws event stop --dry-run 预览 Args: cobra.MaximumNArgs(1), DisableAutoGenTag: true, RunE: func(c *cobra.Command, args []string) error { - as, err := normalizeEventAs(asIdentity) + as, err := eventNormalizeAs(asIdentity) if err != nil { return err } @@ -135,7 +160,7 @@ SIGTERM、关 stdin,或先用 dws event stop --dry-run 预览 personalOpts.StreamTicketMode = streamOpts.Mode personalOpts.StreamTicketURL = streamOpts.TicketURL personalOpts.StreamSourceID = streamOpts.SourceID - return runPersonalEventConsume(c, personalOpts) + return eventRunPersonalConsume(c, personalOpts) } if personalOpts.DebugRawEvents { return fmt.Errorf("event consume: --debug-raw-events is only supported with --as user") @@ -164,7 +189,7 @@ SIGTERM、关 stdin,或先用 dws event stop --dry-run 预览 // Portal ticket normal mode uses portal-managed app credentials, so // local ClientSecret is intentionally not required there. configDir := defaultConfigDir() - clientID, clientSecret, err := resolveEventCredentials(configDir, streamOpts) + clientID, clientSecret, err := eventResolveCredentials(configDir, streamOpts) if err != nil { return fmt.Errorf("event consume: %w", err) } @@ -239,9 +264,9 @@ SIGTERM、关 stdin,或先用 dws event stop --dry-run 预览 // Step 6: foreground mode runs the bus in-process. Otherwise // consume.Run discovers / forks the bus and dials it. if foreground { - return runForegroundBus(ctx, cfg, configDir, clientSecret, streamOpts) + return eventRunForeground(ctx, cfg, configDir, clientSecret, streamOpts) } - return consume.Run(ctx, cfg) + return eventConsumeRun(ctx, cfg) }, } @@ -324,7 +349,7 @@ SIGTERM、关 stdin,或先用 dws event stop --dry-run 预览 // can run `dws event consume` from another shell to consume the events. // v2 may add a "foreground + in-process consumer" combined mode. func runForegroundBus(ctx context.Context, cfg consume.Config, configDir, clientSecret string, streamOpts eventStreamTicketOptions) error { - src, err := newEventSource(ctx, configDir, cfg.ClientID, clientSecret, streamOpts) + src, err := eventNewEventSource(ctx, configDir, cfg.ClientID, clientSecret, streamOpts) if err != nil { return err } @@ -339,7 +364,7 @@ func runForegroundBus(ctx context.Context, cfg consume.Config, configDir, client Logger: slog.Default(), } bus.ApplyEnvTuning(&busCfg) - return bus.Run(ctx, busCfg) + return eventBusRun(ctx, busCfg) } type eventStreamTicketOptions struct { @@ -389,13 +414,13 @@ func eventStreamBusID(streamOpts eventStreamTicketOptions) string { func newEventSource(ctx context.Context, configDir, clientID, clientSecret string, streamOpts eventStreamTicketOptions) (*source.DingtalkSource, error) { if !streamOpts.enabled() { - return source.New(source.Config{ + return eventNewDingtalkSource(source.Config{ ClientID: clientID, ClientSecret: clientSecret, }) } - token, err := ResolveAuxiliaryAccessToken(ctx, configDir, "") + token, err := eventResolveAccessToken(ctx, configDir, "") if err != nil { return nil, fmt.Errorf("event stream ticket: resolve user token: %w", err) } @@ -410,7 +435,7 @@ func newEventSource(ctx context.Context, configDir, clientID, clientSecret strin portalClientSecret = "" } - return source.New(source.Config{ + return eventNewDingtalkSource(source.Config{ ClientID: portalClientID, ClientSecret: portalClientSecret, PortalTicket: &source.PortalTicketConfig{ @@ -474,7 +499,7 @@ func newEventBusCommand() *cobra.Command { // Acquire ReadyPipe early so pre-bus.Run failures can signal // 'E' to the parent process instead of silently dying. - readyPipe := busctl.ReadyFDFromEnv() + readyPipe := eventReadyFDFromEnv() failEarly := func(err error) error { if readyPipe != nil { // 'E' signals failure; the trailing text lets the parent @@ -495,7 +520,7 @@ func newEventBusCommand() *cobra.Command { sourceKind = dwsevent.SourceKindAppStream } if sourceKind == dwsevent.SourceKindPersonalStream { - identity, err := resolvePersonalEventIdentity(ctx, configDir, streamOpts.SourceID) + identity, err := eventResolvePersonal(ctx, configDir, streamOpts.SourceID) if err != nil { return failEarly(fmt.Errorf("event _bus: %w", err)) } @@ -506,7 +531,7 @@ func newEventBusCommand() *cobra.Command { editionName := editionNameOrDefault() workDir := eventWorkDir(configDir, editionName, dwsevent.SourceKindPersonalStream, identityHash) endpoint := defaultIPCEndpoint(workDir, editionName, dwsevent.SourceKindPersonalStream, identityHash) - src, err := newPersonalStreamSource(ctx, personalStreamSourceOptions{ + src, err := eventNewPersonalSource(ctx, personalStreamSourceOptions{ ConfigDir: configDir, Identity: identity, TicketMode: streamOpts.Mode, @@ -516,8 +541,8 @@ func newEventBusCommand() *cobra.Command { if err != nil { return failEarly(err) } - if err := os.MkdirAll(workDir, config.DirPerm); err == nil { - if lf, ferr := os.OpenFile(filepath.Join(workDir, "bus.log"), + if err := eventMkdirAll(workDir, config.DirPerm); err == nil { + if lf, ferr := eventOpenFile(filepath.Join(workDir, "bus.log"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, config.FilePerm); ferr == nil { defer lf.Close() slog.SetDefault(slog.New(slog.NewTextHandler(lf, &slog.HandlerOptions{Level: slog.LevelInfo}))) @@ -537,10 +562,10 @@ func newEventBusCommand() *cobra.Command { Logger: slog.Default(), } bus.ApplyEnvTuning(&busCfg) - return bus.Run(ctx, busCfg) + return eventBusRun(ctx, busCfg) } - resolvedID, secret, err := resolveEventCredentials(configDir, streamOpts) + resolvedID, secret, err := eventResolveCredentials(configDir, streamOpts) if err != nil { return failEarly(fmt.Errorf("event _bus: %w", err)) } @@ -553,7 +578,7 @@ func newEventBusCommand() *cobra.Command { workDir := eventWorkDir(configDir, editionName, dwsevent.SourceKindAppStream, clientIDHash) endpoint := defaultIPCEndpoint(workDir, editionName, dwsevent.SourceKindAppStream, clientIDHash) - src, err := newEventSource(ctx, configDir, clientID, secret, streamOpts) + src, err := eventNewEventSource(ctx, configDir, clientID, secret, streamOpts) if err != nil { return failEarly(err) } @@ -562,8 +587,8 @@ func newEventBusCommand() *cobra.Command { // own log lines never pollute stdout/stderr (which busctl/Spawn // detached). Best-effort: if mkdir / open fails we fall back // to slog.Default (stderr) so we at least see startup errors. - if err := os.MkdirAll(workDir, config.DirPerm); err == nil { - if lf, ferr := os.OpenFile(filepath.Join(workDir, "bus.log"), + if err := eventMkdirAll(workDir, config.DirPerm); err == nil { + if lf, ferr := eventOpenFile(filepath.Join(workDir, "bus.log"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, config.FilePerm); ferr == nil { defer lf.Close() slog.SetDefault(slog.New(slog.NewTextHandler(lf, &slog.HandlerOptions{Level: slog.LevelInfo}))) @@ -585,7 +610,7 @@ func newEventBusCommand() *cobra.Command { // env-var tuning (only fills in fields left at zero; explicit // flags above keep precedence). bus.ApplyEnvTuning(&busCfg) - return bus.Run(ctx, busCfg) + return eventBusRun(ctx, busCfg) }, } cmd.Flags().StringVar(&clientIDOverride, "client-id", "", @@ -642,7 +667,7 @@ func newEventListCommand() *cobra.Command { Args: cobra.NoArgs, DisableAutoGenTag: true, RunE: func(c *cobra.Command, _ []string) error { - as, err := normalizeEventAs(asIdentity) + as, err := eventNormalizeAs(asIdentity) if err != nil { return err } @@ -650,7 +675,7 @@ func newEventListCommand() *cobra.Command { if err := rejectPersonalEventUnsupportedFlags(c, "all", "all-editions", "client-id"); err != nil { return fmt.Errorf("event list: %w", err) } - return runPersonalEventList(c, personalListOptions{ + return eventRunPersonalList(c, personalListOptions{ Category: category, EnabledOnly: enabledOnly, IncludePending: includePending, @@ -704,7 +729,7 @@ func newEventStatusCommand() *cobra.Command { Args: cobra.NoArgs, DisableAutoGenTag: true, RunE: func(c *cobra.Command, _ []string) error { - as, err := normalizeEventAs(asIdentity) + as, err := eventNormalizeAs(asIdentity) if err != nil { return err } @@ -713,7 +738,7 @@ func newEventStatusCommand() *cobra.Command { return fmt.Errorf("event status: %w", err) } personalOpts.Format = formatRaw - return runPersonalEventStatus(c, personalOpts) + return eventRunPersonalStatus(c, personalOpts) } if err := rejectChangedFlags(c, "user", "event", "status", "subscribe-id", "personal-event-base-url", "stream-source-id"); err != nil { return fmt.Errorf("event status: %w", err) @@ -764,14 +789,14 @@ func collectEntries(c *cobra.Command, clientIDOver string, all, allEditions bool // --all-editions trumps --all (scan whole tree) if allEditions { - entries, err := busctl.EnumerateBuses(configDir, "") + entries, err := eventEnumerateBuses(configDir, "") if err != nil { return nil, err } return queryAll(entries), nil } if all { - entries, err := busctl.EnumerateBuses(configDir, editionName) + entries, err := eventEnumerateBuses(configDir, editionName) if err != nil { return nil, err } @@ -782,14 +807,14 @@ func collectEntries(c *cobra.Command, clientIDOver string, all, allEditions bool // otherwise resolve via strict resolver. clientID := clientIDOver if clientID == "" { - resolved, _, _, _, err := authpkg.ResolveAppCredentialsStrict(configDir) + resolved, _, _, _, err := eventResolveAppCredentials(configDir) if err != nil { return nil, fmt.Errorf("event status: resolve credentials: %w (or pass --client-id)", err) } clientID = resolved } hash := dwsevent.ClientIDHash(clientID) - entry := busctl.FindBusByClientID(configDir, editionName, hash) + entry := eventFindBus(configDir, editionName, hash) if entry == nil { // No directory at all — render an empty "not running" so the user // sees a useful answer instead of an error. @@ -813,13 +838,13 @@ func collectEntries(c *cobra.Command, clientIDOver string, all, allEditions bool if entry.Meta == nil { entry.Meta = &bus.Meta{ClientID: clientID, Edition: editionName} } - return []busctl.EntryStatus{busctl.QueryEntry(*entry)}, nil + return []busctl.EntryStatus{eventQueryEntry(*entry)}, nil } func queryAll(entries []busctl.BusEntry) []busctl.EntryStatus { out := make([]busctl.EntryStatus, 0, len(entries)) for _, e := range entries { - out = append(out, busctl.QueryEntry(e)) + out = append(out, eventQueryEntry(e)) } return out } @@ -989,7 +1014,7 @@ func newEventStopCommand() *cobra.Command { Args: cobra.MaximumNArgs(1), DisableAutoGenTag: true, RunE: func(c *cobra.Command, args []string) error { - as, err := normalizeEventAs(asIdentity) + as, err := eventNormalizeAs(asIdentity) if err != nil { return err } @@ -1008,7 +1033,7 @@ func newEventStopCommand() *cobra.Command { if !eventStopConfirmed(c) { return eventStopConfirmationRequired("event stop 会取消个人事件订阅并停止本地消费") } - return runPersonalEventStop(c, opts) + return eventRunPersonalStop(c, opts) } if err := rejectChangedFlags(c, "user", "all", "personal-event-base-url", "stream-source-id"); err != nil { return fmt.Errorf("event stop: %w", err) @@ -1023,14 +1048,14 @@ func newEventStopCommand() *cobra.Command { return eventStopConfirmationRequired("event stop 会停止事件消费") } configDir := defaultConfigDir() - clientID, _, _, _, err := authpkg.ResolveAppCredentialsStrict(configDir) + clientID, _, _, _, err := eventResolveAppCredentials(configDir) if err != nil { return fmt.Errorf("event stop: %w", err) } editionName := editionNameOrDefault() clientIDHash := dwsevent.ClientIDHash(clientID) workDir := eventWorkDir(configDir, editionName, dwsevent.SourceKindAppStream, clientIDHash) - if err := busctl.Stop(busctl.StopConfig{WorkDir: workDir}); err != nil { + if err := eventStopBus(busctl.StopConfig{WorkDir: workDir}); err != nil { if errors.Is(err, busctl.ErrNotRunning) { fmt.Fprintln(c.OutOrStdout(), "bus is not running") return nil diff --git a/internal/app/event_command_full_coverage_test.go b/internal/app/event_command_full_coverage_test.go new file mode 100644 index 00000000..182026d2 --- /dev/null +++ b/internal/app/event_command_full_coverage_test.go @@ -0,0 +1,561 @@ +package app + +import ( + "context" + "errors" + "io" + "os" + "path/filepath" + "strings" + "testing" + "time" + + authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth" + dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/bus" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/busctl" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/consume" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/source" + eventtransport "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" + "github.com/spf13/cobra" +) + +func TestEventConsumeCommandAllBranchesCoverage(t *testing.T) { + oldPersonal := eventRunPersonalConsume + oldCreds, oldConsume, oldForeground := eventResolveCredentials, eventConsumeRun, eventRunForeground + oldNormalize := eventNormalizeAs + t.Cleanup(func() { + eventRunPersonalConsume = oldPersonal + eventResolveCredentials, eventConsumeRun, eventRunForeground = oldCreds, oldConsume, oldForeground + eventNormalizeAs = oldNormalize + }) + eventNormalizeAs = func(value string) (string, error) { + if strings.EqualFold(strings.TrimSpace(value), "app") { + return "app", nil + } + return normalizeEventAs(value) + } + fail := errors.New("failure") + personalCalled := false + eventRunPersonalConsume = func(*cobra.Command, personalConsumeOptions) error { personalCalled = true; return nil } + cmd := newEventConsumeCommand() + cmd.SetContext(context.Background()) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + if err := cmd.RunE(cmd, []string{"event.key"}); err != nil || !personalCalled { + t.Fatalf("personal consume = %v, called=%v", err, personalCalled) + } + + makeApp := func() *cobra.Command { + command := newEventConsumeCommand() + command.SetContext(context.Background()) + command.SetOut(io.Discard) + command.SetErr(io.Discard) + _ = command.Flags().Set("as", "app") + return command + } + invalid := newEventConsumeCommand() + _ = invalid.Flags().Set("as", "invalid") + if err := invalid.RunE(invalid, nil); err == nil { + t.Fatal("invalid event identity succeeded") + } + debug := makeApp() + _ = debug.Flags().Set("debug-raw-events", "true") + if err := debug.RunE(debug, nil); err == nil { + t.Fatal("app debug-raw-events succeeded") + } + userFlag := makeApp() + _ = userFlag.Flags().Set("subscribe-id", "sub") + if err := userFlag.RunE(userFlag, nil); err == nil { + t.Fatal("app personal flag succeeded") + } + if err := makeApp().RunE(makeApp(), []string{"event.key"}); err == nil { + t.Fatal("app event key succeeded") + } + eventResolveCredentials = func(string, eventStreamTicketOptions) (string, string, error) { return "", "", fail } + if err := makeApp().RunE(makeApp(), nil); !errors.Is(err, fail) { + t.Fatalf("event credentials error = %v", err) + } + eventResolveCredentials = func(string, eventStreamTicketOptions) (string, string, error) { return "client", "secret", nil } + badRoute := makeApp() + _ = badRoute.Flags().Set("route", "bad") + if err := badRoute.RunE(badRoute, nil); err == nil { + t.Fatal("invalid event route succeeded") + } + invalidConfig := makeApp() + _ = invalidConfig.Flags().Set("format", "json") + if err := invalidConfig.RunE(invalidConfig, nil); err == nil { + t.Fatal("unbounded JSON event stream succeeded") + } + conflict := makeApp() + conflict.Flags().String("output", "", "") + _ = conflict.Flags().Set("output-dir", t.TempDir()) + _ = conflict.Flags().Set("output", filepath.Join(t.TempDir(), "out")) + if err := conflict.RunE(conflict, nil); err == nil { + t.Fatal("event output conflict succeeded") + } + eventConsumeRun = func(context.Context, consume.Config) error { return fail } + t.Setenv(authpkg.EnvClientID, "half-set") + t.Setenv(authpkg.EnvClientSecret, "") + valid := makeApp() + _ = valid.Flags().Set("format", "table") + _ = valid.Flags().Set("max-events", "1") + if err := valid.RunE(valid, nil); !errors.Is(err, fail) { + t.Fatalf("event consume run error = %v", err) + } + eventRunForeground = func(context.Context, consume.Config, string, string, eventStreamTicketOptions) error { return fail } + foreground := makeApp() + _ = foreground.Flags().Set("foreground", "true") + if err := foreground.RunE(foreground, nil); !errors.Is(err, fail) { + t.Fatalf("foreground event error = %v", err) + } +} + +func TestEventSourcesAndForegroundCoverage(t *testing.T) { + oldNew, oldToken, oldEventSource, oldBus := eventNewDingtalkSource, eventResolveAccessToken, eventNewEventSource, eventBusRun + oldEdition := edition.Get() + t.Cleanup(func() { + eventNewDingtalkSource, eventResolveAccessToken = oldNew, oldToken + eventNewEventSource, eventBusRun = oldEventSource, oldBus + edition.Override(oldEdition) + }) + fail := errors.New("failure") + eventNewDingtalkSource = func(source.Config, ...source.SourceOption) (*source.DingtalkSource, error) { return nil, fail } + if _, err := newEventSource(context.Background(), "config", "client", "secret", eventStreamTicketOptions{}); !errors.Is(err, fail) { + t.Fatalf("SDK event source error = %v", err) + } + eventNewDingtalkSource = func(source.Config, ...source.SourceOption) (*source.DingtalkSource, error) { + return &source.DingtalkSource{}, nil + } + if _, err := newEventSource(context.Background(), "config", "client", "secret", eventStreamTicketOptions{}); err != nil { + t.Fatal(err) + } + eventResolveAccessToken = func(context.Context, string, string) (string, error) { return "", fail } + stream := eventStreamTicketOptions{Mode: "custom"} + if _, err := newEventSource(context.Background(), "config", "client", "secret", stream); !errors.Is(err, fail) { + t.Fatalf("stream token error = %v", err) + } + eventResolveAccessToken = func(context.Context, string, string) (string, error) { return " ", nil } + if _, err := newEventSource(context.Background(), "config", "client", "secret", stream); err == nil { + t.Fatal("empty stream token succeeded") + } + eventResolveAccessToken = func(context.Context, string, string) (string, error) { return "token", nil } + for _, mode := range []string{"custom", "normal"} { + if _, err := newEventSource(context.Background(), "config", "client", "secret", eventStreamTicketOptions{Mode: mode}); err != nil { + t.Fatalf("stream source %s = %v", mode, err) + } + } + + edition.Override(&edition.Hooks{Name: "test"}) + eventNewEventSource = func(context.Context, string, string, string, eventStreamTicketOptions) (*source.DingtalkSource, error) { + return nil, fail + } + if err := runForegroundBus(context.Background(), consume.Config{}, "config", "secret", eventStreamTicketOptions{}); !errors.Is(err, fail) { + t.Fatalf("foreground source error = %v", err) + } + eventNewEventSource = func(context.Context, string, string, string, eventStreamTicketOptions) (*source.DingtalkSource, error) { + return &source.DingtalkSource{}, nil + } + eventBusRun = func(context.Context, bus.Config) error { return fail } + cfg := consume.Config{WorkDir: t.TempDir(), IPCEndpoint: filepath.Join(t.TempDir(), "bus.sock"), ClientID: "client"} + if err := runForegroundBus(context.Background(), cfg, "config", "secret", eventStreamTicketOptions{}); !errors.Is(err, fail) { + t.Fatalf("foreground bus error = %v", err) + } + + home := t.TempDir() + t.Setenv("HOME", home) + if err := os.MkdirAll(filepath.Join(home, ".dws"), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(home, ".dws", "mcp_url"), []byte("https://pre-mcp.example.test"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("DWS_STREAM_SOURCE_ID", "") + if got := defaultEventStreamSourceID(); got != "pre_open_source" { + t.Fatalf("pre stream source ID = %q", got) + } +} + +func TestEventBusCommandAllBranchesCoverage(t *testing.T) { + oldReady, oldPersonal, oldPersonalSource := eventReadyFDFromEnv, eventResolvePersonal, eventNewPersonalSource + oldCreds, oldSource, oldRun := eventResolveCredentials, eventNewEventSource, eventBusRun + oldMkdir, oldOpen := eventMkdirAll, eventOpenFile + t.Cleanup(func() { + eventReadyFDFromEnv, eventResolvePersonal, eventNewPersonalSource = oldReady, oldPersonal, oldPersonalSource + eventResolveCredentials, eventNewEventSource, eventBusRun = oldCreds, oldSource, oldRun + eventMkdirAll, eventOpenFile = oldMkdir, oldOpen + }) + t.Setenv("DWS_CONFIG_DIR", t.TempDir()) + fail := errors.New("failure") + makeBus := func(kind string) *cobra.Command { + cmd := newEventBusCommand() + cmd.SetContext(context.Background()) + _ = cmd.Flags().Set("source-kind", kind) + return cmd + } + read, write, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + eventReadyFDFromEnv = func() *os.File { return write } + eventResolvePersonal = func(context.Context, string, string) (personal.Identity, error) { return personal.Identity{}, fail } + if err := makeBus(string(dwsevent.SourceKindPersonalStream)).RunE(makeBus(string(dwsevent.SourceKindPersonalStream)), nil); !errors.Is(err, fail) { + t.Fatalf("personal identity error = %v", err) + } + marker := make([]byte, 1) + _, _ = read.Read(marker) + _ = read.Close() + if marker[0] != 'E' { + t.Fatalf("ready failure marker = %q", marker) + } + eventReadyFDFromEnv = func() *os.File { return nil } + eventResolvePersonal = func(context.Context, string, string) (personal.Identity, error) { + return personal.Identity{AccessToken: "token", ClientID: "client", SourceID: "open"}, nil + } + eventNewPersonalSource = func(context.Context, personalStreamSourceOptions) (*source.PersonalSource, error) { return nil, fail } + personalCmd := makeBus(string(dwsevent.SourceKindPersonalStream)) + _ = personalCmd.Flags().Set("client-id", "override") + if err := personalCmd.RunE(personalCmd, nil); !errors.Is(err, fail) { + t.Fatalf("personal stream source error = %v", err) + } + eventNewPersonalSource = func(context.Context, personalStreamSourceOptions) (*source.PersonalSource, error) { + return &source.PersonalSource{}, nil + } + eventMkdirAll = func(string, os.FileMode) error { return nil } + eventOpenFile = func(string, int, os.FileMode) (*os.File, error) { return os.CreateTemp(t.TempDir(), "bus-log") } + eventBusRun = func(context.Context, bus.Config) error { return fail } + if err := personalCmd.RunE(personalCmd, nil); !errors.Is(err, fail) { + t.Fatalf("personal bus run error = %v", err) + } + + eventResolveCredentials = func(string, eventStreamTicketOptions) (string, string, error) { return "", "", fail } + if err := makeBus(string(dwsevent.SourceKindAppStream)).RunE(makeBus(string(dwsevent.SourceKindAppStream)), nil); !errors.Is(err, fail) { + t.Fatalf("app bus credentials error = %v", err) + } + eventResolveCredentials = func(string, eventStreamTicketOptions) (string, string, error) { return "client", "secret", nil } + eventNewEventSource = func(context.Context, string, string, string, eventStreamTicketOptions) (*source.DingtalkSource, error) { + return nil, fail + } + appCmd := makeBus("") + _ = appCmd.Flags().Set("client-id", "override") + if err := appCmd.RunE(appCmd, nil); !errors.Is(err, fail) { + t.Fatalf("app event source error = %v", err) + } + eventNewEventSource = func(context.Context, string, string, string, eventStreamTicketOptions) (*source.DingtalkSource, error) { + return &source.DingtalkSource{}, nil + } + if err := appCmd.RunE(appCmd, nil); !errors.Is(err, fail) { + t.Fatalf("app bus run error = %v", err) + } + eventMkdirAll = func(string, os.FileMode) error { return fail } + if err := appCmd.RunE(appCmd, nil); !errors.Is(err, fail) { + t.Fatalf("app bus run after log mkdir failure = %v", err) + } +} + +func TestEventListStatusCollectAndStopCoverage(t *testing.T) { + oldList, oldStatus, oldStopPersonal := eventRunPersonalList, eventRunPersonalStatus, eventRunPersonalStop + oldEnum, oldFind, oldQuery, oldStop := eventEnumerateBuses, eventFindBus, eventQueryEntry, eventStopBus + oldCreds := eventResolveAppCredentials + oldNormalize := eventNormalizeAs + t.Cleanup(func() { + eventRunPersonalList, eventRunPersonalStatus, eventRunPersonalStop = oldList, oldStatus, oldStopPersonal + eventEnumerateBuses, eventFindBus, eventQueryEntry, eventStopBus = oldEnum, oldFind, oldQuery, oldStop + eventResolveAppCredentials = oldCreds + eventNormalizeAs = oldNormalize + }) + eventNormalizeAs = func(value string) (string, error) { + if strings.EqualFold(strings.TrimSpace(value), "app") { + return "app", nil + } + return normalizeEventAs(value) + } + fail := errors.New("failure") + eventRunPersonalList = func(*cobra.Command, personalListOptions) error { return fail } + list := newEventListCommand() + list.SetOut(io.Discard) + if err := list.RunE(list, nil); !errors.Is(err, fail) { + t.Fatalf("personal list error = %v", err) + } + eventRunPersonalStatus = func(*cobra.Command, personalStatusOptions) error { return fail } + status := newEventStatusCommand() + status.SetOut(io.Discard) + if err := status.RunE(status, nil); !errors.Is(err, fail) { + t.Fatalf("personal status error = %v", err) + } + eventRunPersonalStop = func(*cobra.Command, personalStopOptions) error { return fail } + stop := newEventStopCommand() + stop.SetOut(io.Discard) + stop.Flags().Bool("yes", false, "") + _ = stop.Flags().Set("yes", "true") + if err := stop.RunE(stop, []string{"sub"}); !errors.Is(err, fail) { + t.Fatalf("personal stop error = %v", err) + } + + eventEnumerateBuses = func(string, string) ([]busctl.BusEntry, error) { return nil, fail } + if _, err := collectEntries(&cobra.Command{}, "client", false, true); !errors.Is(err, fail) { + t.Fatalf("all-editions collect error = %v", err) + } + if _, err := collectEntries(&cobra.Command{}, "client", true, false); !errors.Is(err, fail) { + t.Fatalf("all collect error = %v", err) + } + eventEnumerateBuses = func(string, string) ([]busctl.BusEntry, error) { + return []busctl.BusEntry{{ClientIDHash: "hash"}}, nil + } + eventQueryEntry = func(entry busctl.BusEntry) busctl.EntryStatus { return busctl.EntryStatus{Entry: entry} } + if got, err := collectEntries(&cobra.Command{}, "client", false, true); err != nil || len(got) != 1 { + t.Fatalf("all-editions entries = %#v, %v", got, err) + } + if got, err := collectEntries(&cobra.Command{}, "client", true, false); err != nil || len(got) != 1 { + t.Fatalf("all entries = %#v, %v", got, err) + } + eventResolveAppCredentials = func(string) (string, string, authpkg.CredentialSource, authpkg.CredentialSource, error) { + return "", "", authpkg.CredentialSourceUnknown, authpkg.CredentialSourceUnknown, fail + } + if _, err := collectEntries(&cobra.Command{}, "", false, false); !errors.Is(err, fail) { + t.Fatalf("single credentials error = %v", err) + } + eventResolveAppCredentials = func(string) (string, string, authpkg.CredentialSource, authpkg.CredentialSource, error) { + return "client", "secret", authpkg.CredentialSourceEnv, authpkg.CredentialSourceEnv, nil + } + eventFindBus = func(string, string, string) *busctl.BusEntry { return nil } + if got, err := collectEntries(&cobra.Command{}, "", false, false); err != nil || len(got) != 1 || got[0].Entry.State != busctl.BusStateNotRunning { + t.Fatalf("missing bus entry = %#v, %v", got, err) + } + eventFindBus = func(string, string, string) *busctl.BusEntry { return &busctl.BusEntry{} } + if got, err := collectEntries(&cobra.Command{}, "client", false, false); err != nil || len(got) != 1 || got[0].Entry.Meta == nil { + t.Fatalf("found bus entry = %#v, %v", got, err) + } + + appList := newEventListCommand() + appList.SetOut(io.Discard) + _ = appList.Flags().Set("as", "app") + _ = appList.Flags().Set("client-id", "client") + if err := appList.RunE(appList, nil); err != nil { + t.Fatal(err) + } + rejectedList := newEventListCommand() + _ = rejectedList.Flags().Set("as", "app") + _ = rejectedList.Flags().Set("category", "chat") + if err := rejectedList.RunE(rejectedList, nil); err == nil { + t.Fatal("app list personal flag succeeded") + } + eventFindBus = func(string, string, string) *busctl.BusEntry { + return &busctl.BusEntry{State: busctl.BusStateOrphan, Meta: &bus.Meta{ClientID: "client"}} + } + eventQueryEntry = func(entry busctl.BusEntry) busctl.EntryStatus { return busctl.EntryStatus{Entry: entry} } + appStatus := newEventStatusCommand() + appStatus.SetOut(io.Discard) + _ = appStatus.Flags().Set("as", "app") + _ = appStatus.Flags().Set("client-id", "client") + _ = appStatus.Flags().Set("fail-on-orphan", "true") + if err := appStatus.RunE(appStatus, nil); err == nil { + t.Fatal("orphan event status succeeded") + } + rejectedStatus := newEventStatusCommand() + _ = rejectedStatus.Flags().Set("as", "app") + _ = rejectedStatus.Flags().Set("event", "key") + if err := rejectedStatus.RunE(rejectedStatus, nil); err == nil { + t.Fatal("app status personal flag succeeded") + } + + appStop := func() *cobra.Command { + cmd := newEventStopCommand() + cmd.SetOut(io.Discard) + cmd.Flags().Bool("yes", true, "") + _ = cmd.Flags().Set("as", "app") + return cmd + } + changedStop := appStop() + _ = changedStop.Flags().Set("all", "true") + if err := changedStop.RunE(changedStop, nil); err == nil { + t.Fatal("app stop personal flag succeeded") + } + if err := appStop().RunE(appStop(), []string{"sub"}); err == nil { + t.Fatal("app subscribe ID succeeded") + } + eventResolveAppCredentials = func(string) (string, string, authpkg.CredentialSource, authpkg.CredentialSource, error) { + return "", "", authpkg.CredentialSourceUnknown, authpkg.CredentialSourceUnknown, fail + } + if err := appStop().RunE(appStop(), nil); !errors.Is(err, fail) { + t.Fatalf("app stop credentials error = %v", err) + } + eventResolveAppCredentials = func(string) (string, string, authpkg.CredentialSource, authpkg.CredentialSource, error) { + return "client", "secret", authpkg.CredentialSourceEnv, authpkg.CredentialSourceEnv, nil + } + eventStopBus = func(busctl.StopConfig) error { return busctl.ErrNotRunning } + if err := appStop().RunE(appStop(), nil); err != nil { + t.Fatalf("already stopped bus = %v", err) + } + eventStopBus = func(busctl.StopConfig) error { return fail } + if err := appStop().RunE(appStop(), nil); !errors.Is(err, fail) { + t.Fatalf("stop bus error = %v", err) + } + eventStopBus = func(busctl.StopConfig) error { return nil } + if err := appStop().RunE(appStop(), nil); err != nil { + t.Fatalf("stop bus success = %v", err) + } +} + +func TestEventCommandParentCoverage(t *testing.T) { + cmd := newEventCommand() + cmd.SetOut(io.Discard) + if err := cmd.RunE(cmd, nil); err != nil { + t.Fatal(err) + } + if !strings.Contains(cmd.Use, "event") { + t.Fatal("event command use changed") + } +} + +func TestEventCommandPureAndRenderBranchesCoverage(t *testing.T) { + t.Setenv(authpkg.EnvClientID, "client") + t.Setenv(authpkg.EnvClientSecret, "secret") + if got := (eventStreamTicketOptions{Mode: "custom", SourceID: " source ", TicketURL: " https://ticket "}).spawnArgs(); len(got) != 6 { + t.Fatalf("stream spawn args = %#v", got) + } + if id, secret, err := resolveEventCredentials(t.TempDir(), eventStreamTicketOptions{}); err != nil || id != "client" || secret != "secret" { + t.Fatalf("app credentials = %q, %q, %v", id, secret, err) + } + if id, secret, err := resolveEventCredentials(t.TempDir(), eventStreamTicketOptions{Mode: "normal", SourceID: "source"}); err != nil || id != "portal-ticket-normal:source" || secret != "" { + t.Fatalf("portal credentials = %q, %q, %v", id, secret, err) + } + if eventStreamTicketURL(" https://ticket ") != "https://ticket" || eventStreamSourceID(" source ") != "source" { + t.Fatal("explicit event stream routing changed") + } + t.Setenv("DWS_STREAM_SOURCE_ID", "environment") + if defaultEventStreamSourceID() != "environment" { + t.Fatal("environment stream source ID ignored") + } + + oldEdition := edition.Get() + edition.Override(&edition.Hooks{}) + t.Cleanup(func() { edition.Override(oldEdition) }) + if editionNameOrDefault() != "open" || sourceKindLabel("") != string(dwsevent.SourceKindAppStream) { + t.Fatal("default event labels changed") + } + if !strings.Contains(eventWorkDir("config", "open", "", "hash"), string(dwsevent.SourceKindAppStream)) { + t.Fatal("default event workdir changed") + } + if _, err := normalizeEventAs("bot"); err == nil { + t.Fatal("bot events became public unexpectedly") + } + flags := &cobra.Command{} + flags.Flags().Bool("all", false, "") + if err := rejectPersonalEventUnsupportedFlags(flags, "all"); err != nil { + t.Fatal(err) + } + _ = flags.Flags().Set("all", "true") + if err := rejectPersonalEventUnsupportedFlags(flags, "all"); err == nil { + t.Fatal("personal unsupported flag accepted") + } + if firstArg(nil) != "" || firstArg([]string{"value"}) != "value" || len(eventTypesWithDefault([]string{"type"})) != 1 { + t.Fatal("event helper defaults changed") + } + _ = eventTypesWithDefault(nil) + + live := &eventtransport.StatusResp{ + Bus: eventtransport.StatusBus{UptimeSecs: 12}, + SourceState: eventtransport.StatusSource{State: "connected", Source: "hook", ReconnectCount: 1}, + Consumers: []eventtransport.StatusConsumer{ + {PID: 1, Received: 2, Dropped: 3}, + {PID: 2, EventTypes: []string{"chat"}, SubscribeID: "sub"}, + }, + PerEventTypeCounters: map[string]eventtransport.Counters{"chat": {Received: 2, Dropped: 1}}, + } + entries := []busctl.EntryStatus{ + {Entry: busctl.BusEntry{State: busctl.BusStateNotRunning, ClientIDHash: "not-running"}}, + {Entry: busctl.BusEntry{State: busctl.BusStateOrphan, HolderPID: 3, Meta: &bus.Meta{ClientID: "orphan", SourceID: "source", StartedAt: time.Now()}}}, + {Entry: busctl.BusEntry{State: busctl.BusStateRunning, HolderPID: 4, Meta: &bus.Meta{ClientID: "offline", StartedAt: time.Now().Add(-time.Minute)}}}, + {Entry: busctl.BusEntry{State: busctl.BusStateRunning, HolderPID: 5, Meta: &bus.Meta{ClientID: "live"}}, Live: live}, + } + if err := renderStatus(io.Discard, entries, "text"); err != nil { + t.Fatal(err) + } + if err := renderStatus(io.Discard, entries, "json"); err != nil { + t.Fatal(err) + } + if err := renderStatus(appFailWriter{err: errors.New("write")}, entries, "json"); err == nil { + t.Fatal("status JSON write failure succeeded") + } + listEntries := []listEntry{ + {ClientIDHash: "hash", BusState: busctl.BusStateNotRunning}, + {ClientID: "client", SourceKind: dwsevent.SourceKindPersonalStream, BusState: busctl.BusStateRunning, BusPID: 2, Consumers: live.Consumers}, + } + if err := renderList(io.Discard, listEntries, "table"); err != nil { + t.Fatal(err) + } + if err := renderList(io.Discard, listEntries, "json"); err != nil { + t.Fatal(err) + } + if err := renderList(appFailWriter{err: errors.New("write")}, listEntries, "json"); err == nil { + t.Fatal("list JSON write failure succeeded") + } + if got := buildListEntry(busctl.EntryStatus{Entry: busctl.BusEntry{Meta: &bus.Meta{ClientID: "client"}}, Live: live}); len(got.Consumers) != 2 { + t.Fatalf("live list entry = %#v", got) + } +} + +func TestEventCommandClosureErrorBranchesCoverage(t *testing.T) { + oldNormalize := eventNormalizeAs + oldList, oldStatus := eventRunPersonalList, eventRunPersonalStatus + oldCreds, oldFind, oldQuery := eventResolveAppCredentials, eventFindBus, eventQueryEntry + t.Cleanup(func() { + eventNormalizeAs = oldNormalize + eventRunPersonalList, eventRunPersonalStatus = oldList, oldStatus + eventResolveAppCredentials, eventFindBus, eventQueryEntry = oldCreds, oldFind, oldQuery + }) + eventNormalizeAs = func(value string) (string, error) { + if strings.TrimSpace(value) == "app" { + return "app", nil + } + return normalizeEventAs(value) + } + personalList := newEventListCommand() + _ = personalList.Flags().Set("all", "true") + if err := personalList.RunE(personalList, nil); err == nil { + t.Fatal("personal list app flag succeeded") + } + personalStatus := newEventStatusCommand() + _ = personalStatus.Flags().Set("fail-on-orphan", "true") + if err := personalStatus.RunE(personalStatus, nil); err == nil { + t.Fatal("personal status app flag succeeded") + } + fail := errors.New("failure") + eventResolveAppCredentials = func(string) (string, string, authpkg.CredentialSource, authpkg.CredentialSource, error) { + return "", "", authpkg.CredentialSourceUnknown, authpkg.CredentialSourceUnknown, fail + } + appList := newEventListCommand() + _ = appList.Flags().Set("as", "app") + if err := appList.RunE(appList, nil); !errors.Is(err, fail) { + t.Fatalf("app list collect error = %v", err) + } + appStatus := newEventStatusCommand() + _ = appStatus.Flags().Set("as", "app") + if err := appStatus.RunE(appStatus, nil); !errors.Is(err, fail) { + t.Fatalf("app status collect error = %v", err) + } + eventResolveAppCredentials = func(string) (string, string, authpkg.CredentialSource, authpkg.CredentialSource, error) { + return "client", "secret", authpkg.CredentialSourceEnv, authpkg.CredentialSourceEnv, nil + } + eventFindBus = func(string, string, string) *busctl.BusEntry { + return &busctl.BusEntry{State: busctl.BusStateRunning, Meta: &bus.Meta{ClientID: "client"}} + } + eventQueryEntry = func(entry busctl.BusEntry) busctl.EntryStatus { return busctl.EntryStatus{Entry: entry} } + appStatus = newEventStatusCommand() + appStatus.SetOut(io.Discard) + _ = appStatus.Flags().Set("as", "app") + _ = appStatus.Flags().Set("fail-on-orphan", "true") + if err := appStatus.RunE(appStatus, nil); err != nil { + t.Fatalf("healthy status with orphan gate = %v", err) + } + appStatus = newEventStatusCommand() + appStatus.SetOut(appFailWriter{err: errors.New("write")}) + _ = appStatus.Flags().Set("as", "app") + _ = appStatus.Flags().Set("format", "json") + if err := appStatus.RunE(appStatus, nil); err == nil { + t.Fatal("status render failure should propagate") + } +} diff --git a/internal/app/event_personal_command.go b/internal/app/event_personal_command.go index 5689548a..ea9de63b 100644 --- a/internal/app/event_personal_command.go +++ b/internal/app/event_personal_command.go @@ -107,6 +107,33 @@ type personalStreamSourceOptions struct { ClientIDOverride string } +var ( + personalResolveEventIdentity = resolvePersonalEventIdentity + personalEnsureSubscription = ensurePersonalSubscription + personalGetSubscription = (*personal.Client).GetSubscription + personalCreateSubscription = (*personal.Client).CreateSubscription + personalDeleteSubscription = (*personal.Client).DeleteSubscription + personalListSubscriptions = (*personal.Client).ListSubscriptions + personalUpsertRunState = personal.UpsertRunState + personalRemoveRunStates = personal.RemoveRunStates + personalLoadRunStates = personal.LoadRunStates + personalConsumeRun = consume.Run + personalValidateConsumeConfig = consume.ValidateConfig + personalValidateNoOutputConflict = consume.ValidateNoOutputConflict + personalNewStreamSource = newPersonalStreamSource + personalBusRun = bus.Run + personalFindBusByIdentity = busctl.FindBusByIdentity + personalQueryEntry = busctl.QueryEntry + personalQueryStatus = busctl.QueryStatus + personalStopBus = busctl.Stop + personalFindProcess = os.FindProcess + personalSignalProcess = (*os.Process).Signal + personalResolveAuxiliaryAccessToken = ResolveAuxiliaryAccessToken + personalLoadTokenData = authpkg.LoadTokenData + personalClientID = authpkg.ClientID + personalResolveAppCredentialsStrict = authpkg.ResolveAppCredentialsStrict +) + func newEventSchemaCommand() *cobra.Command { var asIdentity string var formatRaw string @@ -116,13 +143,10 @@ func newEventSchemaCommand() *cobra.Command { Args: cobra.ExactArgs(1), DisableAutoGenTag: true, RunE: func(c *cobra.Command, args []string) error { - as, err := normalizeEventAs(asIdentity) + _, err := normalizeEventAs(asIdentity) if err != nil { return err } - if as != "user" { - return fmt.Errorf("event schema is only supported with --as user") - } def, ok := personal.Lookup(args[0]) if !ok { return fmt.Errorf("unknown personal event key %q", args[0]) @@ -181,7 +205,7 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro return err } configDir := defaultConfigDir() - identity, err := resolvePersonalEventIdentity(ctx, configDir, opts.StreamSourceID) + identity, err := personalResolveEventIdentity(ctx, configDir, opts.StreamSourceID) if err != nil { return fmt.Errorf("event consume --as user: %w", err) } @@ -223,18 +247,18 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro DryRun: true, } applyPersonalConsumeFilters(&cfg, opts, strings.TrimSpace(opts.SubscribeID), opts.EventKey) - return consume.Run(ctx, cfg) + return personalConsumeRun(ctx, cfg) } client := personal.NewClient(personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity) - sub, eventKey, ruleType, err := ensurePersonalSubscription(ctx, client, identity, opts) + sub, eventKey, ruleType, err := personalEnsureSubscription(ctx, client, identity, opts) if err != nil { return fmt.Errorf("event consume --as user: %w", err) } if sub.SubscribeID == "" { return fmt.Errorf("event consume --as user: server returned empty subscribe_id") } - if err := personal.UpsertRunState(workDir, personal.RunState{ + if err := personalUpsertRunState(workDir, personal.RunState{ SubscribeID: sub.SubscribeID, EventKey: eventKey, RuleType: ruleType, @@ -245,8 +269,8 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro return fmt.Errorf("event consume --as user: save run state: %w", err) } cleanup := func() { - _ = client.DeleteSubscription(context.Background(), sub.SubscribeID) - _ = personal.RemoveRunStates(workDir, []string{sub.SubscribeID}) + _ = personalDeleteSubscription(client, context.Background(), sub.SubscribeID) + _ = personalRemoveRunStates(workDir, []string{sub.SubscribeID}) } // Ownership-based cleanup (AI-subprocess contract, aligned with // lark-cli): a subscription this run CREATED is unsubscribed on exit @@ -287,16 +311,16 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro fmt.Fprintf(c.ErrOrStderr(), "debug raw events enabled: local event filters disabled\nworkdir: %s\nbus_log: %s\n", workDir, filepath.Join(workDir, "bus.log")) } - if err := consume.ValidateConfig(cfg); err != nil { + if err := personalValidateConsumeConfig(cfg); err != nil { return err } if o := c.Flags().Lookup("output"); o != nil && o.Changed { - if err := consume.ValidateNoOutputConflict(cfg, o.Value.String()); err != nil { + if err := personalValidateNoOutputConflict(cfg, o.Value.String()); err != nil { return err } } if opts.Common.Foreground { - src, err := newPersonalStreamSource(ctx, personalStreamSourceOptions{ + src, err := personalNewStreamSource(ctx, personalStreamSourceOptions{ ConfigDir: configDir, Identity: identity, TicketMode: opts.StreamTicketMode, @@ -319,13 +343,13 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro Source: src, } bus.ApplyEnvTuning(&busCfg) - err = bus.Run(ctx, busCfg) + err = personalBusRun(ctx, busCfg) if err != nil && !opts.Ephemeral { cleanup() } return err } - err = consume.Run(ctx, cfg) + err = personalConsumeRun(ctx, cfg) if err != nil && !opts.Ephemeral { cleanup() } @@ -349,7 +373,7 @@ func applyPersonalConsumeFilters(cfg *consume.Config, opts personalConsumeOption func ensurePersonalSubscription(ctx context.Context, client *personal.Client, identity personal.Identity, opts personalConsumeOptions) (*personal.Subscription, string, string, error) { if strings.TrimSpace(opts.SubscribeID) != "" { - sub, err := client.GetSubscription(ctx, opts.SubscribeID) + sub, err := personalGetSubscription(client, ctx, opts.SubscribeID) if err != nil { return nil, "", "", err } @@ -399,7 +423,7 @@ func ensurePersonalSubscription(ctx context.Context, client *personal.Client, id if opts.TTL > 0 { req.TTLSeconds = int64(opts.TTL.Seconds()) } - sub, err := client.CreateSubscription(ctx, req) + sub, err := personalCreateSubscription(client, ctx, req) if err != nil { return nil, "", "", err } @@ -412,17 +436,17 @@ func runPersonalEventStatus(c *cobra.Command, opts personalStatusOptions) error return err } configDir := defaultConfigDir() - identity, err := resolvePersonalEventIdentity(ctx, configDir, opts.StreamSourceID) + identity, err := personalResolveEventIdentity(ctx, configDir, opts.StreamSourceID) if err != nil { return fmt.Errorf("event status --as user: %w", err) } identityHash := dwsevent.IdentityHash(identity.Key()) editionName := editionNameOrDefault() workDir := eventWorkDir(configDir, editionName, dwsevent.SourceKindPersonalStream, identityHash) - entry := busctl.FindBusByIdentity(configDir, editionName, dwsevent.SourceKindPersonalStream, identityHash) + entry := personalFindBusByIdentity(configDir, editionName, dwsevent.SourceKindPersonalStream, identityHash) var qs busctl.EntryStatus if entry != nil { - qs = busctl.QueryEntry(*entry) + qs = personalQueryEntry(*entry) } else { qs = busctl.EntryStatus{Entry: busctl.BusEntry{ WorkDir: workDir, @@ -444,7 +468,7 @@ func runPersonalEventStatus(c *cobra.Command, opts personalStatusOptions) error if status == "" || status == "all" { status = "" } - subs, err := personal.NewClient(personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity).ListSubscriptions(ctx, personal.ListOptions{ + subs, err := personalListSubscriptions(personal.NewClient(personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity), ctx, personal.ListOptions{ Status: status, EventKey: opts.EventKey, SubscribeID: opts.SubscribeID, @@ -547,7 +571,7 @@ func runPersonalEventStop(c *cobra.Command, opts personalStopOptions) error { } configDir := defaultConfigDir() - identity, err := resolvePersonalEventIdentity(ctx, configDir, opts.StreamSourceID) + identity, err := personalResolveEventIdentity(ctx, configDir, opts.StreamSourceID) if err != nil { return fmt.Errorf("event stop --as user: %w", err) } @@ -561,18 +585,18 @@ func runPersonalEventStop(c *cobra.Command, opts personalStopOptions) error { } client := personal.NewClient(personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity) for _, id := range subscribeIDs { - if err := client.DeleteSubscription(ctx, id); err != nil { + if err := personalDeleteSubscription(client, ctx, id); err != nil { return fmt.Errorf("event stop --as user: cancel subscription %s: %w", id, err) } } - if err := personal.RemoveRunStates(workDir, subscribeIDs); err != nil { + if err := personalRemoveRunStates(workDir, subscribeIDs); err != nil { return fmt.Errorf("event stop --as user: update local state: %w", err) } if err := interruptPersonalConsumers(ipcEndpoint, subscribeIDs); err != nil { fmt.Fprintf(c.ErrOrStderr(), "WARN: failed to stop matching local consume process: %v\n", err) } - remaining, err := personal.LoadRunStates(workDir) + remaining, err := personalLoadRunStates(workDir) if err != nil { return fmt.Errorf("event stop --as user: load remaining local state: %w", err) } @@ -582,7 +606,7 @@ func runPersonalEventStop(c *cobra.Command, opts personalStopOptions) error { } busState := "personal bus stopped" - if err := busctl.Stop(busctl.StopConfig{WorkDir: workDir}); err != nil { + if err := personalStopBus(busctl.StopConfig{WorkDir: workDir}); err != nil { if errors.Is(err, busctl.ErrNotRunning) { busState = "personal bus is not running" } else { @@ -604,7 +628,7 @@ func personalStopTargets(workDir, explicit string, all bool) ([]string, error) { if !all { return nil, fmt.Errorf("subscribe_id is required unless --all is set") } - states, err := personal.LoadRunStates(workDir) + states, err := personalLoadRunStates(workDir) if err != nil { return nil, err } @@ -629,7 +653,7 @@ func interruptPersonalConsumers(ipcEndpoint string, subscribeIDs []string) error if ipcEndpoint == "" || len(targets) == 0 { return nil } - status, err := busctl.QueryStatus(ipcEndpoint) + status, err := personalQueryStatus(ipcEndpoint) if err != nil { return nil } @@ -644,11 +668,11 @@ func interruptPersonalConsumers(ipcEndpoint string, subscribeIDs []string) error if _, ok := signalled[consumer.PID]; ok { continue } - proc, err := os.FindProcess(consumer.PID) + proc, err := personalFindProcess(consumer.PID) if err != nil { return fmt.Errorf("find consume pid=%d: %w", consumer.PID, err) } - if err := proc.Signal(os.Interrupt); err != nil && !errors.Is(err, os.ErrProcessDone) { + if err := personalSignalProcess(proc, os.Interrupt); err != nil && !errors.Is(err, os.ErrProcessDone) { return fmt.Errorf("signal consume pid=%d: %w", consumer.PID, err) } signalled[consumer.PID] = struct{}{} @@ -665,11 +689,11 @@ func printPersonalStopResult(w io.Writer, subscribeIDs []string, single bool, bu } func resolvePersonalEventIdentity(ctx context.Context, configDir string, sourceIDOverride string) (personal.Identity, error) { - accessToken, err := ResolveAuxiliaryAccessToken(ctx, configDir, "") + accessToken, err := personalResolveAuxiliaryAccessToken(ctx, configDir, "") if err != nil { return personal.Identity{}, err } - tokenData, _ := authpkg.LoadTokenData(configDir) + tokenData, _ := personalLoadTokenData(configDir) var corpID, userID, clientID, refreshToken string if tokenData != nil { corpID = tokenData.CorpID @@ -684,10 +708,10 @@ func resolvePersonalEventIdentity(ctx context.Context, configDir string, sourceI userID = resolveRuntimeDefault(ctx, "$currentUserId") } if clientID == "" { - clientID = authpkg.ClientID() + clientID = personalClientID() } if clientID == "" { - if id, _, _, _, err := authpkg.ResolveAppCredentialsStrict(configDir); err == nil { + if id, _, _, _, err := personalResolveAppCredentialsStrict(configDir); err == nil { clientID = id } } @@ -750,7 +774,7 @@ func newPersonalStreamSource(ctx context.Context, opts personalStreamSourceOptio clientID := opts.Identity.ClientID clientSecret := "" if mode == "custom" { - resolvedID, secret, _, _, err := authpkg.ResolveAppCredentialsStrict(opts.ConfigDir) + resolvedID, secret, _, _, err := personalResolveAppCredentialsStrict(opts.ConfigDir) if err != nil { return nil, err } @@ -852,10 +876,7 @@ func personalEventStreamSourceID(raw string) string { if v := strings.TrimSpace(raw); v != "" { return v } - if v := strings.TrimSpace(edition.PersonalEventSourceID()); v != "" { - return v - } - return "open" + return strings.TrimSpace(edition.PersonalEventSourceID()) } func personalEventMCPBaseURL(configDir string) string { diff --git a/internal/app/event_personal_full_coverage_test.go b/internal/app/event_personal_full_coverage_test.go new file mode 100644 index 00000000..f1426f09 --- /dev/null +++ b/internal/app/event_personal_full_coverage_test.go @@ -0,0 +1,370 @@ +package app + +import ( + "bytes" + "context" + "errors" + "io" + "os" + "strings" + "testing" + "time" + + authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth" + dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/bus" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/busctl" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/consume" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/source" + eventtransport "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" + "github.com/spf13/cobra" +) + +func TestPersonalEventRemainingSchemaAndSubscriptionCoverage(t *testing.T) { + for _, args := range [][]string{ + {"known", "--as", "app"}, + {"not-a-real-event"}, + } { + cmd := newEventSchemaCommand() + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.SetArgs(args) + if err := cmd.Execute(); err == nil { + t.Fatalf("schema args %#v succeeded", args) + } + } + + oldGet := personalGetSubscription + oldCreate := personalCreateSubscription + t.Cleanup(func() { + personalGetSubscription = oldGet + personalCreateSubscription = oldCreate + }) + client := personal.NewClient("https://example.test", personal.Identity{}) + wantErr := errors.New("subscription") + personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) { return nil, wantErr } + if _, _, _, err := ensurePersonalSubscription(context.Background(), client, personal.Identity{}, personalConsumeOptions{SubscribeID: "sub"}); !errors.Is(err, wantErr) { + t.Fatalf("get subscription error = %v", err) + } + personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) { + return &personal.Subscription{}, nil + } + if _, _, _, err := ensurePersonalSubscription(context.Background(), client, personal.Identity{}, personalConsumeOptions{SubscribeID: "sub"}); err == nil { + t.Fatal("empty subscription event key succeeded") + } + personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) { + return &personal.Subscription{EventKey: personal.EventFromUser}, nil + } + if _, _, _, err := ensurePersonalSubscription(context.Background(), client, personal.Identity{}, personalConsumeOptions{SubscribeID: "sub"}); err == nil { + t.Fatal("private subscription event succeeded") + } + personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) { + return &personal.Subscription{EventKey: personal.EventMention}, nil + } + if _, _, rule, err := ensurePersonalSubscription(context.Background(), client, personal.Identity{}, personalConsumeOptions{SubscribeID: "sub"}); err != nil || rule == "" { + t.Fatalf("default subscription rule = %q, %v", rule, err) + } + personalCreateSubscription = func(*personal.Client, context.Context, personal.CreateSubscriptionRequest) (*personal.Subscription, error) { + return nil, wantErr + } + if _, _, _, err := ensurePersonalSubscription(context.Background(), client, personal.Identity{}, personalConsumeOptions{EventKey: personal.EventMention}); !errors.Is(err, wantErr) { + t.Fatalf("create subscription error = %v", err) + } +} + +func TestPersonalEventRemainingConsumeCoverage(t *testing.T) { + oldIdentity := personalResolveEventIdentity + oldEnsure := personalEnsureSubscription + oldUpsert := personalUpsertRunState + oldDelete := personalDeleteSubscription + oldRemove := personalRemoveRunStates + oldConsume := personalConsumeRun + oldValidate := personalValidateConsumeConfig + oldConflict := personalValidateNoOutputConflict + oldNewSource := personalNewStreamSource + oldBusRun := personalBusRun + t.Cleanup(func() { + personalResolveEventIdentity = oldIdentity + personalEnsureSubscription = oldEnsure + personalUpsertRunState = oldUpsert + personalDeleteSubscription = oldDelete + personalRemoveRunStates = oldRemove + personalConsumeRun = oldConsume + personalValidateConsumeConfig = oldValidate + personalValidateNoOutputConflict = oldConflict + personalNewStreamSource = oldNewSource + personalBusRun = oldBusRun + }) + + wantErr := errors.New("consume") + cmd := newPersonalCoverageCommand() + personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) { return personal.Identity{}, wantErr } + if err := runPersonalEventConsume(cmd, personalConsumeOptions{EventKey: personal.EventMention}); !errors.Is(err, wantErr) { + t.Fatalf("identity error = %v", err) + } + identity := personal.Identity{AccessToken: "token", CorpID: "corp", UserID: "user", ClientID: "client", SourceID: "source"} + personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) { return identity, nil } + if err := runPersonalEventConsume(cmd, personalConsumeOptions{EventKey: personal.EventMention, Common: commonConsumeOptions{RoutesRaw: []string{"bad-route"}}}); err == nil { + t.Fatal("invalid route succeeded") + } + personalConsumeRun = func(context.Context, consume.Config) error { return wantErr } + _ = cmd.Flags().Set("format", "table") + if err := runPersonalEventConsume(cmd, personalConsumeOptions{EventKey: personal.EventMention, Common: commonConsumeOptions{DryRun: true, FormatRaw: "bogus"}}); !errors.Is(err, wantErr) { + t.Fatalf("dry-run consume error = %v", err) + } + + personalEnsureSubscription = func(context.Context, *personal.Client, personal.Identity, personalConsumeOptions) (*personal.Subscription, string, string, error) { + return nil, "", "", wantErr + } + if err := runPersonalEventConsume(cmd, personalConsumeOptions{EventKey: personal.EventMention}); !errors.Is(err, wantErr) { + t.Fatalf("ensure subscription error = %v", err) + } + personalEnsureSubscription = func(context.Context, *personal.Client, personal.Identity, personalConsumeOptions) (*personal.Subscription, string, string, error) { + return &personal.Subscription{}, personal.EventMention, "at", nil + } + if err := runPersonalEventConsume(cmd, personalConsumeOptions{EventKey: personal.EventMention}); err == nil || !strings.Contains(err.Error(), "empty subscribe_id") { + t.Fatalf("empty subscription = %v", err) + } + personalEnsureSubscription = func(context.Context, *personal.Client, personal.Identity, personalConsumeOptions) (*personal.Subscription, string, string, error) { + return &personal.Subscription{SubscribeID: "sub"}, personal.EventMention, "at", nil + } + personalUpsertRunState = func(string, personal.RunState) error { return wantErr } + if err := runPersonalEventConsume(cmd, personalConsumeOptions{EventKey: personal.EventMention}); !errors.Is(err, wantErr) { + t.Fatalf("state upsert error = %v", err) + } + + deletes := 0 + personalUpsertRunState = func(string, personal.RunState) error { return nil } + personalDeleteSubscription = func(*personal.Client, context.Context, string) error { deletes++; return nil } + personalRemoveRunStates = func(string, []string) error { return nil } + personalValidateConsumeConfig = func(consume.Config) error { return wantErr } + if err := runPersonalEventConsume(cmd, personalConsumeOptions{EventKey: personal.EventMention, DebugRawEvents: true}); !errors.Is(err, wantErr) { + t.Fatalf("validate error = %v", err) + } + personalValidateConsumeConfig = func(consume.Config) error { return nil } + _ = cmd.Flags().Set("output", "file") + personalValidateNoOutputConflict = func(consume.Config, string) error { return wantErr } + if err := runPersonalEventConsume(cmd, personalConsumeOptions{EventKey: personal.EventMention}); !errors.Is(err, wantErr) { + t.Fatalf("output conflict = %v", err) + } + personalValidateNoOutputConflict = func(consume.Config, string) error { return nil } + + personalNewStreamSource = func(context.Context, personalStreamSourceOptions) (*source.PersonalSource, error) { + return nil, wantErr + } + if err := runPersonalEventConsume(cmd, personalConsumeOptions{EventKey: personal.EventMention, Common: commonConsumeOptions{Foreground: true}}); !errors.Is(err, wantErr) || deletes == 0 { + t.Fatalf("foreground source error = %v deletes=%d", err, deletes) + } + before := deletes + if err := runPersonalEventConsume(cmd, personalConsumeOptions{EventKey: personal.EventMention, Ephemeral: true, Common: commonConsumeOptions{Foreground: true}}); !errors.Is(err, wantErr) || deletes == before { + t.Fatalf("ephemeral source error = %v deletes=%d", err, deletes) + } + personalNewStreamSource = func(context.Context, personalStreamSourceOptions) (*source.PersonalSource, error) { return nil, nil } + personalBusRun = func(context.Context, bus.Config) error { return wantErr } + if err := runPersonalEventConsume(cmd, personalConsumeOptions{EventKey: personal.EventMention, Common: commonConsumeOptions{Foreground: true}}); !errors.Is(err, wantErr) { + t.Fatalf("bus run error = %v", err) + } + personalConsumeRun = func(context.Context, consume.Config) error { return wantErr } + if err := runPersonalEventConsume(cmd, personalConsumeOptions{EventKey: personal.EventMention}); !errors.Is(err, wantErr) { + t.Fatalf("background consume error = %v", err) + } +} + +func TestPersonalEventRemainingStatusStopAndInterruptCoverage(t *testing.T) { + oldIdentity := personalResolveEventIdentity + oldFindBus := personalFindBusByIdentity + oldQueryEntry := personalQueryEntry + oldList := personalListSubscriptions + oldDelete := personalDeleteSubscription + oldRemove := personalRemoveRunStates + oldLoad := personalLoadRunStates + oldStop := personalStopBus + oldQueryStatus := personalQueryStatus + oldFindProcess := personalFindProcess + oldSignal := personalSignalProcess + t.Cleanup(func() { + personalResolveEventIdentity = oldIdentity + personalFindBusByIdentity = oldFindBus + personalQueryEntry = oldQueryEntry + personalListSubscriptions = oldList + personalDeleteSubscription = oldDelete + personalRemoveRunStates = oldRemove + personalLoadRunStates = oldLoad + personalStopBus = oldStop + personalQueryStatus = oldQueryStatus + personalFindProcess = oldFindProcess + personalSignalProcess = oldSignal + }) + + wantErr := errors.New("status-stop") + cmd := newPersonalCoverageCommand() + personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) { return personal.Identity{}, wantErr } + if err := runPersonalEventStatus(cmd, personalStatusOptions{}); !errors.Is(err, wantErr) { + t.Fatalf("status identity error = %v", err) + } + if err := runPersonalEventStop(cmd, personalStopOptions{SubscribeID: "sub"}); !errors.Is(err, wantErr) { + t.Fatalf("stop identity error = %v", err) + } + identity := personal.Identity{ClientID: "client", SourceID: "source"} + personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) { return identity, nil } + entry := &busctl.BusEntry{State: busctl.BusStateRunning} + personalFindBusByIdentity = func(string, string, dwsevent.SourceKind, string) *busctl.BusEntry { return entry } + personalQueryEntry = func(busctl.BusEntry) busctl.EntryStatus { return busctl.EntryStatus{Entry: *entry} } + personalListSubscriptions = func(*personal.Client, context.Context, personal.ListOptions) ([]personal.Subscription, error) { + return nil, wantErr + } + if err := runPersonalEventStatus(cmd, personalStatusOptions{}); !errors.Is(err, wantErr) { + t.Fatalf("status list error = %v", err) + } + personalListSubscriptions = func(*personal.Client, context.Context, personal.ListOptions) ([]personal.Subscription, error) { + return nil, nil + } + if err := runPersonalEventStatus(cmd, personalStatusOptions{Status: "all", Format: "json"}); err != nil { + t.Fatalf("status entry JSON = %v", err) + } + + personalLoadRunStates = func(string) ([]personal.RunState, error) { return nil, wantErr } + if err := runPersonalEventStop(cmd, personalStopOptions{All: true}); !errors.Is(err, wantErr) { + t.Fatalf("stop targets error = %v", err) + } + personalDeleteSubscription = func(*personal.Client, context.Context, string) error { return wantErr } + if err := runPersonalEventStop(cmd, personalStopOptions{SubscribeID: "sub"}); !errors.Is(err, wantErr) { + t.Fatalf("delete error = %v", err) + } + personalDeleteSubscription = func(*personal.Client, context.Context, string) error { return nil } + personalRemoveRunStates = func(string, []string) error { return wantErr } + if err := runPersonalEventStop(cmd, personalStopOptions{SubscribeID: "sub"}); !errors.Is(err, wantErr) { + t.Fatalf("remove error = %v", err) + } + personalRemoveRunStates = func(string, []string) error { return nil } + personalQueryStatus = func(string) (*eventtransport.StatusResp, error) { return nil, wantErr } + personalLoadRunStates = func(string) ([]personal.RunState, error) { return nil, wantErr } + if err := runPersonalEventStop(cmd, personalStopOptions{SubscribeID: "sub"}); !errors.Is(err, wantErr) { + t.Fatalf("remaining state error = %v", err) + } + personalQueryStatus = func(string) (*eventtransport.StatusResp, error) { + return &eventtransport.StatusResp{Consumers: []eventtransport.StatusConsumer{{SubscribeID: "sub", PID: 123}}}, nil + } + personalFindProcess = func(int) (*os.Process, error) { return nil, wantErr } + personalLoadRunStates = func(string) ([]personal.RunState, error) { return []personal.RunState{{SubscribeID: "other"}}, nil } + if err := runPersonalEventStop(cmd, personalStopOptions{SubscribeID: "sub"}); err != nil { + t.Fatalf("interrupt warning stop = %v", err) + } + personalLoadRunStates = func(string) ([]personal.RunState, error) { return []personal.RunState{{SubscribeID: "other"}}, nil } + if err := runPersonalEventStop(cmd, personalStopOptions{SubscribeID: "sub"}); err != nil { + t.Fatalf("remaining bus stop = %v", err) + } + personalLoadRunStates = func(string) ([]personal.RunState, error) { return nil, nil } + personalStopBus = func(busctl.StopConfig) error { return busctl.ErrNotRunning } + if err := runPersonalEventStop(cmd, personalStopOptions{SubscribeID: "sub"}); err != nil { + t.Fatalf("not running stop = %v", err) + } + personalStopBus = func(busctl.StopConfig) error { return wantErr } + if err := runPersonalEventStop(cmd, personalStopOptions{SubscribeID: "sub"}); !errors.Is(err, wantErr) { + t.Fatalf("bus stop error = %v", err) + } + + personalLoadRunStates = func(string) ([]personal.RunState, error) { + return []personal.RunState{{}, {SubscribeID: "b"}, {SubscribeID: "a"}}, nil + } + if got, err := personalStopTargets("", "", true); err != nil || strings.Join(got, ",") != "a,b" { + t.Fatalf("stop target filtering = %#v, %v", got, err) + } + status := &eventtransport.StatusResp{Consumers: []eventtransport.StatusConsumer{ + {SubscribeID: "other", PID: 1}, + {SubscribeID: "sub", PID: 0}, + {SubscribeID: "sub", PID: os.Getpid()}, + {SubscribeID: "sub", PID: 123}, + {SubscribeID: "sub", PID: 123}, + }} + personalQueryStatus = func(string) (*eventtransport.StatusResp, error) { return status, nil } + personalFindProcess = func(int) (*os.Process, error) { return nil, wantErr } + if err := interruptPersonalConsumers("ipc", []string{" sub ", ""}); !errors.Is(err, wantErr) { + t.Fatalf("find process error = %v", err) + } + proc := &os.Process{} + personalFindProcess = func(int) (*os.Process, error) { return proc, nil } + personalSignalProcess = func(*os.Process, os.Signal) error { return wantErr } + if err := interruptPersonalConsumers("ipc", []string{"sub"}); !errors.Is(err, wantErr) { + t.Fatalf("signal process error = %v", err) + } + personalSignalProcess = func(*os.Process, os.Signal) error { return os.ErrProcessDone } + if err := interruptPersonalConsumers("ipc", []string{"sub"}); err != nil { + t.Fatalf("completed process signal = %v", err) + } +} + +func TestPersonalEventRemainingIdentityAndSourceCoverage(t *testing.T) { + oldAux := personalResolveAuxiliaryAccessToken + oldLoad := personalLoadTokenData + oldClientID := personalClientID + oldCredentials := personalResolveAppCredentialsStrict + oldEdition := edition.Get() + t.Cleanup(func() { + personalResolveAuxiliaryAccessToken = oldAux + personalLoadTokenData = oldLoad + personalClientID = oldClientID + personalResolveAppCredentialsStrict = oldCredentials + edition.Override(oldEdition) + }) + wantErr := errors.New("identity") + personalResolveAuxiliaryAccessToken = func(context.Context, string, string) (string, error) { return "", wantErr } + if _, err := resolvePersonalEventIdentity(context.Background(), "", ""); !errors.Is(err, wantErr) { + t.Fatalf("aux token error = %v", err) + } + personalResolveAuxiliaryAccessToken = func(context.Context, string, string) (string, error) { return "access", nil } + personalLoadTokenData = func(string) (*authpkg.TokenData, error) { return nil, nil } + personalClientID = func() string { return "" } + personalResolveAppCredentialsStrict = func(string) (string, string, authpkg.CredentialSource, authpkg.CredentialSource, error) { + return "resolved", "secret", "", "", nil + } + edition.Override(&edition.Hooks{RuntimeDefaults: func() map[string]edition.RuntimeDefaultFn { + return map[string]edition.RuntimeDefaultFn{ + "$corpId": func(context.Context) (string, bool) { return " corp ", true }, + "$currentUserId": func(context.Context) (string, bool) { return " user ", true }, + } + }}) + if got, err := resolvePersonalEventIdentity(context.Background(), "", "source"); err != nil || got.ClientID != "resolved" || got.CorpID != "corp" { + t.Fatalf("resolved identity = %#v, %v", got, err) + } + personalResolveAppCredentialsStrict = func(string) (string, string, authpkg.CredentialSource, authpkg.CredentialSource, error) { + return "", "", "", "", wantErr + } + edition.Override(&edition.Hooks{}) + if _, err := resolvePersonalEventIdentity(context.Background(), "", ""); err == nil { + t.Fatal("missing client ID succeeded") + } + if got := resolveRuntimeDefault(context.Background(), "missing"); got != "" { + t.Fatalf("missing runtime default = %q", got) + } + + if _, err := newPersonalStreamSource(context.Background(), personalStreamSourceOptions{ConfigDir: "", Identity: personal.Identity{}, TicketMode: "custom"}); !errors.Is(err, wantErr) { + t.Fatalf("custom credential error = %v", err) + } + personalResolveAppCredentialsStrict = func(string) (string, string, authpkg.CredentialSource, authpkg.CredentialSource, error) { + return "resolved", "secret", "", "", nil + } + if src, err := newPersonalStreamSource(context.Background(), personalStreamSourceOptions{Identity: personal.Identity{AccessToken: "token", SourceID: "source"}, TicketMode: "custom"}); err != nil || src == nil { + t.Fatalf("custom resolved source = %#v, %v", src, err) + } + if got := personalEventStreamSourceID(""); got != "open" { + t.Fatalf("default stream source = %q", got) + } + if got := configuredMCPBaseURL(""); got != "" { + t.Fatalf("default missing configured MCP = %q", got) + } +} + +func newPersonalCoverageCommand() *cobra.Command { + cmd := &cobra.Command{Use: "event"} + cmd.SetContext(context.Background()) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + cmd.Flags().String("format", "table", "") + cmd.Flags().String("output", "", "") + return cmd +} + +var _ = time.Second diff --git a/internal/app/force_refresh.go b/internal/app/force_refresh.go index a7d9010a..e12cf8fd 100644 --- a/internal/app/force_refresh.go +++ b/internal/app/force_refresh.go @@ -23,6 +23,20 @@ import ( authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth" ) +type accessTokenGetter interface { + GetAccessToken(context.Context) (string, error) +} + +var ( + markAccessTokenStale = authpkg.MarkAccessTokenStale + newRefreshProvider = func(configDir string) accessTokenGetter { + disc := slog.New(slog.NewTextHandler(io.Discard, nil)) + provider := authpkg.NewOAuthProvider(configDir, disc) + configureOAuthProviderCompatibility(provider, configDir) + return provider + } +) + // ForceRefreshAccessToken forces a single refresh_token exchange and returns // the new access_token. It is intended for callers that have observed a // server-side rejection (HTTP 401 or business code such as @@ -43,12 +57,10 @@ func ForceRefreshAccessToken(ctx context.Context, configDir string) (string, err if strings.TrimSpace(configDir) == "" { return "", fmt.Errorf("config directory is empty") } - if err := authpkg.MarkAccessTokenStale(configDir); err != nil { + if err := markAccessTokenStale(configDir); err != nil { return "", fmt.Errorf("mark access token stale: %w", err) } - disc := slog.New(slog.NewTextHandler(io.Discard, nil)) - provider := authpkg.NewOAuthProvider(configDir, disc) - configureOAuthProviderCompatibility(provider, configDir) + provider := newRefreshProvider(configDir) tok, err := provider.GetAccessToken(ctx) if err != nil { return "", err diff --git a/internal/app/pat_auth_retry.go b/internal/app/pat_auth_retry.go index d2baa050..b277a7db 100644 --- a/internal/app/pat_auth_retry.go +++ b/internal/app/pat_auth_retry.go @@ -52,6 +52,21 @@ const ( var openBrowserFunc = tryOpenBrowser +var ( + patAuthorizationTimeout = PatAuthRetryTimeout + patAuthorizationPollInterval = PatAuthPollInterval + patLoadTokenData = authpkg.LoadTokenData + patWaitForAuthorization = WaitForPatAuthorization + patPollDeviceFlowWithInterval = pollPatDeviceFlowWithInterval + patSaveAppConfig = authpkg.SaveAppConfig + patExchangeCodeForToken = authpkg.ExchangeCodeForToken + patSaveTokenData = authpkg.SaveTokenData + patSleep = time.Sleep + patPollHTTPDo = (*http.Client).Do + patPollNewRequest = http.NewRequestWithContext + patBrowserOpenCommand = browserOpenCommand +) + type patSuppressBrowserOpenKeyType struct{} var patSuppressBrowserOpenKey = patSuppressBrowserOpenKeyType{} @@ -236,10 +251,7 @@ func enrichPATErrorWithOpenBrowser(raw string, openBrowser bool) string { } data["openBrowser"] = openBrowser - encoded, err := marshalSingleLineJSONNoHTMLEscape(payload) - if err != nil { - return raw - } + encoded, _ := marshalSingleLineJSONNoHTMLEscape(payload) return string(encoded) } @@ -256,9 +268,9 @@ func patAuthorizationURIFromData(data map[string]any) string { // WaitForPatAuthorization polls until the user completes authorization or timeout. // It returns true if authorization was completed, false if timed out or cancelled. func WaitForPatAuthorization(ctx context.Context, configDir string, output io.Writer) bool { - timeout := PatAuthRetryTimeout + timeout := patAuthorizationTimeout deadline := time.Now().Add(timeout) - pollTicker := time.NewTicker(PatAuthPollInterval) + pollTicker := time.NewTicker(patAuthorizationPollInterval) defer pollTicker.Stop() start := time.Now() @@ -286,7 +298,7 @@ func WaitForPatAuthorization(ctx context.Context, configDir string, output io.Wr remaining := time.Until(deadline).Truncate(time.Second) // Check if token is now valid - tokenData, err := authpkg.LoadTokenData(configDir) + tokenData, err := patLoadTokenData(configDir) if err == nil && tokenData != nil { if tokenData.IsAccessTokenValid() || tokenData.IsRefreshTokenValid() { fmt.Fprintf(output, "\r%s %s (%s 已用, %s 剩余) \n", @@ -323,7 +335,7 @@ func retryWithPatAuthRetry(ctx context.Context, runner executor.Runner, invocati PrintPatAuthError(output, scopeErr) // Wait for user to complete authorization - authorized := WaitForPatAuthorization(ctx, configDir, output) + authorized := patWaitForAuthorization(ctx, configDir, output) if !authorized { return executor.Result{}, apperrors.NewAuth( "等待用户授权超时", @@ -579,7 +591,7 @@ func handlePatAuthCheck( pollCtx, cancel := context.WithTimeout(ctx, patPollTimeout) defer cancel() - status, authCode, err := pollPatDeviceFlowWithInterval( + status, authCode, err := patPollDeviceFlowWithInterval( pollCtx, patData.Data.FlowID, configDir, output, resolvePATPollInterval(patData.Data.PollIntervalSecs), ) @@ -594,7 +606,7 @@ func handlePatAuthCheck( fmt.Fprintln(output) if appCfg != nil { - if err := authpkg.SaveAppConfig(configDir, appCfg); err != nil { + if err := patSaveAppConfig(configDir, appCfg); err != nil { slog.Warn("failed to persist approved app config from PAT", "error", err) fmt.Fprintf(output, " \u26a0 保存应用配置失败: %v (下次启动可能需要重新授权)\n", err) } @@ -603,12 +615,12 @@ func handlePatAuthCheck( // Exchange authCode for a fresh access token (mirrors device_flow loginOnce). if authCode != "" { slog.Debug("PAT retry: exchanging authCode for token", "hasCode", true) - tokenData, exchErr := authpkg.ExchangeCodeForToken(ctx, configDir, authCode) + tokenData, exchErr := patExchangeCodeForToken(ctx, configDir, authCode) if exchErr != nil { slog.Warn("PAT retry: exchangeCode failed, retrying with existing token", "error", exchErr) fmt.Fprintf(output, " %s 换取新 token 失败: %v (将使用现有凭证重试)\n", tui.StateMark("warning"), exchErr) } else { - if err := authpkg.SaveTokenData(configDir, tokenData); err != nil { + if err := patSaveTokenData(configDir, tokenData); err != nil { slog.Warn("PAT retry: failed to save new token", "error", err) fmt.Fprintf(output, " %s 保存新 token 失败: %v\n", tui.StateMark("warning"), err) } else { @@ -633,7 +645,7 @@ func handlePatAuthCheck( // Workaround: brief delay to let server-side authorization state propagate // before retrying. Without this the retry may use stale credentials. slog.Debug("PAT retry: waiting for server-side state propagation", "delay", "1s") - time.Sleep(1 * time.Second) + patSleep(1 * time.Second) // Retry the original invocation with pat-retrying flag to prevent recursion. fmt.Fprintf(output, "%s %s\n", tui.StateMark("ok"), tui.Bold("授权完成,正在重试...")) @@ -705,10 +717,7 @@ func enrichPATErrorForHostControl(raw string) string { apperrors.ApplyHostMutations(payload) // stderr JSON MUST be single-line. - encoded, err := marshalSingleLineJSONNoHTMLEscape(payload) - if err != nil { - return raw - } + encoded, _ := marshalSingleLineJSONNoHTMLEscape(payload) return string(encoded) } @@ -739,10 +748,7 @@ func buildPATScopeJSON(scopeErr *PatScopeError, includeHostControl bool) string "data": data, } // stderr JSON MUST be single-line. - b, err := jsonutil.Marshal(payload) - if err != nil { - return `{"success":false,"code":"PAT_SCOPE_AUTH_REQUIRED"}` - } + b, _ := jsonutil.Marshal(payload) return string(b) } @@ -803,7 +809,7 @@ func pollPatDeviceFlowWithInterval(ctx context.Context, flowID string, configDir pollCount++ fmt.Fprintf(output, "\r%s [%d] 等待授权中... ", tui.Dim("⟳"), pollCount) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, pollURL, nil) + req, err := patPollNewRequest(ctx, http.MethodGet, pollURL, nil) if err != nil { slog.Debug("PAT poll: failed to create request", "error", err) continue @@ -811,7 +817,7 @@ func pollPatDeviceFlowWithInterval(ctx context.Context, flowID string, configDir if accessToken != "" { req.Header.Set("x-user-access-token", accessToken) } - resp, err := noRedirectClient.Do(req) + resp, err := patPollHTTPDo(noRedirectClient, req) if err != nil { slog.Debug("PAT poll: request failed", "error", err) continue // transient network error, keep polling @@ -858,9 +864,6 @@ func resolvePATPollInterval(seconds int) time.Duration { return patPollInterval } interval := time.Duration(seconds) * time.Second - if interval < time.Second { - return time.Second - } if interval > patMaxPollInterval { return patMaxPollInterval } @@ -882,7 +885,7 @@ func browserOpenCommand(goos, rawURL string) *exec.Cmd { // tryOpenBrowser opens rawURL in the default browser; errors are silently ignored. func tryOpenBrowser(rawURL string) error { - cmd := browserOpenCommand(runtime.GOOS, rawURL) + cmd := patBrowserOpenCommand(runtime.GOOS, rawURL) if cmd == nil { return nil } diff --git a/internal/app/pat_auth_retry_full_coverage_test.go b/internal/app/pat_auth_retry_full_coverage_test.go new file mode 100644 index 00000000..1d783d8b --- /dev/null +++ b/internal/app/pat_auth_retry_full_coverage_test.go @@ -0,0 +1,281 @@ +package app + +import ( + "bytes" + "context" + "errors" + "io" + "net/http" + "os/exec" + "strings" + "testing" + "time" + + authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor" +) + +func TestPATRetryRemainingPureAndWaitCoverage(t *testing.T) { + typed := apperrors.NewAPI("ordinary", apperrors.WithHint("insufficient_scope")) + if !isPatScopeError(typed) { + t.Fatal("typed insufficient_scope was not recognized") + } + typed = apperrors.NewAPI("ordinary", apperrors.WithReason("reason")) + if got := extractPatScopeError(typed); !strings.Contains(got.Message, "reason") { + t.Fatalf("typed scope message = %q", got.Message) + } + + var out bytes.Buffer + PrintPatAuthError(&out, &PatScopeError{Identity: "user", ErrorType: "missing_scope", Message: "missing", Hint: "login"}) + if !strings.Contains(out.String(), "dws auth login") { + t.Fatalf("PAT output = %q", out.String()) + } + if wantsStructuredPATOutputFromRunner(runnerCoverageFallback{}) { + t.Fatal("non-runtime runner requested structured PAT output") + } + if got := enrichPATErrorWithOpenBrowser("", true); got != "" { + t.Fatalf("empty enriched PAT = %q", got) + } + if got := enrichPATErrorWithOpenBrowser("not-json", true); got != "not-json" { + t.Fatalf("malformed enriched PAT = %q", got) + } + if got := enrichPATErrorWithOpenBrowser(`{"code":"x"}`, true); !strings.Contains(got, "openBrowser") { + t.Fatalf("missing data enrichment = %q", got) + } + if got := patAuthorizationURIFromData(map[string]any{"authorizationUrl": " final "}); got != "final" { + t.Fatalf("authorization URI = %q", got) + } + if err := openPATAuthorizationURI(""); err != nil { + t.Fatal(err) + } + + oldTimeout := patAuthorizationTimeout + oldInterval := patAuthorizationPollInterval + oldLoad := patLoadTokenData + t.Cleanup(func() { + patAuthorizationTimeout = oldTimeout + patAuthorizationPollInterval = oldInterval + patLoadTokenData = oldLoad + }) + patAuthorizationTimeout = 50 * time.Millisecond + patAuthorizationPollInterval = time.Millisecond + patLoadTokenData = func(string) (*authpkg.TokenData, error) { + return &authpkg.TokenData{AccessToken: "token", ExpiresAt: time.Now().Add(time.Hour)}, nil + } + out.Reset() + if !WaitForPatAuthorization(context.Background(), "", &out) { + t.Fatal("valid token did not authorize") + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + out.Reset() + if WaitForPatAuthorization(ctx, "", &out) { + t.Fatal("cancelled authorization succeeded") + } + patAuthorizationTimeout = time.Millisecond + patAuthorizationPollInterval = time.Hour + out.Reset() + if WaitForPatAuthorization(context.Background(), "", &out) { + t.Fatal("timed out authorization succeeded") + } + patAuthorizationTimeout = 5 * time.Millisecond + patAuthorizationPollInterval = time.Millisecond + patLoadTokenData = func(string) (*authpkg.TokenData, error) { return nil, nil } + out.Reset() + if WaitForPatAuthorization(context.Background(), "", &out) || !strings.Contains(out.String(), "等待授权中") { + t.Fatalf("invalid-token polling output = %q", out.String()) + } +} + +func TestPATRetryRemainingOrchestrationCoverage(t *testing.T) { + oldWait := patWaitForAuthorization + oldPoll := patPollDeviceFlowWithInterval + oldSaveApp := patSaveAppConfig + oldExchange := patExchangeCodeForToken + oldSaveToken := patSaveTokenData + oldSleep := patSleep + oldOpen := openBrowserFunc + t.Cleanup(func() { + patWaitForAuthorization = oldWait + patPollDeviceFlowWithInterval = oldPoll + patSaveAppConfig = oldSaveApp + patExchangeCodeForToken = oldExchange + patSaveTokenData = oldSaveToken + patSleep = oldSleep + openBrowserFunc = oldOpen + }) + t.Setenv(authpkg.AgentCodeEnv, "") + t.Setenv("DWS_CONFIG_DIR", t.TempDir()) + + scope := &PatScopeError{OriginalError: "missing", Identity: "user", ErrorType: "missing_scope", Message: "missing", Hint: "login", MissingScope: "calendar:read"} + patWaitForAuthorization = func(context.Context, string, io.Writer) bool { return false } + if _, err := retryWithPatAuthRetry(context.Background(), runnerCoverageFallback{}, executor.Invocation{}, scope, t.TempDir(), io.Discard); err == nil { + t.Fatal("PAT retry timeout succeeded") + } + wantErr := errors.New("runner failed") + patWaitForAuthorization = func(context.Context, string, io.Writer) bool { return true } + if _, err := retryWithPatAuthRetry(context.Background(), runnerCoverageFallback{err: wantErr}, executor.Invocation{}, scope, t.TempDir(), io.Discard); !errors.Is(err, wantErr) { + t.Fatalf("authorized retry = %v", err) + } + + patErr := &apperrors.PATError{RawJSON: `{"code":"PAT_NO_PERMISSION"}`} + if err := runDirectPATAuthCheck(context.Background(), nil, patErr, nil, io.Discard); !errors.Is(err, patErr) { + t.Fatalf("nil direct retry = %v", err) + } + if err := runDirectPATAuthCheckWithMode(context.Background(), nil, patErr, nil, io.Discard, true); !errors.Is(err, patErr) { + t.Fatalf("nil retry mode = %v", err) + } + + badRetry := errors.New("retry callback") + patPollDeviceFlowWithInterval = func(context.Context, string, string, io.Writer, time.Duration) (string, string, error) { + return authpkg.StatusApproved, "", nil + } + patSleep = func(time.Duration) {} + if err := runDirectPATAuthCheck(context.Background(), nil, &apperrors.PATError{RawJSON: patRaw("f", "", "")}, func(context.Context) error { return badRetry }, io.Discard); !errors.Is(err, badRetry) { + t.Fatalf("direct retry callback = %v", err) + } + + malformedPAT := &apperrors.PATError{RawJSON: `{`} + if _, err := handlePatAuthCheck(context.Background(), &runtimeRunner{}, executor.Invocation{}, malformedPAT, t.TempDir(), io.Discard); !errors.Is(err, malformedPAT) { + t.Fatalf("malformed PAT handler = %v", err) + } + + openBrowserFunc = func(string) error { return nil } + for _, raw := range []string{ + `{"code":"x","data":{"flowId":"f","authUrl":"https://auth.test","desc":"authorize"}}`, + `{"code":"x","data":{"flowId":"f","authorizationUrl":"https://auth2.test"}}`, + } { + patPollDeviceFlowWithInterval = func(context.Context, string, string, io.Writer, time.Duration) (string, string, error) { + return "", "", wantErr + } + ctx := context.WithValue(context.Background(), patSuppressBrowserOpenKey, true) + if _, err := handlePatAuthCheck(ctx, &runtimeRunner{}, executor.Invocation{}, &apperrors.PATError{RawJSON: raw}, t.TempDir(), io.Discard); err == nil { + t.Fatal("poll failure returned nil") + } + } + + statuses := []string{authpkg.StatusRejected, authpkg.StatusExpired, authpkg.StatusCancelled, "UNKNOWN"} + for _, status := range statuses { + patPollDeviceFlowWithInterval = func(context.Context, string, string, io.Writer, time.Duration) (string, string, error) { + return status, "", nil + } + if _, err := handlePatAuthCheck(context.WithValue(context.Background(), patSuppressBrowserOpenKey, true), &runtimeRunner{}, executor.Invocation{}, &apperrors.PATError{RawJSON: patRaw("f", "", "")}, t.TempDir(), io.Discard); err == nil { + t.Fatalf("status %s returned nil", status) + } + } + + patPollDeviceFlowWithInterval = func(context.Context, string, string, io.Writer, time.Duration) (string, string, error) { + return authpkg.StatusApproved, "code", nil + } + patSaveAppConfig = func(string, *authpkg.AppConfig) error { return wantErr } + patExchangeCodeForToken = func(context.Context, string, string) (*authpkg.TokenData, error) { return nil, wantErr } + skip := executor.Invocation{Params: map[string]any{"retryAfterApproval": false}} + if got, err := handlePatAuthCheck(context.Background(), &runtimeRunner{}, skip, &apperrors.PATError{RawJSON: patRaw("f", "client", "secret")}, t.TempDir(), io.Discard); err != nil || !got.Invocation.Implemented { + t.Fatalf("approved skip = %#v, %v", got, err) + } + + patSaveAppConfig = func(string, *authpkg.AppConfig) error { return nil } + patExchangeCodeForToken = func(context.Context, string, string) (*authpkg.TokenData, error) { + return &authpkg.TokenData{AccessToken: "token"}, nil + } + patSaveTokenData = func(string, *authpkg.TokenData) error { return wantErr } + r := &runtimeRunner{fallback: runnerCoverageFallback{result: executor.Result{Response: map[string]any{"ok": true}}}} + if _, err := handlePatAuthCheck(context.Background(), r, executor.Invocation{}, &apperrors.PATError{RawJSON: patRaw("f", "client", "")}, t.TempDir(), io.Discard); err != nil { + t.Fatalf("approved retry with save failure = %v", err) + } + patSaveTokenData = func(string, *authpkg.TokenData) error { return nil } + if _, err := handlePatAuthCheck(context.Background(), r, executor.Invocation{}, &apperrors.PATError{RawJSON: patRaw("f", "", "")}, t.TempDir(), io.Discard); err != nil { + t.Fatalf("approved retry = %v", err) + } + + for _, inv := range []executor.Invocation{{}, {Params: map[string]any{}}, {Params: map[string]any{"retryAfterApproval": "no"}}, {Params: map[string]any{"retryAfterApproval": true}}} { + if shouldSkipPATRetryAfterApproval(inv) { + t.Fatalf("unexpected skip for %#v", inv.Params) + } + } + if got := enrichPATErrorForHostControl(""); got != "" { + t.Fatalf("empty host PAT = %q", got) + } + if got := enrichPATErrorForHostControl("bad"); got != "bad" { + t.Fatalf("bad host PAT = %q", got) + } + if got := enrichPATErrorForHostControl(`{"value":1}`); !strings.Contains(got, "value") { + t.Fatalf("generic host PAT = %q", got) + } + if _, err := marshalSingleLineJSONNoHTMLEscape(map[string]any{"bad": func() {}}); err == nil { + t.Fatal("unsupported JSON value succeeded") + } +} + +func patRaw(flowID, clientID, secret string) string { + return `{"code":"x","data":{"desc":"authorize","flowId":"` + flowID + `","uri":"https://auth.test","clientId":"` + clientID + `","clientSecret":"` + secret + `"}}` +} + +func TestPATRetryRemainingPollAndBrowserCoverage(t *testing.T) { + oldDo := patPollHTTPDo + oldRequest := patPollNewRequest + oldLoad := patLoadTokenData + oldBrowser := patBrowserOpenCommand + t.Cleanup(func() { + patPollHTTPDo = oldDo + patPollNewRequest = oldRequest + patLoadTokenData = oldLoad + patBrowserOpenCommand = oldBrowser + }) + patLoadTokenData = func(string) (*authpkg.TokenData, error) { return &authpkg.TokenData{AccessToken: "token"}, nil } + cancelled, cancelNow := context.WithCancel(context.Background()) + cancelNow() + if status, _, err := pollPatDeviceFlowWithInterval(cancelled, "flow", t.TempDir(), io.Discard, 0); err != nil || status != authpkg.StatusCancelled { + t.Fatalf("zero-interval cancelled poll = %q, %v", status, err) + } + expired, cancelExpired := context.WithDeadline(context.Background(), time.Now().Add(-time.Second)) + defer cancelExpired() + if status, _, err := pollPatDeviceFlowWithInterval(expired, "flow", t.TempDir(), io.Discard, time.Millisecond); err != nil || status != authpkg.StatusExpired { + t.Fatalf("expired-context poll = %q, %v", status, err) + } + ctx, cancel := context.WithCancel(context.Background()) + patPollHTTPDo = func(*http.Client, *http.Request) (*http.Response, error) { + cancel() + return nil, errors.New("network") + } + if status, _, err := pollPatDeviceFlowWithInterval(ctx, "flow", t.TempDir(), io.Discard, time.Millisecond); err != nil || status != authpkg.StatusCancelled { + t.Fatalf("network poll = %q, %v", status, err) + } + ctx, cancel = context.WithCancel(context.Background()) + patPollHTTPDo = func(*http.Client, *http.Request) (*http.Response, error) { + cancel() + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("{"))}, nil + } + if status, _, err := pollPatDeviceFlowWithInterval(ctx, "flow", t.TempDir(), io.Discard, time.Millisecond); err != nil || status != authpkg.StatusCancelled { + t.Fatalf("malformed poll = %q, %v", status, err) + } + + ctx, cancel = context.WithCancel(context.Background()) + patPollNewRequest = func(context.Context, string, string, io.Reader) (*http.Request, error) { + cancel() + return nil, errors.New("request") + } + if status, _, err := pollPatDeviceFlowWithInterval(ctx, "flow", t.TempDir(), io.Discard, time.Millisecond); err != nil || status != authpkg.StatusCancelled { + t.Fatalf("invalid request poll = %q, %v", status, err) + } + patPollNewRequest = oldRequest + + var out bytes.Buffer + t.Setenv("DWS_DEBUG_PAT_POLL", "1") + printPATPollDebugResponse(&out, 500, nil) + if !strings.Contains(out.String(), "empty body") { + t.Fatalf("empty debug response = %q", out.String()) + } + for _, goos := range []string{"darwin", "linux", "windows", "plan9"} { + _ = browserOpenCommand(goos, "https://example.test") + } + patBrowserOpenCommand = func(string, string) *exec.Cmd { return nil } + if err := tryOpenBrowser("https://example.test"); err != nil { + t.Fatal(err) + } + patBrowserOpenCommand = func(string, string) *exec.Cmd { return exec.Command("definitely-not-a-real-dws-command") } + if err := tryOpenBrowser("https://example.test"); err == nil { + t.Fatal("missing browser command started") + } +} diff --git a/internal/app/plugin_cmd.go b/internal/app/plugin_cmd.go index 36a3539b..b4e49fc8 100644 --- a/internal/app/plugin_cmd.go +++ b/internal/app/plugin_cmd.go @@ -26,6 +26,18 @@ import ( "github.com/spf13/cobra" ) +var ( + pluginInstallFromGit = (*plugin.Loader).InstallFromGit + pluginStat = os.Stat + pluginMkdirAll = os.MkdirAll + pluginWriteFile = os.WriteFile + pluginAbs = filepath.Abs + pluginRegisterDev = (*plugin.Loader).RegisterDevPlugin + pluginListInstalled = (*plugin.Loader).ListInstalled + pluginParseManifest = plugin.ParseManifest + pluginBuild = plugin.BuildPlugin +) + func newPluginCommand() *cobra.Command { pluginCmd := newPlaceholderParent("plugin", i18n.T("插件管理")) @@ -98,7 +110,7 @@ func newPluginInstallCommand() *cobra.Command { loader := plugin.NewLoader(RawVersion()) if gitURL != "" { - p, err := loader.InstallFromGit(gitURL) + p, err := pluginInstallFromGit(loader, gitURL) if err != nil { return apperrors.NewInternal(fmt.Sprintf("install failed: %v", err)) } @@ -247,7 +259,7 @@ func newPluginCreateCommand() *cobra.Command { } dir := filepath.Join(".", name) - if _, err := os.Stat(dir); err == nil { + if _, err := pluginStat(dir); err == nil { return apperrors.NewValidation(fmt.Sprintf("directory %q already exists", dir)) } @@ -258,7 +270,7 @@ func newPluginCreateCommand() *cobra.Command { filepath.Join(dir, "hooks"), } for _, d := range dirs { - if err := os.MkdirAll(d, 0o755); err != nil { + if err := pluginMkdirAll(d, 0o755); err != nil { return apperrors.NewInternal(fmt.Sprintf("failed to create directory: %v", err)) } } @@ -286,7 +298,7 @@ func newPluginCreateCommand() *cobra.Command { } `, name, desc, pluginType, RawVersion(), name) - if err := os.WriteFile(filepath.Join(dir, "plugin.json"), []byte(pluginJSON), 0o644); err != nil { + if err := pluginWriteFile(filepath.Join(dir, "plugin.json"), []byte(pluginJSON), 0o644); err != nil { return apperrors.NewInternal(fmt.Sprintf("failed to write plugin.json: %v", err)) } @@ -317,7 +329,7 @@ Use this skill when the user mentions: - Conversion rules `, name, desc, RawVersion(), name, name) - if err := os.WriteFile(filepath.Join(dir, "skills", name, "SKILL.md"), []byte(skillMD), 0o644); err != nil { + if err := pluginWriteFile(filepath.Join(dir, "skills", name, "SKILL.md"), []byte(skillMD), 0o644); err != nil { return apperrors.NewInternal(fmt.Sprintf("failed to write SKILL.md: %v", err)) } @@ -326,7 +338,7 @@ Use this skill when the user mentions: "hooks": [] } ` - if err := os.WriteFile(filepath.Join(dir, "hooks", "hooks.json"), []byte(hooksJSON), 0o644); err != nil { + if err := pluginWriteFile(filepath.Join(dir, "hooks", "hooks.json"), []byte(hooksJSON), 0o644); err != nil { return apperrors.NewInternal(fmt.Sprintf("failed to write hooks.json: %v", err)) } @@ -377,7 +389,7 @@ to unregister.`, // Register dev plugin dir := args[0] - absDir, err := filepath.Abs(dir) + absDir, err := pluginAbs(dir) if err != nil { return apperrors.NewValidation(fmt.Sprintf("invalid path: %v", err)) } @@ -391,7 +403,7 @@ to unregister.`, return apperrors.NewValidation(fmt.Sprintf("validation failed: %v", err)) } - if err := loader.RegisterDevPlugin(m.Name, absDir); err != nil { + if err := pluginRegisterDev(loader, m.Name, absDir); err != nil { return apperrors.NewInternal(fmt.Sprintf("failed to register: %v", err)) } @@ -582,10 +594,10 @@ func newPluginConfigUnsetCommand() *cobra.Command { // loadDeclaredUserConfig loads the userConfig section from a plugin's manifest. func loadDeclaredUserConfig(loader *plugin.Loader, pluginName string) map[string]plugin.ConfigItem { - plugins := loader.ListInstalled() + plugins := pluginListInstalled(loader) for _, p := range plugins { if p.Name == pluginName { - m, err := plugin.ParseManifest(filepath.Join(p.Path, "plugin.json")) + m, err := pluginParseManifest(filepath.Join(p.Path, "plugin.json")) if err != nil { return nil } @@ -626,7 +638,7 @@ The build configuration is read from the "build" field in plugin.json: DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, args []string) error { dir := args[0] - absDir, err := filepath.Abs(dir) + absDir, err := pluginAbs(dir) if err != nil { return apperrors.NewValidation(fmt.Sprintf("invalid path: %v", err)) } @@ -646,7 +658,7 @@ The build configuration is read from the "build" field in plugin.json: " }", m.Name)) } - if err := plugin.BuildPlugin(absDir); err != nil { + if err := pluginBuild(absDir); err != nil { return apperrors.NewInternal(err.Error()) } diff --git a/internal/app/plugin_command_full_coverage_test.go b/internal/app/plugin_command_full_coverage_test.go new file mode 100644 index 00000000..43757ad2 --- /dev/null +++ b/internal/app/plugin_command_full_coverage_test.go @@ -0,0 +1,181 @@ +package app + +import ( + "bytes" + "errors" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin" + "github.com/spf13/cobra" +) + +func pluginCoverageRun(cmd *cobra.Command, args ...string) (string, error) { + out := &bytes.Buffer{} + cmd.SetOut(out) + cmd.SetErr(io.Discard) + cmd.SilenceErrors = true + cmd.SilenceUsage = true + cmd.SetArgs(args) + err := cmd.Execute() + return out.String(), err +} + +func TestPluginCommandRemainingCoverage(t *testing.T) { + oldGit := pluginInstallFromGit + oldStat := pluginStat + oldMkdir := pluginMkdirAll + oldWrite := pluginWriteFile + oldAbs := pluginAbs + oldRegister := pluginRegisterDev + oldBuild := pluginBuild + oldList, oldParse := pluginListInstalled, pluginParseManifest + t.Cleanup(func() { + pluginInstallFromGit = oldGit + pluginStat = oldStat + pluginMkdirAll = oldMkdir + pluginWriteFile = oldWrite + pluginAbs = oldAbs + pluginRegisterDev = oldRegister + pluginBuild = oldBuild + pluginListInstalled, pluginParseManifest = oldList, oldParse + }) + home := t.TempDir() + work := t.TempDir() + t.Setenv("HOME", home) + oldWD, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(work); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chdir(oldWD) }) + fail := errors.New("failure") + pluginListInstalled = func(*plugin.Loader) []plugin.PluginInfo { + return []plugin.PluginInfo{{Name: "broken", Path: "missing"}} + } + pluginParseManifest = func(string) (*plugin.Manifest, error) { return nil, fail } + if got := loadDeclaredUserConfig(plugin.NewLoader(RawVersion()), "broken"); got != nil { + t.Fatalf("broken declared config = %#v", got) + } + pluginListInstalled, pluginParseManifest = oldList, oldParse + + pluginInstallFromGit = func(*plugin.Loader, string) (*plugin.Plugin, error) { return nil, fail } + if _, err := pluginCoverageRun(newPluginInstallCommand(), "--git", "https://example.test/org/plugin.git"); err == nil { + t.Fatal("git install failure should propagate") + } + pluginInstallFromGit = func(*plugin.Loader, string) (*plugin.Plugin, error) { + return &plugin.Plugin{Manifest: plugin.Manifest{Name: "git-plugin", Version: "1.0.0"}}, nil + } + if out, err := pluginCoverageRun(newPluginInstallCommand(), "--git", "https://example.test/org/plugin.git"); err != nil || !strings.Contains(out, "git-plugin") { + t.Fatalf("git install = %q, %v", out, err) + } + if _, err := pluginCoverageRun(newPluginDisableCommand(), "missing"); err == nil { + t.Fatal("disable missing plugin should fail") + } + + invalidDir := filepath.Join(work, "invalid") + if err := os.MkdirAll(invalidDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(invalidDir, "plugin.json"), []byte(`{"name":"ok-name","version":"1.0.0","type":"invalid"}`), 0o600); err != nil { + t.Fatal(err) + } + if _, err := pluginCoverageRun(newPluginValidateCommand(), invalidDir); err == nil { + t.Fatal("invalid manifest validation should fail") + } + + pluginStat = func(string) (os.FileInfo, error) { return nil, os.ErrNotExist } + pluginMkdirAll = func(string, os.FileMode) error { return fail } + if _, err := pluginCoverageRun(newPluginCreateCommand(), "mkdir-plugin"); err == nil { + t.Fatal("scaffold mkdir failure should propagate") + } + pluginMkdirAll = oldMkdir + for _, target := range []string{"plugin.json", "SKILL.md", "hooks.json"} { + name := "write-" + strings.ToLower(strings.TrimSuffix(target, filepath.Ext(target))) + pluginWriteFile = func(path string, data []byte, mode os.FileMode) error { + if strings.HasSuffix(path, target) { + return fail + } + return oldWrite(path, data, mode) + } + if _, err := pluginCoverageRun(newPluginCreateCommand(), name); err == nil { + t.Fatalf("scaffold %s failure should propagate", target) + } + } + pluginWriteFile = oldWrite + + pluginAbs = func(string) (string, error) { return "", fail } + if _, err := pluginCoverageRun(newPluginDevCommand(), "dir"); err == nil { + t.Fatal("dev absolute-path failure should propagate") + } + pluginAbs = oldAbs + if _, err := pluginCoverageRun(newPluginDevCommand(), invalidDir); err == nil { + t.Fatal("dev manifest validation should fail") + } + validDir := filepath.Join(work, "valid") + if err := os.MkdirAll(validDir, 0o755); err != nil { + t.Fatal(err) + } + manifest := `{ + "name":"valid-plugin","version":"1.0.0","type":"user", + "userConfig":{ + "API_KEY":{"description":"secret key","sensitive":true}, + "PLAIN":{"description":"plain value","default":"default"}, + "UNSET":{"description":"required value"} + }, + "build":{"command":"true","output":"bin/server"} +}` + if err := os.WriteFile(filepath.Join(validDir, "plugin.json"), []byte(manifest), 0o600); err != nil { + t.Fatal(err) + } + pluginRegisterDev = func(*plugin.Loader, string, string) error { return fail } + if _, err := pluginCoverageRun(newPluginDevCommand(), validDir); err == nil { + t.Fatal("dev registration failure should propagate") + } + + loader := plugin.NewLoader(RawVersion()) + installed, err := loader.InstallFromDir(validDir) + if err != nil { + // The declared build output is intentionally absent; install a no-build copy. + noBuild := strings.Replace(manifest, ",\n \"build\":{\"command\":\"true\",\"output\":\"bin/server\"}", "", 1) + if writeErr := os.WriteFile(filepath.Join(validDir, "plugin.json"), []byte(noBuild), 0o600); writeErr != nil { + t.Fatal(writeErr) + } + installed, err = loader.InstallFromDir(validDir) + } + if err != nil { + t.Fatal(err) + } + loader.SetPluginConfig("valid-plugin", "API_KEY", "abcdefghijk") + loader.SetPluginConfig("valid-plugin", "PLAIN", "value") + for _, args := range [][]string{{"valid-plugin"}, {"valid-plugin", "--json"}} { + out, runErr := pluginCoverageRun(newPluginConfigListCommand(), args...) + if runErr != nil || !strings.Contains(out, "UNSET") || !strings.Contains(out, "abcd") { + t.Fatalf("config list %#v = %q, %v", args, out, runErr) + } + } + if err := os.WriteFile(filepath.Join(installed.Root, "plugin.json"), []byte("{"), 0o600); err != nil { + t.Fatal(err) + } + if got := loadDeclaredUserConfig(loader, "valid-plugin"); got != nil { + t.Fatalf("corrupt declared config = %#v", got) + } + + pluginAbs = func(string) (string, error) { return "", fail } + if _, err := pluginCoverageRun(newPluginBuildCommand(), validDir); err == nil { + t.Fatal("build absolute-path failure should propagate") + } + pluginAbs = oldAbs + if err := os.WriteFile(filepath.Join(validDir, "plugin.json"), []byte(manifest), 0o600); err != nil { + t.Fatal(err) + } + pluginBuild = func(string) error { return fail } + if _, err := pluginCoverageRun(newPluginBuildCommand(), validDir); err == nil { + t.Fatal("plugin build failure should propagate") + } +} diff --git a/internal/app/profile_command.go b/internal/app/profile_command.go index ab7166bb..3055d8cf 100644 --- a/internal/app/profile_command.go +++ b/internal/app/profile_command.go @@ -66,10 +66,10 @@ func newProfileListCommand() *cobra.Command { DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, args []string) error { configDir := defaultConfigDir() - if err := authpkg.EnsureProfilesMigration(configDir); err != nil { + if err := profileEnsureProfilesMigration(configDir); err != nil { return apperrors.NewInternal(fmt.Sprintf("failed to migrate profiles: %v", err)) } - cfg, err := authpkg.LoadProfiles(configDir) + cfg, err := profileLoadProfiles(configDir) if err != nil { return apperrors.NewInternal(fmt.Sprintf("failed to load profiles: %v", err)) } @@ -139,6 +139,12 @@ func addProfileSwitchSelectorFlags(cmd *cobra.Command) { var ( profileSwitchSelector = selectProfileSwitchProfile profileSwitchInteractiveTerminal = isInteractiveTerminal + profileSwitchTUIRunner = runProfileSwitchTUI + profileEnsureProfilesMigration = authpkg.EnsureProfilesMigration + profileLoadProfiles = authpkg.LoadProfiles + profileUsePrevious = authpkg.UsePreviousProfile + profileSetCurrent = authpkg.SetCurrentProfile + profileRunTeaProgram = (*tea.Program).Run ) const ( @@ -218,9 +224,9 @@ func switchProfileAndWrite(cmd *cobra.Command, configDir, selector string, usedT err error ) if strings.TrimSpace(selector) == "-" { - profile, err = authpkg.UsePreviousProfile(configDir) + profile, err = profileUsePrevious(configDir) } else { - profile, err = authpkg.SetCurrentProfile(configDir, selector) + profile, err = profileSetCurrent(configDir, selector) } if err != nil { return apperrors.NewValidation(err.Error()) @@ -229,7 +235,7 @@ func switchProfileAndWrite(cmd *cobra.Command, configDir, selector string, usedT clearCompatCache() format, _ := cmd.Root().PersistentFlags().GetString("format") if strings.EqualFold(strings.TrimSpace(format), "json") && !(usedTUI && authLoginAllowsInteractiveDefault(cmd, format)) { - cfg, loadErr := authpkg.LoadProfiles(configDir) + cfg, loadErr := profileLoadProfiles(configDir) if loadErr != nil { return apperrors.NewInternal(fmt.Sprintf("failed to load profiles: %v", loadErr)) } @@ -243,10 +249,10 @@ func selectProfileSwitchProfile(cmd *cobra.Command, configDir string) (string, e if !profileSwitchInteractiveTerminal() { return "", apperrors.NewValidation("profile selector required in non-interactive mode; use dws profile switch ") } - if err := authpkg.EnsureProfilesMigration(configDir); err != nil { + if err := profileEnsureProfilesMigration(configDir); err != nil { return "", apperrors.NewInternal(fmt.Sprintf("failed to migrate profiles: %v", err)) } - cfg, err := authpkg.LoadProfiles(configDir) + cfg, err := profileLoadProfiles(configDir) if err != nil { return "", apperrors.NewInternal(fmt.Sprintf("failed to load profiles: %v", err)) } @@ -260,7 +266,7 @@ func selectProfileSwitchProfile(cmd *cobra.Command, configDir string) (string, e if choice == "" { choice = cfg.Profiles[0].CorpID } - return runProfileSwitchTUI(cmd, cfg, choice) + return profileSwitchTUIRunner(cmd, cfg, choice) } func runProfileSwitchTUI(cmd *cobra.Command, cfg *authpkg.ProfilesConfig, selectedCorpID string) (string, error) { @@ -272,7 +278,7 @@ func runProfileSwitchTUI(cmd *cobra.Command, cfg *authpkg.ProfilesConfig, select tea.WithOutput(cmd.ErrOrStderr()), tea.WithContext(cmd.Context()), ) - finalModel, err := program.Run() + finalModel, err := profileRunTeaProgram(program) if err != nil { if errors.Is(err, tea.ErrInterrupted) { return "", apperrors.NewValidation("组织选择中止: user aborted") diff --git a/internal/app/profile_full_coverage_test.go b/internal/app/profile_full_coverage_test.go new file mode 100644 index 00000000..67e90396 --- /dev/null +++ b/internal/app/profile_full_coverage_test.go @@ -0,0 +1,165 @@ +package app + +import ( + "context" + "errors" + "io" + "testing" + + authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth" + tea "github.com/charmbracelet/bubbletea" + "github.com/spf13/cobra" +) + +func TestProfileRemainingCoverage(t *testing.T) { + oldMigrate := profileEnsureProfilesMigration + oldLoad := profileLoadProfiles + oldPrevious := profileUsePrevious + oldCurrent := profileSetCurrent + oldInteractive := profileSwitchInteractiveTerminal + oldTUI := profileSwitchTUIRunner + oldProgram := profileRunTeaProgram + t.Cleanup(func() { + profileEnsureProfilesMigration = oldMigrate + profileLoadProfiles = oldLoad + profileUsePrevious = oldPrevious + profileSetCurrent = oldCurrent + profileSwitchInteractiveTerminal = oldInteractive + profileSwitchTUIRunner = oldTUI + profileRunTeaProgram = oldProgram + }) + fail := errors.New("failure") + + list := newProfileListCommand() + _, _, _ = authCoverageRoot(list, "table", false) + profileEnsureProfilesMigration = func(string) error { return fail } + if err := list.RunE(list, nil); err == nil { + t.Fatal("profile-list migration should fail") + } + profileEnsureProfilesMigration = func(string) error { return nil } + profileLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) { return nil, fail } + if err := list.RunE(list, nil); err == nil { + t.Fatal("profile-list load should fail") + } + + selectorCmd := &cobra.Command{} + addProfileSwitchSelectorFlags(selectorCmd) + _ = selectorCmd.Flags().Set("name", " ") + if _, err := profileSwitchSelectorFromCommand(selectorCmd, nil); err == nil { + t.Fatal("blank profile selector should fail") + } + + profileSetCurrent = func(string, string) (*authpkg.Profile, error) { + return &authpkg.Profile{CorpID: "ding", CorpName: "Corp"}, nil + } + profileUsePrevious = func(string) (*authpkg.Profile, error) { return nil, fail } + cmd := &cobra.Command{} + _, _, _ = authCoverageRoot(cmd, "table", false) + if err := switchProfileAndWrite(cmd, "cfg", "-", false); err == nil { + t.Fatal("previous-profile error should fail") + } + profileUsePrevious = func(string) (*authpkg.Profile, error) { return &authpkg.Profile{CorpID: "previous"}, nil } + if err := switchProfileAndWrite(cmd, "cfg", "-", false); err != nil { + t.Fatal(err) + } + jsonCmd := &cobra.Command{} + _, _, _ = authCoverageRoot(jsonCmd, "json", false) + profileLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) { return nil, fail } + if err := switchProfileAndWrite(jsonCmd, "cfg", "ding", false); err == nil { + t.Fatal("JSON profile load should fail") + } + profileLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) { + return &authpkg.ProfilesConfig{CurrentProfile: "ding"}, nil + } + jsonCmd.SetOut(&appFailWriter{err: fail}) + if err := switchProfileAndWrite(jsonCmd, "cfg", "ding", false); err == nil { + t.Fatal("JSON profile write should fail") + } + + profileSwitchInteractiveTerminal = func() bool { return true } + profileEnsureProfilesMigration = func(string) error { return fail } + if _, err := selectProfileSwitchProfile(cmd, "cfg"); err == nil { + t.Fatal("selector migration should fail") + } + profileEnsureProfilesMigration = func(string) error { return nil } + profileLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) { return nil, fail } + if _, err := selectProfileSwitchProfile(cmd, "cfg"); err == nil { + t.Fatal("selector load should fail") + } + profileLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) { return nil, nil } + if _, err := selectProfileSwitchProfile(cmd, "cfg"); err == nil { + t.Fatal("empty selector profiles should fail") + } + choices := []string{} + profileSwitchTUIRunner = func(_ *cobra.Command, _ *authpkg.ProfilesConfig, choice string) (string, error) { + choices = append(choices, choice) + return choice, nil + } + for _, cfg := range []*authpkg.ProfilesConfig{ + {CurrentProfile: "current", PrimaryProfile: "primary", Profiles: []authpkg.Profile{{CorpID: "first"}}}, + {PrimaryProfile: "primary", Profiles: []authpkg.Profile{{CorpID: "first"}}}, + {Profiles: []authpkg.Profile{{CorpID: "first"}}}, + } { + profileLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) { return cfg, nil } + if _, err := selectProfileSwitchProfile(cmd, "cfg"); err != nil { + t.Fatal(err) + } + } + if len(choices) != 3 || choices[0] != "current" || choices[1] != "primary" || choices[2] != "first" { + t.Fatalf("profile choices = %#v", choices) + } + + tuiCmd := &cobra.Command{} + tuiCmd.SetContext(context.Background()) + tuiCmd.SetIn(io.NopCloser(&emptyReader{})) + tuiCmd.SetErr(io.Discard) + cfg := &authpkg.ProfilesConfig{Profiles: []authpkg.Profile{{CorpID: "ding"}}} + profileRunTeaProgram = func(*tea.Program) (tea.Model, error) { return nil, tea.ErrInterrupted } + if _, err := runProfileSwitchTUI(tuiCmd, cfg, "ding"); err == nil { + t.Fatal("interrupted TUI should fail") + } + profileRunTeaProgram = func(*tea.Program) (tea.Model, error) { return nil, fail } + if _, err := runProfileSwitchTUI(tuiCmd, cfg, "ding"); err == nil { + t.Fatal("failed TUI should fail") + } + profileRunTeaProgram = func(*tea.Program) (tea.Model, error) { return structModel{}, nil } + if _, err := runProfileSwitchTUI(tuiCmd, cfg, "ding"); err == nil { + t.Fatal("wrong TUI model should fail") + } + for _, model := range []profileSwitchTUIModel{{aborted: true}, {submitted: false}} { + model := model + profileRunTeaProgram = func(*tea.Program) (tea.Model, error) { return model, nil } + if _, err := runProfileSwitchTUI(tuiCmd, cfg, "ding"); err == nil { + t.Fatal("aborted TUI should fail") + } + } + final := newProfileSwitchTUIModel(cfg, "ding") + final.submitted = true + profileRunTeaProgram = func(*tea.Program) (tea.Model, error) { return final, nil } + if got, err := runProfileSwitchTUI(tuiCmd, cfg, "ding"); err != nil || got != "ding" { + t.Fatalf("submitted TUI = %q, %v", got, err) + } + + profiles := make([]authpkg.Profile, 8) + model := profileSwitchTUIModel{profiles: profiles, selected: 7, offset: 99} + model.ensureSelectedVisible() + if model.offset != 3 { + t.Fatalf("clamped offset = %d", model.offset) + } + model.offset = -2 + model.selected = 0 + model.ensureSelectedVisible() + if model.offset != 0 { + t.Fatalf("negative offset = %d", model.offset) + } +} + +type emptyReader struct{} + +func (*emptyReader) Read([]byte) (int, error) { return 0, io.EOF } + +type structModel struct{} + +func (structModel) Init() tea.Cmd { return nil } +func (structModel) Update(tea.Msg) (tea.Model, tea.Cmd) { return structModel{}, nil } +func (structModel) View() string { return "" } diff --git a/internal/app/recovery_command.go b/internal/app/recovery_command.go index 8b32fa6e..4f1f2989 100644 --- a/internal/app/recovery_command.go +++ b/internal/app/recovery_command.go @@ -18,6 +18,11 @@ import ( "github.com/spf13/cobra" ) +var ( + recoverySavePlan = (*recovery.Store).SavePlan + recoverySaveAnalysis = (*recovery.Store).SaveAnalysis +) + func newRecoveryCommand(_ context.Context, loader cli.CatalogLoader, flags *GlobalFlags) *cobra.Command { var ( planUseLast bool @@ -60,7 +65,7 @@ func newRecoveryCommand(_ context.Context, loader cli.CatalogLoader, flags *Glob EnableDocSearch: true, }) recovery.HydratePlanForEvent(last.EventID, last.Context, last.Replay, &plan) - if err := store.SavePlan(last.EventID, plan); err != nil { + if err := recoverySavePlan(store, last.EventID, plan); err != nil { return fmt.Errorf("保存恢复计划失败: %w", err) } @@ -90,7 +95,7 @@ func newRecoveryCommand(_ context.Context, loader cli.CatalogLoader, flags *Glob planner := recovery.NewPlanner(runtime) executor := recovery.NewExecutor(planner, runtime) bundle := executor.Execute(cmd.Context(), *last) - if err := store.SaveAnalysis(last.EventID, bundle.Plan, bundle); err != nil { + if err := recoverySaveAnalysis(store, last.EventID, bundle.Plan, bundle); err != nil { return fmt.Errorf("保存恢复分析失败: %w", err) } diff --git a/internal/app/recovery_command_full_coverage_test.go b/internal/app/recovery_command_full_coverage_test.go new file mode 100644 index 00000000..5b2d7682 --- /dev/null +++ b/internal/app/recovery_command_full_coverage_test.go @@ -0,0 +1,153 @@ +package app + +import ( + "context" + "errors" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport" +) + +func recoveryCoverageRun(cmdArgs ...string) (string, error) { + cmd := newRecoveryCommand(context.Background(), cli.StaticLoader{}, &GlobalFlags{}) + out := &strings.Builder{} + cmd.SetOut(out) + cmd.SetErr(io.Discard) + cmd.SetArgs(cmdArgs) + err := cmd.Execute() + return out.String(), err +} + +func TestRecoveryCommandRemainingCoverage(t *testing.T) { + oldSavePlan, oldSaveAnalysis := recoverySavePlan, recoverySaveAnalysis + t.Cleanup(func() { + recoverySavePlan, recoverySaveAnalysis = oldSavePlan, oldSaveAnalysis + }) + configDir := t.TempDir() + t.Setenv("DWS_CONFIG_DIR", configDir) + store := recovery.NewStore(configDir) + last, err := store.Capture(recovery.RecoveryContext{ServerID: "doc", ToolName: "get"}) + if err != nil { + t.Fatal(err) + } + recoverySavePlan = func(*recovery.Store, string, recovery.RecoveryPlan) error { return errors.New("save plan") } + if _, err := recoveryCoverageRun("plan", "--last"); err == nil { + t.Fatal("injected plan save failure succeeded") + } + recoverySavePlan = oldSavePlan + recoverySaveAnalysis = func(*recovery.Store, string, recovery.RecoveryPlan, recovery.RecoveryBundle) error { + return errors.New("save analysis") + } + if _, err := recoveryCoverageRun("execute", "--last"); err == nil { + t.Fatal("injected analysis save failure succeeded") + } + recoverySaveAnalysis = oldSaveAnalysis + + parent := newRecoveryCommand(context.Background(), nil, nil) + parent.SetOut(io.Discard) + if err := parent.RunE(parent, nil); err != nil { + t.Fatal(err) + } + if out, err := recoveryCoverageRun("plan", "--last"); err != nil || !strings.Contains(out, last.EventID) { + t.Fatalf("recovery plan = %q, %v", out, err) + } + if out, err := recoveryCoverageRun("execute", "--event-id", last.EventID); err != nil || out == "" { + t.Fatalf("recovery execute = %q, %v", out, err) + } + + for _, args := range [][]string{ + {"finalize"}, + {"finalize", "--event-id", last.EventID}, + {"finalize", "--event-id", last.EventID, "--outcome", "unknown"}, + {"finalize", "--event-id", last.EventID, "--outcome", "recovered", "--execution-file", "missing"}, + } { + if _, err := recoveryCoverageRun(args...); err == nil { + t.Fatalf("recovery finalize %#v should fail", args) + } + } + executionPath := filepath.Join(t.TempDir(), "execution.json") + if err := os.WriteFile(executionPath, []byte(`{"action":"retry","attempt":1,"result":"ok"}`), 0o600); err != nil { + t.Fatal(err) + } + if out, err := recoveryCoverageRun("finalize", "--event-id", last.EventID, "--outcome", "handoff", "--execution-file", executionPath); err != nil || !strings.Contains(out, "execution_recorded") { + t.Fatalf("recovery finalize = %q, %v", out, err) + } + if _, err := recoveryCoverageRun("finalize", "--event-id", last.EventID, "--outcome", "failed"); err != nil { + t.Fatal(err) + } + + if _, err := loadRecoverySnapshot(store, true, last.EventID); err == nil { + t.Fatal("conflicting snapshot selectors should fail") + } + if _, err := loadRecoverySnapshot(store, false, "missing"); err == nil { + t.Fatal("missing event snapshot should fail") + } + if _, err := loadRecoverySnapshot(store, false, ""); err == nil { + t.Fatal("empty snapshot selector should fail") + } + missingStore := recovery.NewStore(t.TempDir()) + if _, err := loadRecoverySnapshot(missingStore, true, ""); err == nil { + t.Fatal("missing latest snapshot should fail") + } + + eventsPath := filepath.Join(configDir, "recovery", "recovery_events.jsonl") + if err := os.Remove(eventsPath); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(eventsPath, 0o700); err != nil { + t.Fatal(err) + } + if _, err := recoveryCoverageRun("plan", "--last"); err == nil { + t.Fatal("recovery plan save should fail") + } + if _, err := recoveryCoverageRun("execute", "--last"); err == nil { + t.Fatal("recovery analysis save should fail") + } + if _, err := recoveryCoverageRun("finalize", "--event-id", last.EventID, "--outcome", "recovered"); err == nil { + t.Fatal("recovery finalization save should fail") + } +} + +func TestRecoveryExecutionAndRuntimeRemainingCoverage(t *testing.T) { + t.Setenv("DINGTALK_DEVDOC_MCP_URL", "http://127.0.0.1:1") + path := filepath.Join(t.TempDir(), "execution.json") + if err := os.WriteFile(path, []byte(`{"attempts":{}}`), 0o600); err != nil { + t.Fatal(err) + } + if _, err := loadRecoveryExecution(path); err == nil { + t.Fatal("invalid attempts should fail") + } + if _, err := decodeRecoveryAttempts([]byte(`[{}`), nil, "", ""); err == nil { + t.Fatal("invalid attempt array should fail") + } + + fail := errors.New("catalog") + SetDynamicServers(nil) + runtime := &recoveryRuntime{ + loader: cli.CatalogLoaderFrom(cli.Catalog{}, fail), + transport: transport.NewClient(nil), + } + if _, err := runtime.CallToolDirect(context.Background(), "missing", "tool", nil); !errors.Is(err, fail) { + t.Fatalf("direct resolution error = %v", err) + } + if got, err := runtime.Search(context.Background(), "query", recovery.RecoveryContext{}); err == nil || got.DocSearch.Status != "error" { + t.Fatalf("search error = %#v, %v", got, err) + } + if got := parseDocSearchItems(&transport.ToolCallResult{Content: map[string]any{}, Blocks: []transport.ContentBlock{{Text: "not-json"}}}); got != nil { + t.Fatalf("empty doc search items = %#v", got) + } + for _, payload := range []map[string]any{ + {"data": map[string]any{}}, + {"result": map[string]any{}}, + } { + if got := parseDocSearchItemsFromMap(payload); got != nil { + t.Fatalf("empty nested doc search items = %#v", got) + } + } +} diff --git a/internal/app/root.go b/internal/app/root.go index d819ea1e..445476ba 100644 --- a/internal/app/root.go +++ b/internal/app/root.go @@ -49,6 +49,30 @@ type outputFileContextKey struct{} const recoveryEventStderrPrefix = "RECOVERY_EVENT_ID=" +var ( + rootNormalizeProcessProfileArgs = normalizeProcessProfileArgs + rootExecuteCommand = (*cobra.Command).ExecuteC + rootNewRootCommandWithEngine = NewRootCommandWithEngine + rootRunPreParse = pipeline.RunPreParse + rootLatestRecoveryCapture = recovery.LatestCapture + rootResetRecoveryState = recovery.ResetRuntimeState + rootStopAllStdioClients = StopAllStdioClients + rootLoadPlugins = loadPlugins + rootMkdirAll = os.MkdirAll + rootCreateFile = os.Create + rootCloseFile = (*os.File).Close + rootPluginInjectConfigEnv = (*plugin.Loader).InjectPluginConfigEnv + rootPluginLoadUser = (*plugin.Loader).LoadUser + rootPluginLoadDev = (*plugin.Loader).LoadDev + rootPluginDescriptors = (*plugin.Plugin).ToServerDescriptors + rootPluginStdioClients = (*plugin.Plugin).StdioClients + rootRegisterPluginHTTPServer = registerPluginHTTPServer + rootRegisterStdioManifest = registerStdioServerFromManifest + rootPluginLoadHooks = (*plugin.Plugin).LoadHooks + rootPluginSyncSkills = plugin.SyncSkills + rootAuthLoadTokenData = authpkg.LoadTokenData +) + // Execute runs the root command and returns the process exit code. func Execute() (exitCode int) { defer func() { @@ -58,13 +82,13 @@ func Execute() (exitCode int) { } }() - restoreArgs := normalizeProcessProfileArgs() + restoreArgs := rootNormalizeProcessProfileArgs() defer restoreArgs() timing := NewTimingCollector() defer func() { - StopAllStdioClients() // Ensure child processes are terminated on exit - CloseAuditSink() // Drain async audit forwards on all exit paths, + rootStopAllStdioClients() // Ensure child processes are terminated on exit + CloseAuditSink() // Drain async audit forwards on all exit paths, // including command errors where Cobra skips PersistentPostRunE. timing.PrintIfEnabled() timing.WriteReportIfEnabled(RawVersion(), SanitizeCommand(os.Args)) @@ -77,17 +101,17 @@ func Execute() (exitCode int) { ctx = WithTimingCollector(ctx, timing) initStart := time.Now() - recovery.ResetRuntimeState() + rootResetRecoveryState() engine := newPipelineEngine() - root := NewRootCommandWithEngine(ctx, engine) + root := rootNewRootCommandWithEngine(ctx, engine) timing.Record("cmd_init", time.Since(initStart)) // Run PreParse handlers on raw argv before Cobra parses flags. // This corrects model-generated errors like --userId → --user-id // and --limit100 → --limit 100. - pipeline.RunPreParse(root, engine) + rootRunPreParse(root, engine) - executed, err := root.ExecuteC() + executed, err := rootExecuteCommand(root) if err != nil { if executed == nil { executed = root @@ -99,7 +123,7 @@ func Execute() (exitCode int) { _, _ = fmt.Fprintln(os.Stderr) } _ = printExecutionError(executed, os.Stdout, os.Stderr, err) - if last := recovery.LatestCapture(); last != nil && last.EventID != "" { + if last := rootLatestRecoveryCapture(); last != nil && last.EventID != "" { _, _ = fmt.Fprintf(os.Stderr, "%s%s\n", recoveryEventStderrPrefix, last.EventID) } return apperrors.ExitCode(err) @@ -374,7 +398,7 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine) // --- Plugin loading: runs AFTER legacy commands so plugin endpoints can // be appended on top of the static endpoint registry. - pluginCmds := loadPlugins(engine, runner) + pluginCmds := rootLoadPlugins(engine, runner) if len(pluginCmds) > 0 { addPluginCommandsSafe(root, pluginCmds) } @@ -686,10 +710,10 @@ func configureOutputSink(cmd *cobra.Command) error { if err := validateOptionalPath("--output", outputPath); err != nil { return err } - if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil { + if err := rootMkdirAll(filepath.Dir(outputPath), 0o755); err != nil { return apperrors.NewInternal(fmt.Sprintf("failed to prepare output directory: %v", err)) } - file, err := os.Create(outputPath) + file, err := rootCreateFile(outputPath) if err != nil { return apperrors.NewInternal(fmt.Sprintf("failed to create output file: %v", err)) } @@ -703,7 +727,7 @@ func closeOutputSink(cmd *cobra.Command) error { if !ok || file == nil { return nil } - if err := file.Close(); err != nil { + if err := rootCloseFile(file); err != nil { return apperrors.NewInternal(fmt.Sprintf("failed to close output file: %v", err)) } return nil @@ -775,10 +799,10 @@ func loadPlugins(engine *pipeline.Engine, _ executor.Runner) []*cobra.Command { // variables so that expandPluginVars can resolve ${KEY} references // in plugin.json headers, endpoints, etc. User-set env vars take // precedence (InjectPluginConfigEnv skips already-set keys). - pluginLoader.InjectPluginConfigEnv() + rootPluginInjectConfigEnv(pluginLoader) // Load TokenData once; reused for stdio injection below. - tokenData, _ := authpkg.LoadTokenData(defaultConfigDir()) + tokenData, _ := rootAuthLoadTokenData(defaultConfigDir()) var userCtx *plugin.UserContext if tokenData != nil { // Inject user context if either UserID or CorpID is present. @@ -791,32 +815,32 @@ func loadPlugins(engine *pipeline.Engine, _ executor.Runner) []*cobra.Command { } // 1. Load user plugins (per settings.json) - userPlugins := pluginLoader.LoadUser() + userPlugins := rootPluginLoadUser(pluginLoader) // 2. Load dev plugins (registered via `dws plugin dev`) - devPlugins := pluginLoader.LoadDev() + devPlugins := rootPluginLoadDev(pluginLoader) allPlugins := append(userPlugins, devPlugins...) // 3. Register HTTP descriptors and authentication from the manifest. for _, p := range allPlugins { - for _, srv := range p.ToServerDescriptors() { - registerPluginHTTPServer(srv) + for _, srv := range rootPluginDescriptors(p) { + rootRegisterPluginHTTPServer(srv) } } // 4. Register stdio descriptors and unstarted clients. The subprocess is // started and initialized only when a command is actually executed. for _, p := range allPlugins { - for _, sc := range p.StdioClients(userCtx) { - registerStdioServerFromManifest(p, sc) + for _, sc := range rootPluginStdioClients(p, userCtx) { + rootRegisterStdioManifest(p, sc) } } // 5. Register plugin hooks into pipeline engine if engine != nil { for _, p := range allPlugins { - hooksCfg, err := p.LoadHooks() + hooksCfg, err := rootPluginLoadHooks(p) if err != nil { slog.Warn("plugin: failed to load hooks", "plugin", p.Manifest.Name, "error", err) @@ -832,7 +856,7 @@ func loadPlugins(engine *pipeline.Engine, _ executor.Runner) []*cobra.Command { } // 7. Sync plugin skills to agent directories - plugin.SyncSkills(allPlugins) + rootPluginSyncSkills(allPlugins) if len(allPlugins) > 0 { slog.Debug("plugins loaded", diff --git a/internal/app/root_full_coverage_test.go b/internal/app/root_full_coverage_test.go new file mode 100644 index 00000000..7301277f --- /dev/null +++ b/internal/app/root_full_coverage_test.go @@ -0,0 +1,299 @@ +package app + +import ( + "context" + "errors" + "io" + "os" + "path/filepath" + "testing" + + authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes" + "github.com/spf13/cobra" +) + +func TestRootExecuteAllBranchesCoverage(t *testing.T) { + oldNormalize := rootNormalizeProcessProfileArgs + oldExecute := rootExecuteCommand + oldNewRoot := rootNewRootCommandWithEngine + oldPreParse := rootRunPreParse + oldLatest := rootLatestRecoveryCapture + oldReset := rootResetRecoveryState + oldStop := rootStopAllStdioClients + oldArgs := os.Args + t.Cleanup(func() { + rootNormalizeProcessProfileArgs = oldNormalize + rootExecuteCommand = oldExecute + rootNewRootCommandWithEngine = oldNewRoot + rootRunPreParse = oldPreParse + rootLatestRecoveryCapture = oldLatest + rootResetRecoveryState = oldReset + rootStopAllStdioClients = oldStop + os.Args = oldArgs + }) + os.Args = []string{"dws"} + rootNormalizeProcessProfileArgs = func() func() { return func() {} } + rootRunPreParse = func(*cobra.Command, *pipeline.Engine) {} + rootResetRecoveryState = func() {} + rootStopAllStdioClients = func() {} + rootNewRootCommandWithEngine = func(context.Context, *pipeline.Engine) *cobra.Command { + return &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true} + } + rootLatestRecoveryCapture = func() *recovery.LastError { return nil } + rootExecuteCommand = func(cmd *cobra.Command) (*cobra.Command, error) { return cmd, nil } + if code := Execute(); code != 0 { + t.Fatalf("successful Execute code = %d", code) + } + + wantErr := errors.New("unknown command missing") + rootLatestRecoveryCapture = func() *recovery.LastError { return &recovery.LastError{EventID: "evt-test"} } + rootExecuteCommand = func(*cobra.Command) (*cobra.Command, error) { return nil, wantErr } + if code := Execute(); code == 0 { + t.Fatal("failed Execute returned zero") + } + + rootExecuteCommand = func(*cobra.Command) (*cobra.Command, error) { panic("boom") } + if code := Execute(); code != 5 { + t.Fatalf("panic Execute code = %d", code) + } +} + +func TestRootConstructionHooksAndVersionCoverage(t *testing.T) { + oldLoadPlugins := rootLoadPlugins + oldEdition := edition.Get() + oldVersion, oldBuild, oldCommit := version, buildTime, gitCommit + t.Cleanup(func() { + rootLoadPlugins = oldLoadPlugins + edition.Override(oldEdition) + version, buildTime, gitCommit = oldVersion, oldBuild, oldCommit + }) + + rootLoadPlugins = func(*pipeline.Engine, executor.Runner) []*cobra.Command { + return []*cobra.Command{{Use: "plugin-added", Run: func(*cobra.Command, []string) {}}} + } + preRunCalled := false + edition.Override(&edition.Hooks{ + AfterPersistentPreRun: func(*cobra.Command, []string) error { preRunCalled = true; return nil }, + RegisterExtraCommands: func(root *cobra.Command, _ edition.ToolCaller) { + root.AddCommand(&cobra.Command{Use: "extra", Run: func(*cobra.Command, []string) {}}) + }, + }) + root := NewRootCommandWithEngine(context.Background(), pipeline.NewEngine()) + root.SetOut(io.Discard) + root.SetErr(io.Discard) + root.SetArgs([]string{"version", "--client-id", "client", "--client-secret", "secret", "--debug"}) + if err := root.Execute(); err != nil || !preRunCalled { + t.Fatalf("root version execution = %v preRun=%v", err, preRunCalled) + } + + root = NewRootCommandWithEngine(context.Background(), nil) + root.SetOut(io.Discard) + root.SetErr(io.Discard) + root.SetArgs(nil) + if err := root.Execute(); err != nil { + t.Fatalf("root help execution = %v", err) + } + + standalone := newVersionCommand() + standalone.Flags().String("format", "", "") + standalone.SetOut(io.Discard) + standalone.SetArgs([]string{"--format", "json"}) + version, buildTime, gitCommit = "1.2.3", "today", "commit" + edition.Override(&edition.Hooks{}) + if err := standalone.Execute(); err != nil { + t.Fatalf("JSON version = %v", err) + } + + root = NewRootCommandWithEngine(context.Background(), nil) + root.SetOut(io.Discard) + root.SetErr(io.Discard) + root.SetArgs([]string{"version", "--output", "bad\x00path"}) + if err := root.Execute(); err == nil { + t.Fatal("unsafe output path succeeded") + } +} + +func TestRootFlagsPluginsAndOutputRemainingCoverage(t *testing.T) { + parent := &cobra.Command{Use: "root"} + parent.PersistentFlags().String("format", "json", "") + child := &cobra.Command{Use: "child"} + parent.AddCommand(child) + if !wantsJSONErrors(child) { + t.Fatal("root JSON format was not inherited") + } + localRoot := &cobra.Command{Use: "root"} + localRoot.Flags().String("format", "json", "") + localChild := &cobra.Command{Use: "child"} + localRoot.AddCommand(localChild) + if !wantsJSONErrors(localChild) { + t.Fatal("root-local JSON format was not recognized") + } + falseJSON := &cobra.Command{Use: "false-json"} + falseJSON.Flags().Bool("json", true, "") + _ = falseJSON.Flags().Set("json", "false") + if commandRequestsJSONErrors(falseJSON) { + t.Fatal("explicit false JSON flag requested JSON") + } + brokenJSON := &cobra.Command{Use: "broken"} + brokenJSON.Flags().String("json", "not-bool", "") + _ = brokenJSON.Flags().Set("json", "value") + if !commandRequestsJSONErrors(brokenJSON) { + t.Fatal("changed non-bool json flag was not treated as JSON") + } + + pluginRoot := &cobra.Command{Use: "root"} + pluginRoot.AddCommand(&cobra.Command{Use: "market"}) + addPluginCommandsSafe(pluginRoot, []*cobra.Command{ + {Use: "auth"}, + {Use: "duplicate"}, + {Use: "duplicate"}, + {Use: "market"}, + }) + + oldMkdir := rootMkdirAll + oldCreate := rootCreateFile + oldClose := rootCloseFile + t.Cleanup(func() { + rootMkdirAll = oldMkdir + rootCreateFile = oldCreate + rootCloseFile = oldClose + }) + wantErr := errors.New("filesystem") + newOutputCommand := func(path string) *cobra.Command { + root := &cobra.Command{Use: "root"} + root.PersistentFlags().String("output", path, "") + cmd := &cobra.Command{Use: "output"} + root.AddCommand(cmd) + cmd.SetContext(context.Background()) + return cmd + } + successPath := filepath.Join(t.TempDir(), "success", "out") + successCmd := newOutputCommand(successPath) + if err := configureOutputSink(successCmd); err != nil { + t.Fatalf("output sink success = %v", err) + } + if err := closeOutputSink(successCmd); err != nil { + t.Fatalf("output sink close = %v", err) + } + badTypeRoot := &cobra.Command{Use: "root"} + badTypeRoot.PersistentFlags().Bool("output", false, "") + badTypeChild := &cobra.Command{Use: "child"} + badTypeRoot.AddCommand(badTypeChild) + if err := configureOutputSink(badTypeChild); err == nil { + t.Fatal("non-string output flag succeeded") + } + rootMkdirAll = func(string, os.FileMode) error { return wantErr } + if err := configureOutputSink(newOutputCommand(filepath.Join(t.TempDir(), "dir", "out"))); err == nil { + t.Fatal("mkdir failure succeeded") + } + rootMkdirAll = func(string, os.FileMode) error { return nil } + rootCreateFile = func(string) (*os.File, error) { return nil, wantErr } + if err := configureOutputSink(newOutputCommand(filepath.Join(t.TempDir(), "out"))); err == nil { + t.Fatal("create failure succeeded") + } + rootCreateFile = oldCreate + file, err := os.CreateTemp(t.TempDir(), "close") + if err != nil { + t.Fatal(err) + } + cmd := &cobra.Command{Use: "close"} + cmd.SetContext(context.WithValue(context.Background(), outputFileContextKey{}, file)) + rootCloseFile = func(*os.File) error { return wantErr } + if err := closeOutputSink(cmd); err == nil { + t.Fatal("close failure succeeded") + } + rootCloseFile = oldClose + file, err = os.CreateTemp(t.TempDir(), "close-success") + if err != nil { + t.Fatal(err) + } + cmd.SetContext(context.WithValue(context.Background(), outputFileContextKey{}, file)) + if err := closeOutputSink(cmd); err != nil { + t.Fatalf("close success = %v", err) + } + _ = file.Close() + + for _, flags := range []*GlobalFlags{nil, {Debug: true}, {Verbose: true}, {}} { + configureLogLevel(flags) + } +} + +func TestRootLoadPluginsRemainingCoverage(t *testing.T) { + oldInject := rootPluginInjectConfigEnv + oldUser := rootPluginLoadUser + oldDev := rootPluginLoadDev + oldDescriptors := rootPluginDescriptors + oldStdioClients := rootPluginStdioClients + oldHTTP := rootRegisterPluginHTTPServer + oldStdio := rootRegisterStdioManifest + oldHooks := rootPluginLoadHooks + oldSync := rootPluginSyncSkills + oldToken := rootAuthLoadTokenData + t.Cleanup(func() { + rootPluginInjectConfigEnv = oldInject + rootPluginLoadUser = oldUser + rootPluginLoadDev = oldDev + rootPluginDescriptors = oldDescriptors + rootPluginStdioClients = oldStdioClients + rootRegisterPluginHTTPServer = oldHTTP + rootRegisterStdioManifest = oldStdio + rootPluginLoadHooks = oldHooks + rootPluginSyncSkills = oldSync + rootAuthLoadTokenData = oldToken + }) + + p1 := &plugin.Plugin{Manifest: plugin.Manifest{Name: "one"}} + p2 := &plugin.Plugin{Manifest: plugin.Manifest{Name: "two"}} + p3 := &plugin.Plugin{Manifest: plugin.Manifest{Name: "three"}} + rootPluginInjectConfigEnv = func(*plugin.Loader) {} + rootPluginLoadUser = func(*plugin.Loader) []*plugin.Plugin { return []*plugin.Plugin{p1, p2} } + rootPluginLoadDev = func(*plugin.Loader) []*plugin.Plugin { return []*plugin.Plugin{p3} } + rootAuthLoadTokenData = func(string) (*authpkg.TokenData, error) { + return &authpkg.TokenData{UserID: "user", CorpID: "corp"}, nil + } + rootPluginDescriptors = func(p *plugin.Plugin) []mcptypes.ServerDescriptor { + if p == p1 { + return []mcptypes.ServerDescriptor{{Key: "http", Endpoint: "https://example.test"}} + } + return []mcptypes.ServerDescriptor{{Key: "no-cli", Endpoint: "https://example.test"}} + } + client := transport.NewStdioClient("ignored", nil, nil) + rootPluginStdioClients = func(p *plugin.Plugin, uc *plugin.UserContext) []plugin.StdioServerClient { + if p == p1 && uc != nil && uc.UserID == "user" { + return []plugin.StdioServerClient{{Key: "local", Client: client}} + } + return nil + } + httpCount := 0 + stdioCount := 0 + rootRegisterPluginHTTPServer = func(mcptypes.ServerDescriptor) { httpCount++ } + rootRegisterStdioManifest = func(*plugin.Plugin, plugin.StdioServerClient) mcptypes.ServerDescriptor { + stdioCount++ + return mcptypes.ServerDescriptor{} + } + rootPluginLoadHooks = func(p *plugin.Plugin) (*plugin.HooksConfig, error) { + switch p { + case p1: + return nil, errors.New("hooks") + case p2: + return nil, nil + default: + return &plugin.HooksConfig{Hooks: []plugin.HookEntry{{Phase: "pre-request", Command: "true"}}}, nil + } + } + synced := false + rootPluginSyncSkills = func([]*plugin.Plugin) { synced = true } + if got := loadPlugins(pipeline.NewEngine(), runnerCoverageFallback{}); got != nil { + t.Fatalf("loaded plugin commands = %#v", got) + } + if httpCount != 3 || stdioCount != 1 || !synced { + t.Fatalf("registered http=%d stdio=%d synced=%v", httpCount, stdioCount, synced) + } +} diff --git a/internal/app/root_help.go b/internal/app/root_help.go index 451b1129..14fb56b7 100644 --- a/internal/app/root_help.go +++ b/internal/app/root_help.go @@ -183,9 +183,6 @@ func visibleMCPRootCommands(root *cobra.Command) []*cobra.Command { } allowed := resolveVisibleProducts() - if len(allowed) == 0 { - return nil - } commands := make([]*cobra.Command, 0) for _, cmd := range root.Commands() { diff --git a/internal/app/runner.go b/internal/app/runner.go index bb325285..a13c0fd1 100644 --- a/internal/app/runner.go +++ b/internal/app/runner.go @@ -20,6 +20,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "log/slog" "net/http" "os" @@ -40,6 +41,9 @@ import ( ) func init() { + runnerHandlePatAuthCheck = handlePatAuthCheck + runnerRetryWithPatAuthRetry = retryWithPatAuthRetry + configmeta.Register(configmeta.ConfigItem{ Name: "DWS_RUNTIME_CONTENT_SCAN", Category: configmeta.CategoryRuntime, @@ -158,6 +162,19 @@ type runtimeRunner struct { auditSink audit.Sink } +var ( + runnerResolveMultiProfileSelections = resolveMultiProfileSelections + runnerResolveProfile = authpkg.ResolveProfile + runnerGetCachedRuntimeToken = getCachedRuntimeToken + runnerPreflightDocDownload = (*runtimeRunner).preflightDocDownload + runnerCallTool = (*transport.Client).CallTool + runnerStdioEnsureInitialized = (*transport.StdioClient).EnsureInitialized + runnerStdioCallTool = (*transport.StdioClient).CallTool + runnerHandlePatAuthCheck func(context.Context, *runtimeRunner, executor.Invocation, *apperrors.PATError, string, io.Writer) (executor.Result, error) + runnerRetryWithPatAuthRetry func(context.Context, executor.Runner, executor.Invocation, *PatScopeError, string, io.Writer) (executor.Result, error) + runnerCaptureRuntimeFailure = captureRuntimeFailure +) + func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation) (executor.Result, error) { // Global dry-run is an execution barrier, not merely a transport option. // Return a deterministic local preview before profile resolution, catalog @@ -177,7 +194,7 @@ func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation) // invocations within the same process free. logHostOwnedPATDecisionOnce() - selections, multi, err := resolveMultiProfileSelections(defaultConfigDir(), authpkg.RuntimeProfile()) + selections, multi, err := runnerResolveMultiProfileSelections(defaultConfigDir(), authpkg.RuntimeProfile()) if err != nil { return executor.Result{}, apperrors.NewValidation(err.Error()) } @@ -207,7 +224,7 @@ func (r *runtimeRunner) runSingle(ctx context.Context, invocation executor.Invoc // ~70ms on macOS; starting it here lets the load overlap with endpoint // resolution and catalog loading below. if prefetchToken { - go getCachedRuntimeToken(ctx) + go runnerGetCachedRuntimeToken(ctx) } if shouldUseDirectRuntime(invocation) { @@ -278,7 +295,7 @@ func resolveMultiProfileSelections(configDir, rawSelector string) ([]multiProfil if rawSelector == "" || !strings.Contains(rawSelector, ",") { return nil, false, nil } - if p, err := authpkg.ResolveProfile(configDir, rawSelector); err == nil && p != nil { + if p, err := runnerResolveProfile(configDir, rawSelector); err == nil && p != nil { return nil, false, nil } @@ -290,7 +307,7 @@ func resolveMultiProfileSelections(configDir, rawSelector string) ([]multiProfil if selector == "" { return nil, false, fmt.Errorf("--profile contains an empty profile selector: %q", rawSelector) } - profile, err := authpkg.ResolveProfile(configDir, selector) + profile, err := runnerResolveProfile(configDir, selector) if err != nil { return nil, false, err } @@ -306,9 +323,6 @@ func resolveMultiProfileSelections(configDir, rawSelector string) ([]multiProfil Profile: *profile, }) } - if len(selections) == 0 { - return nil, false, nil - } return selections, true, nil } @@ -570,25 +584,25 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string, defer cancel() } - if err := r.preflightDocDownload(callCtx, tc, endpoint, invocation); err != nil { + if err := runnerPreflightDocDownload(r, callCtx, tc, endpoint, invocation); err != nil { if patCheck := apperrors.AsPatAuthCheckError(err); patCheck != nil { if IsPatRetrying(ctx) { return executor.Result{}, patCheck } - return handlePatAuthCheck(ctx, r, invocation, patCheck, defaultConfigDir(), os.Stderr) + return runnerHandlePatAuthCheck(ctx, r, invocation, patCheck, defaultConfigDir(), os.Stderr) } - captureRuntimeFailure(invocation, err, err) + runnerCaptureRuntimeFailure(invocation, err, err) return executor.Result{}, err } callStart := time.Now() - callResult, err := tc.CallTool(callCtx, endpoint, invocation.Tool, invocation.Params) + callResult, err := runnerCallTool(tc, callCtx, endpoint, invocation.Tool, invocation.Params) RecordTiming(ctx, "mcp_call", time.Since(callStart)) if err != nil { if isAuthError(err) { if fn := edition.Get().OnAuthError; fn != nil { if overrideErr := fn(defaultConfigDir(), err); overrideErr != nil { - captureRuntimeFailure(invocation, err, overrideErr) + runnerCaptureRuntimeFailure(invocation, err, overrideErr) return executor.Result{}, overrideErr } } @@ -596,10 +610,10 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string, // PAT scope error: offer human-readable output and retry after authorization if isPatScopeError(err) { scopeErr := extractPatScopeError(err) - captureRuntimeFailure(invocation, err, err) - return retryWithPatAuthRetry(ctx, r, invocation, scopeErr, defaultConfigDir(), os.Stderr) + runnerCaptureRuntimeFailure(invocation, err, err) + return runnerRetryWithPatAuthRetry(ctx, r, invocation, scopeErr, defaultConfigDir(), os.Stderr) } - captureRuntimeFailure(invocation, err, err) + runnerCaptureRuntimeFailure(invocation, err, err) return executor.Result{}, err } @@ -610,7 +624,7 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string, if IsPatRetrying(ctx) { return executor.Result{}, patCheck // already retried once, don't loop } - return handlePatAuthCheck(ctx, r, invocation, patCheck, defaultConfigDir(), os.Stderr) + return runnerHandlePatAuthCheck(ctx, r, invocation, patCheck, defaultConfigDir(), os.Stderr) } return executor.Result{}, editionErr } @@ -621,7 +635,7 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string, if IsPatRetrying(ctx) { return executor.Result{}, patCheck // already retried once, don't loop } - return handlePatAuthCheck(ctx, r, invocation, patCheck, defaultConfigDir(), os.Stderr) + return runnerHandlePatAuthCheck(ctx, r, invocation, patCheck, defaultConfigDir(), os.Stderr) } if callResult.IsError { @@ -632,7 +646,7 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string, // patterns (PAT permission, gateway-auth) before generic handling. if classify := edition.Get().ClassifyToolResult; classify != nil { if hookErr := classify(callResult.Content); hookErr != nil { - captureRuntimeFailure(invocation, hookErr, hookErr) + runnerCaptureRuntimeFailure(invocation, hookErr, hookErr) return executor.Result{}, hookErr } } @@ -648,10 +662,10 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string, // PAT scope error in business response: offer human-readable output and retry if isPatScopeError(mcpErr) { scopeErr := extractPatScopeError(mcpErr) - captureRuntimeFailure(invocation, mcpErr, mcpErr) - return retryWithPatAuthRetry(ctx, r, invocation, scopeErr, defaultConfigDir(), os.Stderr) + runnerCaptureRuntimeFailure(invocation, mcpErr, mcpErr) + return runnerRetryWithPatAuthRetry(ctx, r, invocation, scopeErr, defaultConfigDir(), os.Stderr) } - captureRuntimeFailure(invocation, mcpErr, mcpErr) + runnerCaptureRuntimeFailure(invocation, mcpErr, mcpErr) return executor.Result{}, mcpErr } @@ -720,7 +734,7 @@ func (r *runtimeRunner) executeStdioInvocation(ctx context.Context, invocation e callCtx, cancel = context.WithTimeout(ctx, time.Duration(r.globalFlags.Timeout)*time.Second) defer cancel() } - if err := client.EnsureInitialized(callCtx); err != nil { + if err := runnerStdioEnsureInitialized(client, callCtx); err != nil { return executor.Result{}, apperrors.NewAPI( fmt.Sprintf("stdio initialize failed: %v", err), apperrors.WithOperation("initialize"), @@ -728,7 +742,7 @@ func (r *runtimeRunner) executeStdioInvocation(ctx context.Context, invocation e ) } - callResult, err := client.CallTool(callCtx, invocation.Tool, invocation.Params) + callResult, err := runnerStdioCallTool(client, callCtx, invocation.Tool, invocation.Params) if err != nil { return executor.Result{}, apperrors.NewAPI( fmt.Sprintf("stdio call failed: %v", err), @@ -889,9 +903,6 @@ func productEndpointOverride(productID string) (string, bool) { func resolveIdentityHeaders() map[string]string { id := authpkg.EnsureExists(defaultConfigDir()) headers := id.Headers() - if headers == nil { - headers = make(map[string]string) - } // Inject environment variable based headers for MCP gateway tracking. // DINGTALK_AGENT, if set by the caller, is forwarded verbatim as the diff --git a/internal/app/runner_full_coverage_test.go b/internal/app/runner_full_coverage_test.go new file mode 100644 index 00000000..5ea85e7f --- /dev/null +++ b/internal/app/runner_full_coverage_test.go @@ -0,0 +1,366 @@ +package app + +import ( + "context" + "errors" + "io" + "strings" + "testing" + + authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/safety" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" +) + +type runnerCoverageFallback struct { + result executor.Result + err error +} + +func (f runnerCoverageFallback) Run(context.Context, executor.Invocation) (executor.Result, error) { + return f.result, f.err +} + +func TestRunnerRemainingRoutingCoverage(t *testing.T) { + oldResolveMulti := runnerResolveMultiProfileSelections + oldResolveProfile := runnerResolveProfile + oldCachedToken := runnerGetCachedRuntimeToken + t.Cleanup(func() { + runnerResolveMultiProfileSelections = oldResolveMulti + runnerResolveProfile = oldResolveProfile + runnerGetCachedRuntimeToken = oldCachedToken + }) + + created := newCommandRunnerWithFlags(cli.StaticLoader{}, &GlobalFlags{Timeout: 2}) + if created.(*runtimeRunner).transport == nil { + t.Fatal("runner transport was not created") + } + + wantErr := errors.New("profiles failed") + runnerResolveMultiProfileSelections = func(string, string) ([]multiProfileSelection, bool, error) { + return nil, false, wantErr + } + if _, err := (&runtimeRunner{}).Run(context.Background(), executor.Invocation{}); err == nil { + t.Fatal("profile resolution error was accepted") + } + + inv := executor.Invocation{CanonicalProduct: "product", Tool: "tool"} + prefetched := make(chan struct{}, 1) + runnerGetCachedRuntimeToken = func(context.Context) string { + prefetched <- struct{}{} + return "" + } + r := &runtimeRunner{ + loader: cli.CatalogLoaderFrom(cli.Catalog{}, wantErr), + transport: transport.NewClient(nil), + fallback: runnerCoverageFallback{}, + } + directMiss := inv + directMiss.Kind = "helper_invocation" + if _, err := r.runSingle(context.Background(), directMiss, false); !errors.Is(err, wantErr) { + t.Fatalf("direct runtime miss load error = %v", err) + } + directHit := executor.Invocation{Kind: "helper_invocation", CanonicalProduct: defaultPATProductID, Tool: "pat", DryRun: true} + if got, err := r.runSingle(context.Background(), directHit, false); err != nil || got.Response["dry_run"] != true { + t.Fatalf("direct runtime hit = %#v, %v", got, err) + } + if _, err := r.runSingle(context.Background(), inv, true); !errors.Is(err, wantErr) { + t.Fatalf("runSingle error = %v", err) + } + <-prefetched + + runnerResolveProfile = func(_ string, selector string) (*authpkg.Profile, error) { + if selector == "a,b" { + return nil, errors.New("not a combined profile") + } + if selector == "a" { + return nil, wantErr + } + return nil, nil + } + if _, _, err := resolveMultiProfileSelections("", "a,b"); !errors.Is(err, wantErr) { + t.Fatalf("profile error = %v", err) + } + runnerResolveProfile = func(_ string, selector string) (*authpkg.Profile, error) { + if selector == "a,b" { + return nil, errors.New("not a combined profile") + } + if selector == "a" { + return &authpkg.Profile{CorpID: "corp-a"}, nil + } + return nil, nil + } + if _, _, err := resolveMultiProfileSelections("", "a,b"); err == nil || !strings.Contains(err.Error(), "not found") { + t.Fatalf("nil profile error = %v", err) + } + + selections := []multiProfileSelection{ + {Selector: "bad", Profile: authpkg.Profile{CorpID: "bad"}}, + {Selector: "good", Profile: authpkg.Profile{CorpID: "good"}}, + } + r = &runtimeRunner{fallback: runnerCoverageFallback{err: wantErr}} + result, err := r.runMultiProfile(context.Background(), inv, selections[:1]) + if err != nil || result.Response == nil { + t.Fatalf("multi failure aggregation = %#v, %v", result, err) + } + r.fallback = runnerCoverageFallback{result: executor.Result{Response: map[string]any{"endpoint": "local", "value": 1}}} + result, err = r.runMultiProfile(context.Background(), inv, selections[1:]) + if err != nil || result.Response == nil { + t.Fatalf("multi success aggregation = %#v, %v", result, err) + } + + product := cli.CanonicalProduct{ID: "product", Endpoint: "https://catalog.test", Tools: []cli.ToolDescriptor{{RPCName: "tool"}}} + r = &runtimeRunner{ + loader: cli.StaticLoader{Catalog: cli.Catalog{Products: []cli.CanonicalProduct{product}}}, + transport: transport.NewClient(nil), + globalFlags: &GlobalFlags{DryRun: true}, + fallback: runnerCoverageFallback{}, + } + t.Setenv("DINGTALK_PRODUCT_MCP_URL", "https://override.test") + got, err := r.runSingle(context.Background(), inv, false) + if err != nil || got.Response["endpoint"] != "https://override.test" { + t.Fatalf("catalog override = %#v, %v", got, err) + } +} + +func TestRunnerRemainingExecutionCoverage(t *testing.T) { + oldEdition := edition.Get() + oldPreflight := runnerPreflightDocDownload + oldCall := runnerCallTool + oldHandle := runnerHandlePatAuthCheck + oldRetry := runnerRetryWithPatAuthRetry + oldCapture := runnerCaptureRuntimeFailure + t.Cleanup(func() { + edition.Override(oldEdition) + runnerPreflightDocDownload = oldPreflight + runnerCallTool = oldCall + runnerHandlePatAuthCheck = oldHandle + runnerRetryWithPatAuthRetry = oldRetry + runnerCaptureRuntimeFailure = oldCapture + }) + + pluginAuthMu.Lock() + oldPluginRegistry := pluginAuthRegistry + pluginAuthRegistry = make(map[string]*PluginAuth) + pluginAuthMu.Unlock() + t.Cleanup(func() { + pluginAuthMu.Lock() + pluginAuthRegistry = oldPluginRegistry + pluginAuthMu.Unlock() + }) + + runnerCaptureRuntimeFailure = func(executor.Invocation, error, error) {} + runnerPreflightDocDownload = func(*runtimeRunner, context.Context, *transport.Client, string, executor.Invocation) error { + return nil + } + runnerCallTool = func(*transport.Client, context.Context, string, string, map[string]any) (transport.ToolCallResult, error) { + return transport.ToolCallResult{Content: map[string]any{"value": 1}}, nil + } + r := &runtimeRunner{ + transport: transport.NewClient(nil), + globalFlags: &GlobalFlags{Token: "token", Timeout: 1}, + } + inv := executor.Invocation{CanonicalProduct: "product", Tool: "tool", Params: map[string]any{"x": 1}} + + if got, err := r.executeInvocation(context.Background(), "https://example.test", inv); err != nil || !got.Invocation.Implemented { + t.Fatalf("default auth execution = %#v, %v", got, err) + } + + wantErr := errors.New("preflight") + runnerPreflightDocDownload = func(*runtimeRunner, context.Context, *transport.Client, string, executor.Invocation) error { + return wantErr + } + if _, err := r.executeInvocation(context.Background(), "https://example.test", inv); !errors.Is(err, wantErr) { + t.Fatalf("preflight error = %v", err) + } + patErr := &apperrors.PATError{RawJSON: `{"code":"PAT_NO_PERMISSION"}`} + runnerPreflightDocDownload = func(*runtimeRunner, context.Context, *transport.Client, string, executor.Invocation) error { + return patErr + } + retrying := context.WithValue(context.Background(), patRetryingKey, true) + if _, err := r.executeInvocation(retrying, "https://example.test", inv); !errors.Is(err, patErr) { + t.Fatalf("retrying preflight PAT = %v", err) + } + handled := errors.New("handled PAT") + runnerHandlePatAuthCheck = func(context.Context, *runtimeRunner, executor.Invocation, *apperrors.PATError, string, io.Writer) (executor.Result, error) { + return executor.Result{}, handled + } + if _, err := r.executeInvocation(context.Background(), "https://example.test", inv); !errors.Is(err, handled) { + t.Fatalf("handled preflight PAT = %v", err) + } + runnerPreflightDocDownload = oldPreflight + runnerPreflightDocDownload = func(*runtimeRunner, context.Context, *transport.Client, string, executor.Invocation) error { + return nil + } + + authErr := apperrors.NewAuth("expired") + runnerCallTool = func(*transport.Client, context.Context, string, string, map[string]any) (transport.ToolCallResult, error) { + return transport.ToolCallResult{}, authErr + } + overrideErr := errors.New("auth override") + edition.Override(&edition.Hooks{OnAuthError: func(string, error) error { return overrideErr }}) + if _, err := r.executeInvocation(context.Background(), "https://example.test", inv); !errors.Is(err, overrideErr) { + t.Fatalf("auth override = %v", err) + } + edition.Override(&edition.Hooks{OnAuthError: func(string, error) error { return nil }}) + if _, err := r.executeInvocation(context.Background(), "https://example.test", inv); !errors.Is(err, authErr) { + t.Fatalf("auth passthrough = %v", err) + } + + scopeErr := errors.New("missing_scope calendar:read") + runnerCallTool = func(*transport.Client, context.Context, string, string, map[string]any) (transport.ToolCallResult, error) { + return transport.ToolCallResult{}, scopeErr + } + retried := errors.New("scope retried") + runnerRetryWithPatAuthRetry = func(context.Context, executor.Runner, executor.Invocation, *PatScopeError, string, io.Writer) (executor.Result, error) { + return executor.Result{}, retried + } + if _, err := r.executeInvocation(context.Background(), "https://example.test", inv); !errors.Is(err, retried) { + t.Fatalf("scope retry = %v", err) + } + + runnerCallTool = func(*transport.Client, context.Context, string, string, map[string]any) (transport.ToolCallResult, error) { + return transport.ToolCallResult{}, wantErr + } + edition.Override(&edition.Hooks{}) + if _, err := r.executeInvocation(context.Background(), "https://example.test", inv); !errors.Is(err, wantErr) { + t.Fatalf("generic call error = %v", err) + } + + patContent := map[string]any{"code": "PAT_NO_PERMISSION", "data": map[string]any{"flowId": "f"}} + runnerCallTool = func(*transport.Client, context.Context, string, string, map[string]any) (transport.ToolCallResult, error) { + return transport.ToolCallResult{Content: patContent}, nil + } + edition.Override(&edition.Hooks{ClassifyToolResult: func(map[string]any) error { return patErr }}) + if _, err := r.executeInvocation(retrying, "https://example.test", inv); !errors.Is(err, patErr) { + t.Fatalf("edition retry PAT = %v", err) + } + if _, err := r.executeInvocation(context.Background(), "https://example.test", inv); !errors.Is(err, handled) { + t.Fatalf("edition handled PAT = %v", err) + } + edition.Override(&edition.Hooks{ClassifyToolResult: func(map[string]any) error { return wantErr }}) + if _, err := r.executeInvocation(context.Background(), "https://example.test", inv); !errors.Is(err, wantErr) { + t.Fatalf("edition classification = %v", err) + } + + edition.Override(&edition.Hooks{}) + if _, err := r.executeInvocation(retrying, "https://example.test", inv); err == nil { + t.Fatal("built-in retrying PAT succeeded") + } + if _, err := r.executeInvocation(context.Background(), "https://example.test", inv); !errors.Is(err, handled) { + t.Fatalf("built-in handled PAT = %v", err) + } + + callCount := 0 + edition.Override(&edition.Hooks{ClassifyToolResult: func(map[string]any) error { + callCount++ + if callCount%2 == 0 { + return wantErr + } + return nil + }}) + runnerCallTool = func(*transport.Client, context.Context, string, string, map[string]any) (transport.ToolCallResult, error) { + return transport.ToolCallResult{IsError: true, Content: map[string]any{"message": "business"}}, nil + } + if _, err := r.executeInvocation(context.Background(), "https://example.test", inv); !errors.Is(err, wantErr) { + t.Fatalf("business hook = %v", err) + } + + edition.Override(&edition.Hooks{}) + runnerCallTool = func(*transport.Client, context.Context, string, string, map[string]any) (transport.ToolCallResult, error) { + return transport.ToolCallResult{IsError: true, Blocks: []transport.ContentBlock{{Text: "missing_scope mail:read"}}, Content: map[string]any{}}, nil + } + if _, err := r.executeInvocation(context.Background(), "https://example.test", inv); !errors.Is(err, retried) { + t.Fatalf("business scope retry = %v", err) + } + + r.scanner = coverageScanner{report: safety.Report{Scanned: true, Findings: []safety.Finding{{Pattern: "bad"}}}} + r.enforceContentScan = true + runnerCallTool = func(*transport.Client, context.Context, string, string, map[string]any) (transport.ToolCallResult, error) { + return transport.ToolCallResult{Content: map[string]any{"value": 1}}, nil + } + if _, err := r.executeInvocation(context.Background(), "https://example.test", inv); err == nil { + t.Fatal("scan failure succeeded") + } +} + +func TestRunnerRemainingStdioAuthAndHeadersCoverage(t *testing.T) { + oldStdioInit := runnerStdioEnsureInitialized + oldStdioCall := runnerStdioCallTool + oldEdition := edition.Get() + t.Cleanup(func() { + runnerStdioEnsureInitialized = oldStdioInit + runnerStdioCallTool = oldStdioCall + edition.Override(oldEdition) + StopAllStdioClients() + }) + + client := transport.NewStdioClient("unused", nil, nil) + RegisterStdioClient("stdio-product", client) + r := &runtimeRunner{globalFlags: &GlobalFlags{Timeout: 1}} + 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") { + t.Fatalf("stdio initialize error = %v", err) + } + runnerStdioEnsureInitialized = func(*transport.StdioClient, context.Context) error { return nil } + 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") { + 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") { + 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 { + t.Fatalf("stdio success = %#v, %v", got, err) + } + + r.globalFlags.Token = " explicit " + if got := r.resolveAuthToken(context.Background()); got != "explicit" { + t.Fatalf("explicit auth token = %q", got) + } + edition.Override(&edition.Hooks{TokenProvider: func(_ context.Context, fallback func() (string, error)) (string, error) { + _, _ = fallback() + return "provided", nil + }}) + r.globalFlags.Token = "" + if got := r.resolveAuthToken(context.Background()); got != "provided" { + t.Fatalf("provided auth token = %q", got) + } + if got := resolveRuntimeAuthToken(context.Background(), " runtime "); got != "runtime" { + t.Fatalf("runtime explicit token = %q", got) + } + + t.Setenv(envDWSChannel, "channel") + edition.Override(&edition.Hooks{ + MergeHeaders: func(headers map[string]string) map[string]string { return headers }, + EnterpriseCredentialHeaders: func(headers map[string]string) map[string]string { + headers["x-enterprise"] = "yes" + return headers + }, + }) + headers := resolveIdentityHeaders() + if headers["x-dws-channel"] != "channel" || headers["x-enterprise"] != "yes" { + t.Fatalf("identity headers = %#v", headers) + } + + if got := detectBusinessError(map[string]any{"content": map[string]any{"success": false, "errorMsg": "nested"}}); got != "nested" { + t.Fatalf("nested business error = %q", got) + } + if got := detectBusinessError(map[string]any{"success": false, "data": map[string]any{"success": false, "errorMsg": "nested-first"}}); got != "nested-first" { + t.Fatalf("nested-first business error = %q", got) + } +} diff --git a/internal/app/skill_command.go b/internal/app/skill_command.go index 4cd5d5bf..c4d63a21 100644 --- a/internal/app/skill_command.go +++ b/internal/app/skill_command.go @@ -35,6 +35,28 @@ import ( "github.com/spf13/cobra" ) +var ( + skillLoadAccessToken = loadSkillAccessToken + skillDownloadToTmp = downloadSkillToTmpDir + skillHTTPDo = func(client *http.Client, req *http.Request) (*http.Response, error) { return client.Do(req) } + skillNewRequest = http.NewRequestWithContext + skillLoadTokenData = authpkg.LoadTokenData + skillResolveTargetPath = resolveSkillTargetPath + skillFetchDownloadInfo = fetchSkillDownloadInfo + skillDownloadFile = downloadSkillFile + skillExtractZip = extractSkillZip + skillUserHomeDir = os.UserHomeDir + skillMkdirTemp = os.MkdirTemp + skillCreate = os.Create + skillCreateTemp = os.CreateTemp + skillRemoveAll = os.RemoveAll + skillRemove = os.Remove + skillMkdirAll = os.MkdirAll + skillOpenFile = os.OpenFile + skillCopy = io.Copy + skillOpenZipFile = func(file *zip.File) (io.ReadCloser, error) { return file.Open() } +) + func init() { configmeta.Register(configmeta.ConfigItem{ Name: "DWS_SKILL_API_HOST", @@ -274,7 +296,7 @@ func newSkillAddHintCommand() *cobra.Command { func runSkillGet(cmd *cobra.Command, args []string) error { skillID, _ := cmd.Flags().GetString("skill-id") - accessToken, err := loadSkillAccessToken() + accessToken, err := skillLoadAccessToken() if err != nil { return err } @@ -282,7 +304,7 @@ func runSkillGet(cmd *cobra.Command, args []string) error { apiURL := fmt.Sprintf("%s/cli/install?skillId=%s", skillAPIHost(), url.QueryEscape(strings.TrimSpace(skillID))) _, _ = fmt.Fprintln(cmd.OutOrStdout(), "⬇️ 下载技能包...") - tmpDir, err := downloadSkillToTmpDir(cmd.Context(), apiURL, accessToken) + tmpDir, err := skillDownloadToTmp(cmd.Context(), apiURL, accessToken) if err != nil { return err } @@ -297,7 +319,7 @@ func runSkillFind(cmd *cobra.Command, args []string) error { if source == "" { source, _ = cmd.Flags().GetString("scopes") } - accessToken, err := loadSkillAccessToken() + accessToken, err := skillLoadAccessToken() if err != nil { return err } @@ -306,14 +328,14 @@ func runSkillFind(cmd *cobra.Command, args []string) error { if source != "" { apiURL += "&source=" + url.QueryEscape(source) } - req, err := http.NewRequestWithContext(cmd.Context(), http.MethodGet, apiURL, nil) + req, err := skillNewRequest(cmd.Context(), http.MethodGet, apiURL, nil) if err != nil { return apperrors.NewInternal(fmt.Sprintf("failed to create request: %v", err)) } req.Header.Set("x-user-access-token", accessToken) client := &http.Client{Timeout: 30 * time.Second} - resp, err := client.Do(req) + resp, err := skillHTTPDo(client, req) if err != nil { return apperrors.NewAPI(fmt.Sprintf("failed to search skills: %v", err), apperrors.WithRetryable(true)) } @@ -361,12 +383,12 @@ func runSkillAdd(cmd *cobra.Command, args []string) error { } // Resolve target path - destPath, err := resolveSkillTargetPath(target) + destPath, err := skillResolveTargetPath(target) if err != nil { return apperrors.NewValidation(fmt.Sprintf("invalid target '%s': %v. Supported targets: %s", target, err, supportedTargets())) } - accessToken, err := loadSkillAccessToken() + accessToken, err := skillLoadAccessToken() if err != nil { return err } @@ -378,7 +400,7 @@ func runSkillAdd(cmd *cobra.Command, args []string) error { // Step 1: Get download URL from API fmt.Fprintf(w, "正在获取技能信息...\n") - downloadResp, err := fetchSkillDownloadInfo(ctx, accessToken, skillID) + downloadResp, err := skillFetchDownloadInfo(ctx, accessToken, skillID) if err != nil { return err } @@ -401,7 +423,7 @@ func runSkillAdd(cmd *cobra.Command, args []string) error { // Step 2: Download the skill zip file fmt.Fprintf(w, "正在下载技能...\n") - tempZipPath, err := downloadSkillFile(ctx, downloadResp.Result.DownloadURL, downloadResp.Result.FileName) + tempZipPath, err := skillDownloadFile(ctx, downloadResp.Result.DownloadURL, downloadResp.Result.FileName) if err != nil { return err } @@ -409,7 +431,7 @@ func runSkillAdd(cmd *cobra.Command, args []string) error { // Step 3: Extract zip to destination fmt.Fprintf(w, "正在解压到 %s...\n", destPath) - if err := extractSkillZip(tempZipPath, destPath); err != nil { + if err := skillExtractZip(tempZipPath, destPath); err != nil { return err } @@ -421,7 +443,7 @@ func runSkillAdd(cmd *cobra.Command, args []string) error { func loadSkillAccessToken() (string, error) { configDir := defaultConfigDir() - tokenData, err := authpkg.LoadTokenData(configDir) + tokenData, err := skillLoadTokenData(configDir) if err != nil || tokenData == nil || !tokenData.IsAccessTokenValid() { return "", skillAuthError() } @@ -464,7 +486,7 @@ func resolveSkillTargetPath(target string) (string, error) { return "", fmt.Errorf("unsupported target") } - homeDir, err := os.UserHomeDir() + homeDir, err := skillUserHomeDir() if err != nil { return "", fmt.Errorf("failed to get home directory: %w", err) } @@ -476,7 +498,7 @@ func resolveSkillTargetPath(target string) (string, error) { func fetchSkillDownloadInfo(ctx context.Context, accessToken, skillID string) (*downloadSkillResponse, error) { url := fmt.Sprintf("%s?skillId=%s", skillDownloadEndpoint, skillID) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + req, err := skillNewRequest(ctx, http.MethodGet, url, nil) if err != nil { return nil, apperrors.NewInternal(fmt.Sprintf("failed to create request: %v", err)) } @@ -485,7 +507,7 @@ func fetchSkillDownloadInfo(ctx context.Context, accessToken, skillID string) (* req.Header.Set("x-user-access-token", accessToken) client := &http.Client{Timeout: 30 * time.Second} - resp, err := client.Do(req) + resp, err := skillHTTPDo(client, req) if err != nil { return nil, apperrors.NewAPI(fmt.Sprintf("failed to call download API: %v", err), apperrors.WithRetryable(true)) @@ -515,14 +537,14 @@ func fetchSkillDownloadInfo(ctx context.Context, accessToken, skillID string) (* } func downloadSkillToTmpDir(ctx context.Context, apiURL, accessToken string) (string, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil) + req, err := skillNewRequest(ctx, http.MethodGet, apiURL, nil) if err != nil { return "", apperrors.NewInternal(fmt.Sprintf("failed to create request: %v", err)) } req.Header.Set("x-user-access-token", accessToken) client := &http.Client{Timeout: skillDownloadTimeout} - resp, err := client.Do(req) + resp, err := skillHTTPDo(client, req) if err != nil { return "", apperrors.NewAPI(fmt.Sprintf("failed to download skill package: %v", err), apperrors.WithRetryable(true)) } @@ -532,22 +554,22 @@ func downloadSkillToTmpDir(ctx context.Context, apiURL, accessToken string) (str return "", parseLegacySkillAPIError(resp) } - tmpDir, err := os.MkdirTemp("", "dws-skill-*") + tmpDir, err := skillMkdirTemp("", "dws-skill-*") if err != nil { return "", apperrors.NewInternal(fmt.Sprintf("failed to create temp dir: %v", err)) } filename := filenameFromDisposition(resp.Header.Get("Content-Disposition")) destPath := filepath.Join(tmpDir, filename) - file, err := os.Create(destPath) + file, err := skillCreate(destPath) if err != nil { - os.RemoveAll(tmpDir) + _ = skillRemoveAll(tmpDir) return "", apperrors.NewInternal(fmt.Sprintf("failed to create temp file: %v", err)) } defer file.Close() - if _, err := io.Copy(file, resp.Body); err != nil { - os.RemoveAll(tmpDir) + if _, err := skillCopy(file, resp.Body); err != nil { + _ = skillRemoveAll(tmpDir) return "", apperrors.NewAPI(fmt.Sprintf("failed to save downloaded file: %v", err)) } return tmpDir, nil @@ -586,7 +608,7 @@ func downloadSkillFile(ctx context.Context, downloadURL, fileName string) (strin } client := &http.Client{Timeout: skillDownloadTimeout} - resp, err := client.Do(req) + resp, err := skillHTTPDo(client, req) if err != nil { return "", apperrors.NewAPI(fmt.Sprintf("failed to download skill: %v", err), apperrors.WithRetryable(true)) @@ -602,21 +624,21 @@ func downloadSkillFile(ctx context.Context, downloadURL, fileName string) (strin if fileName == "" { fileName = "skill.zip" } - tempFile, err := os.CreateTemp("", "dws-skill-*.zip") + tempFile, err := skillCreateTemp("", "dws-skill-*.zip") if err != nil { return "", apperrors.NewInternal(fmt.Sprintf("failed to create temp file: %v", err)) } tempPath := tempFile.Name() // Copy response body to temp file - _, err = io.Copy(tempFile, resp.Body) + _, err = skillCopy(tempFile, resp.Body) closeErr := tempFile.Close() if err != nil { - os.Remove(tempPath) + _ = skillRemove(tempPath) return "", apperrors.NewAPI(fmt.Sprintf("failed to save downloaded file: %v", err)) } if closeErr != nil { - os.Remove(tempPath) + _ = skillRemove(tempPath) return "", apperrors.NewInternal(fmt.Sprintf("failed to close temp file: %v", closeErr)) } @@ -626,7 +648,7 @@ func downloadSkillFile(ctx context.Context, downloadURL, fileName string) (strin // extractSkillZip extracts a zip file to the destination directory. func extractSkillZip(zipPath, destDir string) error { // Ensure destination directory exists - if err := os.MkdirAll(destDir, 0755); err != nil { + if err := skillMkdirAll(destDir, 0755); err != nil { return apperrors.NewInternal(fmt.Sprintf("failed to create destination directory: %v", err)) } @@ -655,16 +677,16 @@ func extractZipFile(file *zip.File, destDir string) error { if file.FileInfo().IsDir() { // Use 0755 to ensure we have write permission for creating files inside - return os.MkdirAll(filePath, 0755) + return skillMkdirAll(filePath, 0755) } // Ensure parent directory exists with write permission - if err := os.MkdirAll(filepath.Dir(filePath), 0755); err != nil { + if err := skillMkdirAll(filepath.Dir(filePath), 0755); err != nil { return apperrors.NewInternal(fmt.Sprintf("failed to create directory: %v", err)) } // Extract file - srcFile, err := file.Open() + srcFile, err := skillOpenZipFile(file) if err != nil { return apperrors.NewInternal(fmt.Sprintf("failed to open file in zip: %v", err)) } @@ -675,13 +697,13 @@ func extractZipFile(file *zip.File, destDir string) error { if fileMode&0600 == 0 { fileMode = 0644 } - destFile, err := os.OpenFile(filePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fileMode) + destFile, err := skillOpenFile(filePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fileMode) if err != nil { return apperrors.NewInternal(fmt.Sprintf("failed to create file: %v", err)) } defer destFile.Close() - if _, err := io.Copy(destFile, srcFile); err != nil { + if _, err := skillCopy(destFile, srcFile); err != nil { return apperrors.NewInternal(fmt.Sprintf("failed to extract file: %v", err)) } @@ -691,6 +713,6 @@ func extractZipFile(file *zip.File, destDir string) error { // cleanupTempFile removes a temporary file, ignoring errors. func cleanupTempFile(path string) { if path != "" { - os.Remove(path) + _ = skillRemove(path) } } diff --git a/internal/app/skill_command_full_coverage_test.go b/internal/app/skill_command_full_coverage_test.go new file mode 100644 index 00000000..237e39fc --- /dev/null +++ b/internal/app/skill_command_full_coverage_test.go @@ -0,0 +1,326 @@ +package app + +import ( + "archive/zip" + "bytes" + "context" + "errors" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth" + "github.com/spf13/cobra" +) + +type skillErrorReadCloser struct{ err error } + +func (r skillErrorReadCloser) Read([]byte) (int, error) { return 0, r.err } +func (skillErrorReadCloser) Close() error { return nil } + +func skillCoverageResponse(status int, body string) *http.Response { + return &http.Response{StatusCode: status, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)} +} + +func skillCoverageCommand() *cobra.Command { + cmd := &cobra.Command{} + cmd.SetContext(context.Background()) + cmd.SetOut(io.Discard) + cmd.Flags().String("skill-id", "id", "") + cmd.Flags().String("query", "query", "") + cmd.Flags().String("source", "", "") + cmd.Flags().String("scopes", "scope", "") + return cmd +} + +func TestSkillCommandHighLevelRemainingCoverage(t *testing.T) { + oldToken := skillLoadAccessToken + oldTmp := skillDownloadToTmp + oldHTTP := skillHTTPDo + oldNewRequest := skillNewRequest + oldTarget := skillResolveTargetPath + oldFetch := skillFetchDownloadInfo + oldDownload := skillDownloadFile + oldExtract := skillExtractZip + t.Cleanup(func() { + skillLoadAccessToken = oldToken + skillDownloadToTmp = oldTmp + skillHTTPDo = oldHTTP + skillNewRequest = oldNewRequest + skillResolveTargetPath = oldTarget + skillFetchDownloadInfo = oldFetch + skillDownloadFile = oldDownload + skillExtractZip = oldExtract + }) + fail := errors.New("failure") + cmd := skillCoverageCommand() + skillLoadAccessToken = func() (string, error) { return "", fail } + if err := runSkillGet(cmd, nil); !errors.Is(err, fail) { + t.Fatalf("skill get auth error = %v", err) + } + skillLoadAccessToken = func() (string, error) { return "token", nil } + skillNewRequest = func(context.Context, string, string, io.Reader) (*http.Request, error) { return nil, fail } + if err := runSkillFind(cmd, nil); err == nil { + t.Fatal("skill find request failure should propagate") + } + skillNewRequest = oldNewRequest + skillDownloadToTmp = func(context.Context, string, string) (string, error) { return "", fail } + if err := runSkillGet(cmd, nil); !errors.Is(err, fail) { + t.Fatalf("skill get download error = %v", err) + } + + skillLoadAccessToken = func() (string, error) { return "", fail } + if err := runSkillFind(cmd, nil); !errors.Is(err, fail) { + t.Fatalf("skill find auth error = %v", err) + } + skillLoadAccessToken = func() (string, error) { return "token", nil } + skillHTTPDo = func(*http.Client, *http.Request) (*http.Response, error) { return nil, fail } + if err := runSkillFind(cmd, nil); err == nil { + t.Fatal("skill find network failure should propagate") + } + skillHTTPDo = func(*http.Client, *http.Request) (*http.Response, error) { + return skillCoverageResponse(http.StatusBadRequest, "bad"), nil + } + if err := runSkillFind(cmd, nil); err == nil { + t.Fatal("skill find HTTP failure should propagate") + } + skillHTTPDo = func(*http.Client, *http.Request) (*http.Response, error) { + return skillCoverageResponse(http.StatusOK, "{"), nil + } + if err := runSkillFind(cmd, nil); err == nil { + t.Fatal("skill find malformed response should fail") + } + for _, body := range []string{ + `{"success":false,"errorMsg":"message"}`, + `{"success":false,"errorCode":"code"}`, + `{"success":false}`, + } { + skillHTTPDo = func(*http.Client, *http.Request) (*http.Response, error) { + return skillCoverageResponse(http.StatusOK, body), nil + } + if err := runSkillFind(cmd, nil); err == nil { + t.Fatalf("skill find API failure %s should propagate", body) + } + } + + skillResolveTargetPath = func(string) (string, error) { return "", fail } + if err := runSkillAdd(cmd, []string{"id", "target"}); err == nil { + t.Fatal("invalid skill target should fail") + } + skillResolveTargetPath = func(string) (string, error) { return "dest", nil } + skillLoadAccessToken = func() (string, error) { return "", fail } + if err := runSkillAdd(cmd, []string{"id", "target"}); !errors.Is(err, fail) { + t.Fatalf("skill add auth error = %v", err) + } + skillLoadAccessToken = func() (string, error) { return "token", nil } + skillFetchDownloadInfo = func(context.Context, string, string) (*downloadSkillResponse, error) { return nil, fail } + if err := runSkillAdd(cmd, []string{"id", "target"}); !errors.Is(err, fail) { + t.Fatalf("skill info error = %v", err) + } + for _, response := range []*downloadSkillResponse{ + {Success: false, ErrorMsg: "message"}, + {Success: false, ErrorCode: "code"}, + {Success: false}, + {Success: true}, + {Success: true, Result: &downloadSkillResult{}}, + } { + skillFetchDownloadInfo = func(context.Context, string, string) (*downloadSkillResponse, error) { return response, nil } + if err := runSkillAdd(cmd, []string{"id", "target"}); err == nil { + t.Fatalf("invalid download response %#v should fail", response) + } + } + skillFetchDownloadInfo = func(context.Context, string, string) (*downloadSkillResponse, error) { + return &downloadSkillResponse{Success: true, Result: &downloadSkillResult{DownloadURL: "url", FileName: "skill.zip"}}, nil + } + skillDownloadFile = func(context.Context, string, string) (string, error) { return "", fail } + if err := runSkillAdd(cmd, []string{"id", "target"}); !errors.Is(err, fail) { + t.Fatalf("skill file download error = %v", err) + } + skillDownloadFile = func(context.Context, string, string) (string, error) { return "temp.zip", nil } + skillExtractZip = func(string, string) error { return fail } + if err := runSkillAdd(cmd, []string{"id", "target"}); !errors.Is(err, fail) { + t.Fatalf("skill extraction error = %v", err) + } + skillExtractZip = func(string, string) error { return nil } + if err := runSkillAdd(cmd, []string{"id", "target"}); err != nil { + t.Fatal(err) + } +} + +func TestSkillCommandLowLevelRemainingCoverage(t *testing.T) { + oldHTTP := skillHTTPDo + oldNewRequest, oldLoadToken := skillNewRequest, skillLoadTokenData + oldHome := skillUserHomeDir + oldMkdirTemp, oldCreate, oldCreateTemp := skillMkdirTemp, skillCreate, skillCreateTemp + oldRemoveAll, oldRemove, oldMkdir := skillRemoveAll, skillRemove, skillMkdirAll + oldOpen, oldCopy, oldZipOpen := skillOpenFile, skillCopy, skillOpenZipFile + t.Cleanup(func() { + skillHTTPDo = oldHTTP + skillNewRequest, skillLoadTokenData = oldNewRequest, oldLoadToken + skillUserHomeDir = oldHome + skillMkdirTemp, skillCreate, skillCreateTemp = oldMkdirTemp, oldCreate, oldCreateTemp + skillRemoveAll, skillRemove, skillMkdirAll = oldRemoveAll, oldRemove, oldMkdir + skillOpenFile, skillCopy, skillOpenZipFile = oldOpen, oldCopy, oldZipOpen + }) + fail := errors.New("failure") + skillLoadTokenData = func(string) (*authpkg.TokenData, error) { return nil, nil } + if _, err := loadSkillAccessToken(); err == nil { + t.Fatal("invalid skill access token succeeded") + } + skillLoadTokenData = oldLoadToken + skillNewRequest = func(context.Context, string, string, io.Reader) (*http.Request, error) { return nil, fail } + if _, err := fetchSkillDownloadInfo(context.Background(), "token", "id"); err == nil { + t.Fatal("download-info request failure should propagate") + } + if _, err := downloadSkillFile(context.Background(), "https://skill.test", "token"); err == nil { + t.Fatal("skill-file request failure should propagate") + } + skillNewRequest = oldNewRequest + skillUserHomeDir = func() (string, error) { return "", fail } + if _, err := resolveSkillTargetPath("codex"); err == nil { + t.Fatal("skill target HOME error should fail") + } + t.Setenv("DWS_SKILL_API_HOST", "https://skill.test/") + if skillAPIHost() != "https://skill.test" { + t.Fatal("skill API override was not normalized") + } + + skillHTTPDo = func(*http.Client, *http.Request) (*http.Response, error) { return nil, fail } + if _, err := fetchSkillDownloadInfo(context.Background(), "token", "id"); err == nil { + t.Fatal("download-info network failure should propagate") + } + for _, status := range []int{http.StatusUnauthorized, http.StatusBadGateway} { + skillHTTPDo = func(*http.Client, *http.Request) (*http.Response, error) { + return skillCoverageResponse(status, "body"), nil + } + if _, err := fetchSkillDownloadInfo(context.Background(), "token", "id"); err == nil { + t.Fatalf("download-info HTTP %d should fail", status) + } + } + skillHTTPDo = func(*http.Client, *http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK, Body: skillErrorReadCloser{err: fail}}, nil + } + if _, err := fetchSkillDownloadInfo(context.Background(), "token", "id"); err == nil { + t.Fatal("download-info body failure should propagate") + } + skillHTTPDo = func(*http.Client, *http.Request) (*http.Response, error) { + return skillCoverageResponse(http.StatusOK, "{"), nil + } + if _, err := fetchSkillDownloadInfo(context.Background(), "token", "id"); err == nil { + t.Fatal("malformed download-info should fail") + } + + skillHTTPDo = func(*http.Client, *http.Request) (*http.Response, error) { + return skillCoverageResponse(http.StatusOK, "body"), nil + } + skillHTTPDo = func(*http.Client, *http.Request) (*http.Response, error) { return nil, fail } + if _, err := downloadSkillToTmpDir(context.Background(), "https://skill.test", "token"); err == nil { + t.Fatal("temporary skill download network failure should propagate") + } + skillHTTPDo = func(*http.Client, *http.Request) (*http.Response, error) { + return skillCoverageResponse(http.StatusOK, "body"), nil + } + skillMkdirTemp = func(string, string) (string, error) { return "", fail } + if _, err := downloadSkillToTmpDir(context.Background(), "https://skill.test", "token"); err == nil { + t.Fatal("download temp-dir failure should propagate") + } + tmpDir := t.TempDir() + skillMkdirTemp = func(string, string) (string, error) { return tmpDir, nil } + skillCreate = func(string) (*os.File, error) { return nil, fail } + if _, err := downloadSkillToTmpDir(context.Background(), "https://skill.test", "token"); err == nil { + t.Fatal("download temp-file failure should propagate") + } + skillCreate = func(string) (*os.File, error) { return os.CreateTemp(t.TempDir(), "skill") } + skillCopy = func(io.Writer, io.Reader) (int64, error) { return 0, fail } + if _, err := downloadSkillToTmpDir(context.Background(), "https://skill.test", "token"); err == nil { + t.Fatal("download save failure should propagate") + } + + skillHTTPDo = func(*http.Client, *http.Request) (*http.Response, error) { return nil, fail } + if _, err := downloadSkillFile(context.Background(), "https://skill.test", "x"); err == nil { + t.Fatal("skill-file network failure should propagate") + } + skillHTTPDo = func(*http.Client, *http.Request) (*http.Response, error) { + return skillCoverageResponse(http.StatusBadGateway, "body"), nil + } + if _, err := downloadSkillFile(context.Background(), "https://skill.test", "x"); err == nil { + t.Fatal("skill-file HTTP failure should propagate") + } + skillHTTPDo = func(*http.Client, *http.Request) (*http.Response, error) { + return skillCoverageResponse(http.StatusOK, "body"), nil + } + skillCreateTemp = func(string, string) (*os.File, error) { return nil, fail } + if _, err := downloadSkillFile(context.Background(), "https://skill.test", "x"); err == nil { + t.Fatal("skill-file temp failure should propagate") + } + skillCreateTemp = func(string, string) (*os.File, error) { return os.CreateTemp(t.TempDir(), "skill") } + skillCopy = func(io.Writer, io.Reader) (int64, error) { return 0, fail } + if _, err := downloadSkillFile(context.Background(), "https://skill.test", "x"); err == nil { + t.Fatal("skill-file copy failure should propagate") + } + skillCopy = func(w io.Writer, _ io.Reader) (int64, error) { + _ = w.(*os.File).Close() + return 0, nil + } + if _, err := downloadSkillFile(context.Background(), "https://skill.test", "x"); err == nil { + t.Fatal("skill-file close failure should propagate") + } + + skillMkdirAll = func(string, os.FileMode) error { return fail } + if err := extractSkillZip("missing", "dest"); err == nil { + t.Fatal("zip destination mkdir failure should propagate") + } + zipPath := filepath.Join(t.TempDir(), "files.zip") + buf := &bytes.Buffer{} + zw := zip.NewWriter(buf) + _, _ = zw.Create("dir/") + w, _ := zw.Create("file") + _, _ = w.Write([]byte("content")) + header := &zip.FileHeader{Name: "mode-file", Method: zip.Store} + header.SetMode(0o111) + w, _ = zw.CreateHeader(header) + _, _ = w.Write([]byte("mode")) + _ = zw.Close() + if err := os.WriteFile(zipPath, buf.Bytes(), 0o600); err != nil { + t.Fatal(err) + } + zr, err := zip.OpenReader(zipPath) + if err != nil { + t.Fatal(err) + } + defer zr.Close() + skillMkdirAll = func(string, os.FileMode) error { return fail } + if err := extractZipFile(zr.File[0], t.TempDir()); err == nil { + t.Fatal("zip directory mkdir failure should propagate") + } + if err := extractZipFile(zr.File[1], t.TempDir()); err == nil { + t.Fatal("zip parent mkdir failure should propagate") + } + skillMkdirAll = func(string, os.FileMode) error { return nil } + skillOpenZipFile = func(*zip.File) (io.ReadCloser, error) { return nil, fail } + if err := extractZipFile(zr.File[1], t.TempDir()); err == nil { + t.Fatal("zip source-open failure should propagate") + } + skillOpenZipFile = oldZipOpen + skillOpenFile = func(string, int, os.FileMode) (*os.File, error) { return nil, fail } + if err := extractZipFile(zr.File[1], t.TempDir()); err == nil { + t.Fatal("zip destination-open failure should propagate") + } + skillOpenFile = func(string, int, os.FileMode) (*os.File, error) { return os.CreateTemp(t.TempDir(), "out") } + skillCopy = func(io.Writer, io.Reader) (int64, error) { return 0, fail } + if err := extractZipFile(zr.File[1], t.TempDir()); err == nil { + t.Fatal("zip copy failure should propagate") + } + skillCopy = func(io.Writer, io.Reader) (int64, error) { return 0, nil } + var usedMode os.FileMode + skillOpenFile = func(_ string, _ int, mode os.FileMode) (*os.File, error) { + usedMode = mode + return os.CreateTemp(t.TempDir(), "mode") + } + if err := extractZipFile(zr.File[2], t.TempDir()); err != nil || usedMode != 0o644 { + t.Fatalf("zip fallback mode = %v, %v", usedMode, err) + } +} diff --git a/internal/app/skill_setup.go b/internal/app/skill_setup.go index 9ef86dd3..effa84e6 100644 --- a/internal/app/skill_setup.go +++ b/internal/app/skill_setup.go @@ -41,6 +41,33 @@ const ( skillSetupModeMulti = "multi" ) +var ( + skillSetupResolveMode = resolveSkillSetupMode + skillSetupResolveSource = resolveSkillSetupSourceOrEmbedded + skillSetupResolveTargets = resolveSkillSetupTargets + skillSetupListMulti = listMultiSkillNames + skillSetupFilterMulti = filterMultiSkillNames + skillSetupConfirm = confirmSkillSetup + skillSetupInstallMono = installSkillToHomes + skillSetupInstallMulti = installMultiSkillToHomes + skillSetupCopyDir = copyDir + skillSetupRunForm = (*huh.Form).Run + skillSetupInteractive = isInteractiveTerminal + skillSetupReadDir = os.ReadDir + skillSetupStat = os.Stat + skillSetupExecutable = os.Executable + skillSetupGetwd = os.Getwd + skillSetupUserHomeDir = os.UserHomeDir + skillSetupRemoveAll = os.RemoveAll + skillSetupMkdirAll = os.MkdirAll + skillSetupWalk = filepath.Walk + skillSetupRel = filepath.Rel + skillSetupReadlink = os.Readlink + skillSetupOpen = os.Open + skillSetupOpenFile = os.OpenFile + skillSetupCopy = io.Copy +) + func newSkillSetupCommand() *cobra.Command { cmd := &cobra.Command{ Use: "setup", @@ -89,7 +116,7 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error { out := cmd.OutOrStdout() errOut := cmd.ErrOrStderr() - mode, err := resolveSkillSetupMode(mode, autoYes, out) + mode, err := skillSetupResolveMode(mode, autoYes, out) if err != nil { return err } @@ -98,13 +125,13 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error { return fmt.Errorf("--skill / --exclude 仅在 --mode multi 下有效(mono 只有一个 skill,无需挑选)") } - skillSrc, srcCleanup, err := resolveSkillSetupSourceOrEmbedded(source, mode) + skillSrc, srcCleanup, err := skillSetupResolveSource(source, mode) if err != nil { return err } defer srcCleanup() - dests, err := resolveSkillSetupTargets(target, mode) + dests, err := skillSetupResolveTargets(target, mode) if err != nil { return err } @@ -112,14 +139,14 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error { // multi 模式枚举 src 下的子 skill 名,供确认信息与安装步骤共用 var multiSkillNames []string if mode == skillSetupModeMulti { - allMultiSkillNames, listErr := listMultiSkillNames(skillSrc) + allMultiSkillNames, listErr := skillSetupListMulti(skillSrc) if listErr != nil { return listErr } if len(allMultiSkillNames) == 0 { return fmt.Errorf("multi 模式下 %s 内未发现含 SKILL.md 的子目录", skillSrc) } - filtered, filterErr := filterMultiSkillNames(allMultiSkillNames, includeRaw, excludeRaw) + filtered, filterErr := skillSetupFilterMulti(allMultiSkillNames, includeRaw, excludeRaw) if filterErr != nil { return filterErr } @@ -142,7 +169,7 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error { } if !autoYes { - ok, err := confirmSkillSetup(out, mode, skillSrc, dests, multiSkillNames) + ok, err := skillSetupConfirm(out, mode, skillSrc, dests, multiSkillNames) if err != nil { return err } @@ -157,9 +184,9 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error { var installed, skipped int switch mode { case skillSetupModeMono: - installed, skipped, err = installSkillToHomes(skillSrc, dests, out, errOut) + installed, skipped, err = skillSetupInstallMono(skillSrc, dests, out, errOut) case skillSetupModeMulti: - installed, skipped, err = installMultiSkillToHomes(skillSrc, multiSkillNames, dests, out, errOut) + installed, skipped, err = skillSetupInstallMulti(skillSrc, multiSkillNames, dests, out, errOut) default: return fmt.Errorf("内部错误:未知 mode %q", mode) } @@ -297,7 +324,7 @@ func filterMultiSkillNames(all, include, exclude []string) ([]string, error) { // listMultiSkillNames returns sorted names of subdirectories under src that // contain a SKILL.md file (i.e. valid multi-mode skill bundles). func listMultiSkillNames(src string) ([]string, error) { - entries, err := os.ReadDir(src) + entries, err := skillSetupReadDir(src) if err != nil { return nil, fmt.Errorf("无法读取 multi skill 源目录 %s: %w", src, err) } @@ -306,7 +333,7 @@ func listMultiSkillNames(src string) ([]string, error) { if !e.IsDir() { continue } - if _, err := os.Stat(filepath.Join(src, e.Name(), "SKILL.md")); err == nil { + if _, err := skillSetupStat(filepath.Join(src, e.Name(), "SKILL.md")); err == nil { names = append(names, e.Name()) } } @@ -328,7 +355,7 @@ func resolveSkillSetupMode(mode string, autoYes bool, out io.Writer) (string, er return "", fmt.Errorf("不支持的 --mode 值: %s(可选 mono / multi)", mode) } - if autoYes || !isInteractiveTerminal() { + if autoYes || !skillSetupInteractive() { fmt.Fprintln(out, "未指定 --mode,非交互环境下默认使用 mono") return skillSetupModeMono, nil } @@ -346,7 +373,7 @@ func resolveSkillSetupMode(mode string, autoYes bool, out io.Writer) (string, er Value(&choice), ), ) - if err := form.Run(); err != nil { + if err := skillSetupRunForm(form); err != nil { return "", fmt.Errorf("交互式选择中止: %w", err) } return choice, nil @@ -405,7 +432,7 @@ func skillSourceCandidates(explicit, subdir string) []string { if env := strings.TrimSpace(os.Getenv("DWS_SKILL_SOURCE")); env != "" { roots = append(roots, env, filepath.Join(env, "skills", subdir)) } - if exe, err := os.Executable(); err == nil { + if exe, err := skillSetupExecutable(); err == nil { exeDir := filepath.Dir(exe) roots = append(roots, filepath.Join(exeDir, "skills", subdir), @@ -413,13 +440,13 @@ func skillSourceCandidates(explicit, subdir string) []string { filepath.Join(exeDir, "..", "share", "skills", "dws"), ) } - if wd, err := os.Getwd(); err == nil { + if wd, err := skillSetupGetwd(); err == nil { roots = append(roots, filepath.Join(wd, "skills", subdir)) } // User-level cache populated by install.sh / install.ps1 / npm install.js // from the dws-skills.zip release asset. Lets `dws skill setup` find a // source even when the user has no source checkout on disk. - if home, err := os.UserHomeDir(); err == nil { + if home, err := skillSetupUserHomeDir(); err == nil { roots = append(roots, filepath.Join(home, ".dws", "skills", subdir)) } return roots @@ -431,16 +458,16 @@ func isSkillSourceRoot(path, mode string) bool { } switch mode { case skillSetupModeMono: - fi, err := os.Stat(filepath.Join(path, "SKILL.md")) + fi, err := skillSetupStat(filepath.Join(path, "SKILL.md")) return err == nil && !fi.IsDir() case skillSetupModeMulti: - entries, err := os.ReadDir(path) + entries, err := skillSetupReadDir(path) if err != nil { return false } for _, e := range entries { if e.IsDir() { - if _, err := os.Stat(filepath.Join(path, e.Name(), "SKILL.md")); err == nil { + if _, err := skillSetupStat(filepath.Join(path, e.Name(), "SKILL.md")); err == nil { return true } } @@ -458,7 +485,7 @@ func isSkillSourceRoot(path, mode string) bool { // - mono → /dws (单 skill,整个 src 拷成一个 dws 目录) // - multi → (安装时把 src 下每个子目录拷成兄弟 skill) func resolveSkillSetupTargets(target, mode string) ([]string, error) { - home, err := os.UserHomeDir() + home, err := skillSetupUserHomeDir() if err != nil { return nil, fmt.Errorf("无法解析用户 HOME: %w", err) } @@ -489,15 +516,12 @@ func detectExistingAgentHomes(home, mode string) []string { base := filepath.Join(home, rel) parent := filepath.Dir(base) if i > 0 { - if _, err := os.Stat(parent); errors.Is(err, os.ErrNotExist) { + if _, err := skillSetupStat(parent); errors.Is(err, os.ErrNotExist) { continue } } out = append(out, agentHomeForMode(base, mode)) } - if len(out) == 0 { - out = append(out, agentHomeForMode(filepath.Join(home, ".agents", "skills"), mode)) - } return out } @@ -530,7 +554,7 @@ func confirmSkillSetup(out io.Writer, mode, src string, dests []string, multiSki } } - if !isInteractiveTerminal() { + if !skillSetupInteractive() { return true, nil } @@ -544,7 +568,7 @@ func confirmSkillSetup(out io.Writer, mode, src string, dests []string, multiSki Value(&confirm), ), ) - if err := form.Run(); err != nil { + if err := skillSetupRunForm(form); err != nil { return false, fmt.Errorf("确认中止: %w", err) } return confirm, nil @@ -561,7 +585,7 @@ func mutualExclusionVictims(dest, mode string) []string { case skillSetupModeMono: // dest = /dws → agent-home = parent agentHome := filepath.Dir(dest) - entries, err := os.ReadDir(agentHome) + entries, err := skillSetupReadDir(agentHome) if err != nil { return nil } @@ -576,7 +600,7 @@ func mutualExclusionVictims(dest, mode string) []string { case skillSetupModeMulti: // dest = → mono 残留是 dest/dws monoPath := filepath.Join(dest, "dws") - if _, err := os.Stat(monoPath); err == nil { + if _, err := skillSetupStat(monoPath); err == nil { return []string{monoPath} } return nil @@ -588,7 +612,7 @@ func mutualExclusionVictims(dest, mode string) []string { // Failures emit a warning to errOut but never abort the install. func cleanupMutualExclusion(dest, mode string, out, errOut io.Writer) { for _, victim := range mutualExclusionVictims(dest, mode) { - if err := os.RemoveAll(victim); err != nil { + if err := skillSetupRemoveAll(victim); err != nil { fmt.Fprintf(errOut, " ⚠️ 互斥清理失败(继续安装) %s: %v\n", victim, err) continue } @@ -602,17 +626,17 @@ func installSkillToHomes(src string, dests []string, out, errOut io.Writer) (ins // 先做互斥清理:装 mono 前先把同级 dingtalk-* 子目录全部干掉 cleanupMutualExclusion(dest, skillSetupModeMono, out, errOut) - if err := os.RemoveAll(dest); err != nil { + if err := skillSetupRemoveAll(dest); err != nil { fmt.Fprintf(errOut, " ✗ 清理失败 %s: %v\n", dest, err) skipped++ continue } - if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { + if err := skillSetupMkdirAll(filepath.Dir(dest), 0o755); err != nil { fmt.Fprintf(errOut, " ✗ 父目录创建失败 %s: %v\n", dest, err) skipped++ continue } - if err := copyDir(src, dest); err != nil { + if err := skillSetupCopyDir(src, dest); err != nil { fmt.Fprintf(errOut, " ✗ 拷贝失败 %s: %v\n", dest, err) skipped++ continue @@ -632,7 +656,7 @@ func installMultiSkillToHomes(src string, skillNames []string, dests []string, o // 互斥清理:装 multi 前先把 dest/dws/ 整个删除(mono 残留) cleanupMutualExclusion(dest, skillSetupModeMulti, out, errOut) - if err := os.MkdirAll(dest, 0o755); err != nil { + if err := skillSetupMkdirAll(dest, 0o755); err != nil { fmt.Fprintf(errOut, " ✗ Agent 目录创建失败 %s: %v\n", dest, err) skipped += len(skillNames) continue @@ -641,12 +665,12 @@ func installMultiSkillToHomes(src string, skillNames []string, dests []string, o for _, name := range skillNames { subSrc := filepath.Join(src, name) subDest := filepath.Join(dest, name) - if err := os.RemoveAll(subDest); err != nil { + if err := skillSetupRemoveAll(subDest); err != nil { fmt.Fprintf(errOut, " ✗ 清理失败 %s: %v\n", subDest, err) skipped++ continue } - if err := copyDir(subSrc, subDest); err != nil { + if err := skillSetupCopyDir(subSrc, subDest); err != nil { fmt.Fprintf(errOut, " ✗ 拷贝失败 %s: %v\n", subDest, err) skipped++ continue @@ -659,22 +683,22 @@ func installMultiSkillToHomes(src string, skillNames []string, dests []string, o } func copyDir(src, dst string) error { - return filepath.Walk(src, func(path string, info os.FileInfo, walkErr error) error { + return skillSetupWalk(src, func(path string, info os.FileInfo, walkErr error) error { if walkErr != nil { return walkErr } - rel, err := filepath.Rel(src, path) + rel, err := skillSetupRel(src, path) if err != nil { return err } target := filepath.Join(dst, rel) if info.IsDir() { - return os.MkdirAll(target, info.Mode()) + return skillSetupMkdirAll(target, info.Mode()) } if info.Mode()&os.ModeSymlink != 0 { // resolve symlink target and copy the underlying file - resolved, err := os.Readlink(path) + resolved, err := skillSetupReadlink(path) if err != nil { return err } @@ -688,22 +712,22 @@ func copyDir(src, dst string) error { } func copyFileContent(src, dst string, mode os.FileMode) error { - if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + if err := skillSetupMkdirAll(filepath.Dir(dst), 0o755); err != nil { return err } - in, err := os.Open(src) + in, err := skillSetupOpen(src) if err != nil { return err } defer in.Close() - out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode&os.ModePerm) + out, err := skillSetupOpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode&os.ModePerm) if err != nil { return err } defer out.Close() - _, err = io.Copy(out, in) + _, err = skillSetupCopy(out, in) return err } diff --git a/internal/app/skill_setup_embed.go b/internal/app/skill_setup_embed.go index 635c4b6b..29fc7d63 100644 --- a/internal/app/skill_setup_embed.go +++ b/internal/app/skill_setup_embed.go @@ -23,6 +23,20 @@ import ( dwsroot "github.com/DingTalk-Real-AI/dingtalk-workspace-cli" ) +var ( + embeddedSkillStat = func(name string) (fs.FileInfo, error) { + return fs.Stat(dwsroot.EmbeddedSkills, name) + } + embeddedSkillMkdirTemp = os.MkdirTemp + embeddedSkillRemoveAll = os.RemoveAll + embeddedSkillWalkDir = func(root string, fn fs.WalkDirFunc) error { + return fs.WalkDir(dwsroot.EmbeddedSkills, root, fn) + } + embeddedSkillReadFile = dwsroot.EmbeddedSkills.ReadFile + embeddedSkillMkdirAll = os.MkdirAll + embeddedSkillWriteFile = os.WriteFile +) + // resolveSkillSetupSourceOrEmbedded resolves the skill source for `skill // setup`. An explicit --source or DWS_SKILL_SOURCE is honored as a developer // override (validated as an on-disk dir). Otherwise it falls back to the skill @@ -50,33 +64,33 @@ func resolveSkillSetupSourceOrEmbedded(explicit, mode string) (string, func(), e func materializeEmbeddedSkillSource(mode string) (string, func(), error) { noop := func() {} sub := "skills/" + mode // embed.FS always uses forward slashes - if _, err := fs.Stat(dwsroot.EmbeddedSkills, sub); err != nil { + if _, err := embeddedSkillStat(sub); err != nil { return "", noop, fmt.Errorf("内嵌 skill 不含 %q(二进制可能未随 skills/ 重新构建): %w", sub, err) } - tmp, err := os.MkdirTemp("", "dws-skill-"+mode+"-") + tmp, err := embeddedSkillMkdirTemp("", "dws-skill-"+mode+"-") if err != nil { return "", noop, fmt.Errorf("创建临时 skill 目录失败: %w", err) } - cleanup := func() { _ = os.RemoveAll(tmp) } + cleanup := func() { _ = embeddedSkillRemoveAll(tmp) } - walkErr := fs.WalkDir(dwsroot.EmbeddedSkills, sub, func(p string, d fs.DirEntry, err error) error { + walkErr := embeddedSkillWalkDir(sub, func(p string, d fs.DirEntry, err error) error { if err != nil { return err } rel := strings.TrimPrefix(strings.TrimPrefix(p, sub), "/") dst := filepath.Join(tmp, filepath.FromSlash(rel)) if d.IsDir() { - return os.MkdirAll(dst, 0o755) + return embeddedSkillMkdirAll(dst, 0o755) } - data, readErr := dwsroot.EmbeddedSkills.ReadFile(p) + data, readErr := embeddedSkillReadFile(p) if readErr != nil { return readErr } - if mkErr := os.MkdirAll(filepath.Dir(dst), 0o755); mkErr != nil { + if mkErr := embeddedSkillMkdirAll(filepath.Dir(dst), 0o755); mkErr != nil { return mkErr } - return os.WriteFile(dst, data, 0o644) + return embeddedSkillWriteFile(dst, data, 0o644) }) if walkErr != nil { cleanup() diff --git a/internal/app/skill_setup_full_coverage_test.go b/internal/app/skill_setup_full_coverage_test.go new file mode 100644 index 00000000..33e1f4fa --- /dev/null +++ b/internal/app/skill_setup_full_coverage_test.go @@ -0,0 +1,312 @@ +package app + +import ( + "bytes" + "errors" + "io" + "io/fs" + "os" + "path/filepath" + "testing" + "time" + + "github.com/charmbracelet/huh" + "github.com/spf13/cobra" +) + +type skillSetupFileInfo struct { + name string + mode os.FileMode +} + +func (i skillSetupFileInfo) Name() string { return i.name } +func (i skillSetupFileInfo) Size() int64 { return 0 } +func (i skillSetupFileInfo) Mode() os.FileMode { return i.mode } +func (i skillSetupFileInfo) ModTime() time.Time { return time.Time{} } +func (i skillSetupFileInfo) IsDir() bool { return i.mode.IsDir() } +func (i skillSetupFileInfo) Sys() any { return nil } + +func skillSetupCoverageCommand(t *testing.T, mode string, yes bool) *cobra.Command { + t.Helper() + cmd := newSkillSetupCommand() + root := &cobra.Command{Use: "dws"} + root.PersistentFlags().Bool("dry-run", false, "") + root.AddCommand(cmd) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + _ = cmd.Flags().Set("mode", mode) + _ = cmd.Flags().Set("yes", map[bool]string{true: "true", false: "false"}[yes]) + return cmd +} + +func TestSkillSetupHighLevelRemainingCoverage(t *testing.T) { + oldMode := skillSetupResolveMode + oldSource := skillSetupResolveSource + oldTargets := skillSetupResolveTargets + oldList := skillSetupListMulti + oldFilter := skillSetupFilterMulti + oldConfirm := skillSetupConfirm + oldMono := skillSetupInstallMono + oldMulti := skillSetupInstallMulti + t.Cleanup(func() { + skillSetupResolveMode = oldMode + skillSetupResolveSource = oldSource + skillSetupResolveTargets = oldTargets + skillSetupListMulti = oldList + skillSetupFilterMulti = oldFilter + skillSetupConfirm = oldConfirm + skillSetupInstallMono = oldMono + skillSetupInstallMulti = oldMulti + }) + 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 } + skillSetupFilterMulti = func(all, _, _ []string) ([]string, error) { return all, nil } + + skillSetupListMulti = func(string) ([]string, error) { return nil, fail } + if err := skillSetupCoverageCommand(t, skillSetupModeMulti, true).RunE(skillSetupCoverageCommand(t, skillSetupModeMulti, true), nil); err == nil { + t.Fatal("multi list failure should propagate") + } + skillSetupListMulti = func(string) ([]string, error) { return nil, nil } + cmd := skillSetupCoverageCommand(t, skillSetupModeMulti, true) + if err := cmd.RunE(cmd, nil); err == nil { + t.Fatal("empty multi source should fail") + } + skillSetupListMulti = func(string) ([]string, error) { return []string{"dws-shared", "dingtalk-doc"}, nil } + skillSetupFilterMulti = func([]string, []string, []string) ([]string, error) { return nil, fail } + cmd = skillSetupCoverageCommand(t, skillSetupModeMulti, true) + if err := cmd.RunE(cmd, nil); err == nil { + t.Fatal("multi filter failure should propagate") + } + skillSetupFilterMulti = func(all, _, _ []string) ([]string, error) { return all, nil } + cmd = skillSetupCoverageCommand(t, skillSetupModeMulti, true) + _ = cmd.Root().PersistentFlags().Set("dry-run", "true") + if err := cmd.RunE(cmd, nil); err != nil { + t.Fatal(err) + } + + skillSetupConfirm = func(io.Writer, string, string, []string, []string) (bool, error) { return false, fail } + cmd = skillSetupCoverageCommand(t, skillSetupModeMono, false) + if err := cmd.RunE(cmd, nil); err == nil { + t.Fatal("confirmation failure should propagate") + } + skillSetupConfirm = func(io.Writer, string, string, []string, []string) (bool, error) { return false, nil } + cmd = skillSetupCoverageCommand(t, skillSetupModeMono, false) + if err := cmd.RunE(cmd, nil); err != nil { + t.Fatal(err) + } + + skillSetupResolveMode = func(string, bool, io.Writer) (string, error) { return "unknown", nil } + cmd = skillSetupCoverageCommand(t, skillSetupModeMono, true) + if err := cmd.RunE(cmd, nil); err == nil { + t.Fatal("unknown resolved mode should fail") + } + skillSetupResolveMode = func(mode string, _ bool, _ io.Writer) (string, error) { return mode, nil } + skillSetupInstallMono = func(string, []string, io.Writer, io.Writer) (int, int, error) { return 0, 0, fail } + cmd = skillSetupCoverageCommand(t, skillSetupModeMono, true) + if err := cmd.RunE(cmd, nil); err == nil { + t.Fatal("mono install failure should propagate") + } + skillSetupInstallMono = func(string, []string, io.Writer, io.Writer) (int, int, error) { return 1, 0, nil } + cmd = skillSetupCoverageCommand(t, skillSetupModeMono, true) + if err := cmd.RunE(cmd, nil); err != nil { + t.Fatal(err) + } + skillSetupInstallMulti = func(string, []string, []string, io.Writer, io.Writer) (int, int, error) { return 0, 0, fail } + cmd = skillSetupCoverageCommand(t, skillSetupModeMulti, true) + if err := cmd.RunE(cmd, nil); err == nil { + t.Fatal("multi install failure should propagate") + } +} + +func TestSkillSetupLowLevelRemainingCoverage(t *testing.T) { + oldRunForm, oldInteractive := skillSetupRunForm, skillSetupInteractive + oldReadDir, oldStat := skillSetupReadDir, skillSetupStat + oldExecutable, oldGetwd, oldHome := skillSetupExecutable, skillSetupGetwd, skillSetupUserHomeDir + oldRemove, oldMkdir := skillSetupRemoveAll, skillSetupMkdirAll + oldCopyDir, oldWalk, oldRel := skillSetupCopyDir, skillSetupWalk, skillSetupRel + oldReadlink, oldOpen, oldOpenFile, oldCopy := skillSetupReadlink, skillSetupOpen, skillSetupOpenFile, skillSetupCopy + t.Cleanup(func() { + skillSetupRunForm, skillSetupInteractive = oldRunForm, oldInteractive + skillSetupReadDir, skillSetupStat = oldReadDir, oldStat + skillSetupExecutable, skillSetupGetwd, skillSetupUserHomeDir = oldExecutable, oldGetwd, oldHome + skillSetupRemoveAll, skillSetupMkdirAll = oldRemove, oldMkdir + skillSetupCopyDir, skillSetupWalk, skillSetupRel = oldCopyDir, oldWalk, oldRel + skillSetupReadlink, skillSetupOpen, skillSetupOpenFile, skillSetupCopy = oldReadlink, oldOpen, oldOpenFile, oldCopy + }) + fail := errors.New("failure") + + skillSetupInteractive = func() bool { return true } + skillSetupRunForm = func(*huh.Form) error { return fail } + if _, err := resolveSkillSetupMode("", false, io.Discard); err == nil { + t.Fatal("interactive mode failure should propagate") + } + skillSetupRunForm = func(*huh.Form) error { return nil } + if got, err := resolveSkillSetupMode("", false, io.Discard); err != nil || got != skillSetupModeMono { + t.Fatalf("interactive default choice = %q, %v", got, err) + } + + source := writeMultiSkillSource(t, []string{"dingtalk-doc"}) + if err := os.WriteFile(filepath.Join(source, "README"), []byte("file"), 0o600); err != nil { + t.Fatal(err) + } + if got, err := listMultiSkillNames(source); err != nil || len(got) != 1 { + t.Fatalf("listed multi skills = %#v, %v", got, err) + } + t.Setenv("DWS_SKILL_SOURCE", source) + if got, err := resolveSkillSetupSource("", skillSetupModeMulti); err != nil || got != source { + t.Fatalf("environment skill source = %q, %v", got, err) + } + t.Setenv("DWS_SKILL_SOURCE", "") + legacyRoot := t.TempDir() + legacyMono := filepath.Join(legacyRoot, "skills", skillSetupModeMono) + if err := os.MkdirAll(legacyMono, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(legacyMono, "SKILL.md"), []byte("skill"), 0o600); err != nil { + t.Fatal(err) + } + skillSetupExecutable = func() (string, error) { return filepath.Join(legacyRoot, "dws"), nil } + skillSetupStat = oldStat + if got, err := resolveSkillSetupSource("", skillSetupModeMono); err != nil || got != legacyMono { + t.Fatalf("legacy executable source = %q, %v", got, err) + } + t.Setenv("DWS_SKILL_SOURCE", "env-source") + if got := skillSourceCandidates("", skillSetupModeMono); len(got) < 2 { + t.Fatalf("environment source candidates = %#v", got) + } + t.Setenv("DWS_SKILL_SOURCE", "") + skillSetupExecutable = func() (string, error) { return "", fail } + skillSetupGetwd = func() (string, error) { return "", fail } + skillSetupUserHomeDir = func() (string, error) { return "", fail } + if _, err := resolveSkillSetupSource("", skillSetupModeMono); err == nil { + t.Fatal("missing fallback source should fail") + } + _ = skillSourceCandidates("explicit", skillSetupModeMono) + + skillSetupReadDir = func(string) ([]os.DirEntry, error) { return nil, fail } + if isSkillSourceRoot("missing", skillSetupModeMulti) { + t.Fatal("unreadable multi source accepted") + } + skillSetupUserHomeDir = func() (string, error) { return "", fail } + if _, err := resolveSkillSetupTargets("all", skillSetupModeMono); err == nil { + t.Fatal("HOME failure should propagate") + } + + monoDest := filepath.Join(t.TempDir(), "skills", "dws") + multiRoot := filepath.Dir(monoDest) + if err := os.MkdirAll(filepath.Join(multiRoot, "dingtalk-doc"), 0o755); err != nil { + t.Fatal(err) + } + skillSetupReadDir, skillSetupStat = oldReadDir, oldStat + var out, errOut bytes.Buffer + skillSetupRunForm = func(*huh.Form) error { return fail } + if _, err := confirmSkillSetup(&out, skillSetupModeMulti, "src", []string{monoDest}, []string{"dingtalk-doc"}); err == nil { + t.Fatal("confirmation form failure should propagate") + } + skillSetupRunForm = func(*huh.Form) error { return nil } + if ok, err := confirmSkillSetup(&out, skillSetupModeMono, "src", []string{monoDest}, nil); err != nil || ok { + t.Fatalf("EOF confirmation = %v, %v", ok, err) + } + skillSetupRemoveAll = func(string) error { return fail } + cleanupMutualExclusion(monoDest, skillSetupModeMono, &out, &errOut) + + skillSetupCopyDir = func(string, string) error { return fail } + skillSetupRemoveAll = func(string) error { return fail } + _, skipped, _ := installSkillToHomes("src", []string{"a"}, &out, &errOut) + if skipped != 1 { + t.Fatal("mono remove failure not skipped") + } + skillSetupRemoveAll = func(string) error { return nil } + skillSetupMkdirAll = func(string, os.FileMode) error { return fail } + _, skipped, _ = installSkillToHomes("src", []string{"b"}, &out, &errOut) + if skipped != 1 { + t.Fatal("mono mkdir failure not skipped") + } + skillSetupMkdirAll = func(string, os.FileMode) error { return nil } + _, skipped, _ = installSkillToHomes("src", []string{"c"}, &out, &errOut) + if skipped != 1 { + t.Fatal("mono copy failure not skipped") + } + + skillSetupMkdirAll = func(string, os.FileMode) error { return fail } + _, skipped, _ = installMultiSkillToHomes("src", []string{"one", "two"}, []string{"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) + 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) + if skipped != 1 { + t.Fatal("multi copy failure count mismatch") + } + + skillSetupWalk = func(string, filepath.WalkFunc) error { return fail } + if err := copyDir("src", "dst"); !errors.Is(err, fail) { + t.Fatalf("walk failure = %v", err) + } + skillSetupWalk = func(_ string, fn filepath.WalkFunc) error { + return fn("path", skillSetupFileInfo{name: "path"}, nil) + } + skillSetupRel = func(string, string) (string, error) { return "", fail } + if err := copyDir("src", "dst"); !errors.Is(err, fail) { + t.Fatalf("relative-path failure = %v", err) + } + skillSetupRel = func(string, string) (string, error) { return "file", nil } + skillSetupWalk = func(_ string, fn filepath.WalkFunc) error { + return fn("link", skillSetupFileInfo{name: "link", mode: os.ModeSymlink}, nil) + } + skillSetupReadlink = func(string) (string, error) { return "", fail } + if err := copyDir("src", "dst"); !errors.Is(err, fail) { + t.Fatalf("readlink failure = %v", err) + } + for _, target := range []string{"relative-target", "/absolute-target"} { + skillSetupReadlink = func(string) (string, error) { return target, nil } + _ = copyDir("src", "dst") + } + + skillSetupMkdirAll = func(string, os.FileMode) error { return fail } + if err := copyFileContent("src", "dst", 0o600); !errors.Is(err, fail) { + t.Fatalf("copy mkdir failure = %v", err) + } + skillSetupMkdirAll = func(string, os.FileMode) error { return nil } + skillSetupOpen = func(string) (*os.File, error) { return nil, fail } + if err := copyFileContent("src", "dst", 0o600); !errors.Is(err, fail) { + t.Fatalf("copy open failure = %v", err) + } + in, err := os.CreateTemp(t.TempDir(), "in") + if err != nil { + t.Fatal(err) + } + skillSetupOpen = func(string) (*os.File, error) { return in, nil } + skillSetupOpenFile = func(string, int, os.FileMode) (*os.File, error) { return nil, fail } + if err := copyFileContent("src", "dst", 0o600); !errors.Is(err, fail) { + t.Fatalf("copy output-open failure = %v", err) + } + outFile, err := os.CreateTemp(t.TempDir(), "out") + if err != nil { + t.Fatal(err) + } + skillSetupOpen = func(string) (*os.File, error) { return in, nil } + skillSetupOpenFile = func(string, int, os.FileMode) (*os.File, error) { return outFile, nil } + skillSetupCopy = func(io.Writer, io.Reader) (int64, error) { return 0, fail } + if err := copyFileContent("src", "dst", 0o600); !errors.Is(err, fail) { + t.Fatalf("copy content failure = %v", err) + } + + closed, err := os.CreateTemp(t.TempDir(), "closed") + if err != nil { + t.Fatal(err) + } + _ = closed.Close() + if isCharDevice(closed) { + t.Fatal("closed file is not a character device") + } + _ = fs.ValidPath("path") +} diff --git a/internal/app/stdio_registry.go b/internal/app/stdio_registry.go index 62837489..c83f1cd9 100644 --- a/internal/app/stdio_registry.go +++ b/internal/app/stdio_registry.go @@ -26,6 +26,7 @@ const stdioEndpointScheme = "stdio://" var ( stdioMu sync.RWMutex stdioClients = make(map[string]*transport.StdioClient) + stopStdio = func(client *transport.StdioClient) error { return client.Stop() } ) // RegisterStdioClient stores a StdioClient keyed by its canonical product ID @@ -75,7 +76,7 @@ func StopAllStdioClients() { stdioMu.Lock() defer stdioMu.Unlock() for id, client := range stdioClients { - if err := client.Stop(); err != nil { + if err := stopStdio(client); err != nil { slog.Warn("failed to stop stdio client", "id", id, "error", err) } } @@ -91,7 +92,7 @@ func StopStdioClient(productID string) bool { if !ok { return false } - if err := client.Stop(); err != nil { + if err := stopStdio(client); err != nil { slog.Warn("failed to stop stdio client", "id", productID, "error", err) } delete(stdioClients, productID) @@ -108,7 +109,7 @@ func StopStdioClientsByPlugin(pluginName string) int { count := 0 for id, client := range stdioClients { if len(id) > len(prefix) && id[:len(prefix)] == prefix { - if err := client.Stop(); err != nil { + if err := stopStdio(client); err != nil { slog.Warn("failed to stop stdio client", "id", id, "error", err) } delete(stdioClients, id) diff --git a/internal/app/timing.go b/internal/app/timing.go index 8b7224e4..7ed74b8c 100644 --- a/internal/app/timing.go +++ b/internal/app/timing.go @@ -55,6 +55,16 @@ const ( perfReportFile = "latest.json" ) +var ( + timingMarshalIndent = json.MarshalIndent + timingMkdirAll = os.MkdirAll + timingWriteFile = os.WriteFile + timingRemove = os.Remove + timingRename = os.Rename + timingReadFile = os.ReadFile + timingUserHomeDir = os.UserHomeDir +) + // timingContextKey is the context key for TimingCollector. type timingContextKey struct{} @@ -295,7 +305,7 @@ func (tc *TimingCollector) WriteReportIfEnabled(cliVersion, command string) { } report := tc.BuildReport(cliVersion, command) - data, err := json.MarshalIndent(report, "", " ") + data, err := timingMarshalIndent(report, "", " ") if err != nil { return } @@ -306,22 +316,22 @@ func (tc *TimingCollector) WriteReportIfEnabled(cliVersion, command string) { } dir := filepath.Dir(path) - if err := os.MkdirAll(dir, 0o700); err != nil { + if err := timingMkdirAll(dir, 0o700); err != nil { return } tmp := path + ".tmp" - if err := os.WriteFile(tmp, data, 0o600); err != nil { - _ = os.Remove(tmp) + if err := timingWriteFile(tmp, data, 0o600); err != nil { + _ = timingRemove(tmp) return } - _ = os.Rename(tmp, path) + _ = timingRename(tmp, path) } // LoadLatestReport reads the default perf report file (~/.dws/perf/latest.json). func LoadLatestReport() (*PerfReport, error) { path := defaultPerfReportPath() - data, err := os.ReadFile(path) + data, err := timingReadFile(path) if err != nil { return nil, err } @@ -341,7 +351,7 @@ func resolvePerfReportPath(dest string) string { } func defaultPerfReportPath() string { - home, err := os.UserHomeDir() + home, err := timingUserHomeDir() if err != nil { return "" } diff --git a/internal/app/upgrade.go b/internal/app/upgrade.go index c12fa609..aee0769d 100644 --- a/internal/app/upgrade.go +++ b/internal/app/upgrade.go @@ -31,6 +31,53 @@ var ( ugBoldGrn = tui.Success ) +type upgradeReleaseClient interface { + FetchLatestReleaseForTrack(upgrade.ReleaseTrack) (*upgrade.ReleaseInfo, error) + FetchReleaseByTag(string) (*upgrade.ReleaseInfo, error) + FetchReleaseVersions(upgrade.ReleaseTrack) ([]upgrade.VersionEntry, error) +} + +type upgradeRollbackManager interface { + ListBackups() ([]upgrade.BackupInfo, error) + RollbackTo(upgrade.BackupInfo) error + Backup(string) (string, error) + Cleanup(int) error +} + +var ( + newUpgradeReleaseClient = func() upgradeReleaseClient { return upgrade.NewClient() } + newUpgradeRollback = func() upgradeRollbackManager { return upgrade.NewRollbackManager() } + ensureUpgradeDirs = upgrade.EnsureUpgradeDirectories + cleanupUpgradeStale = upgrade.CleanupStaleFiles + upgradeNeedsUpgrade = upgrade.NeedsUpgrade + findUpgradeBinary = upgrade.FindBinaryAsset + findUpgradeSkills = upgrade.FindSkillsAsset + findUpgradeChecksums = upgrade.FindChecksumsAsset + downloadUpgradeFile = upgrade.Download + downloadUpgradeProgress = upgrade.DownloadWithProgress + extractUpgradeZip = upgrade.ExtractZip + findExtractedBinary = upgrade.FindBinaryInDir + locateUpgradeSkill = upgrade.LocateSkillMD + replaceUpgradeSelf = upgrade.ReplaceSelf + installUpgradeSkills = upgrade.UpgradeSkillLocations + upgradeMkdirTemp = os.MkdirTemp + upgradeRemoveAll = os.RemoveAll + upgradeReadFile = os.ReadFile + upgradeMkdirAll = os.MkdirAll + verifyUpgradeFile = strictVerifyFile + extractUpgradeTarGz = extractTarGz + validateUpgradeBinary = validateNewBinary + upgradeStat = os.Stat + upgradeChmod = os.Chmod + upgradeTryExecVersion = tryExecVersion + upgradeRepairDarwin = repairDarwinBinary + upgradeLookPath = exec.LookPath + upgradeCommandOutput = func(name string, args ...string) ([]byte, error) { + return exec.Command(name, args...).CombinedOutput() + } + upgradeUserHomeDir = os.UserHomeDir +) + const defaultListLimit = 10 func newUpgradeCommand() *cobra.Command { @@ -128,7 +175,7 @@ type upgradeOptions struct { // --- dws upgrade --check --- func runUpgradeCheck(cmd *cobra.Command, format string, track upgrade.ReleaseTrack) error { - client := upgrade.NewClient() + client := newUpgradeReleaseClient() if format != "json" { fmt.Printf(" %s\n", ugDim(fmt.Sprintf("检查更新%s...", upgradeTrackSuffix(track)))) @@ -140,7 +187,7 @@ func runUpgradeCheck(cmd *cobra.Command, format string, track upgrade.ReleaseTra } currentVer := version - needsUpgrade := upgrade.NeedsUpgrade(currentVer, latest.Version) + needsUpgrade := upgradeNeedsUpgrade(currentVer, latest.Version) if format == "json" { return writeJSON(cmd.OutOrStdout(), map[string]any{ @@ -184,7 +231,7 @@ func runUpgradeCheck(cmd *cobra.Command, format string, track upgrade.ReleaseTra // runUpgradeList displays available versions. When limit > 0, only the most // recent `limit` versions are shown; pass 0 to show all (--all flag). func runUpgradeList(cmd *cobra.Command, format string, limit int, track upgrade.ReleaseTrack) error { - client := upgrade.NewClient() + client := newUpgradeReleaseClient() if format != "json" { fmt.Printf(" %s\n", ugDim(fmt.Sprintf("获取版本列表%s...", upgradeTrackSuffix(track)))) @@ -264,7 +311,7 @@ func runUpgradeList(cmd *cobra.Command, format string, limit int, track upgrade. // --- dws upgrade --rollback --- func runUpgradeRollback(yes bool) error { - rm := upgrade.NewRollbackManager() + rm := newUpgradeRollback() backups, err := rm.ListBackups() if err != nil { @@ -341,13 +388,13 @@ func writeDryRunPlan(w io.Writer, currentVer, binaryAssetName string, hasSkills func runUpgrade(ctx context.Context, opts upgradeOptions) error { fmt.Printf(" %s\n", ugDim(fmt.Sprintf("检查更新%s...", upgradeTrackSuffix(opts.track)))) - if err := upgrade.EnsureUpgradeDirectories(); err != nil { + if err := ensureUpgradeDirs(); err != nil { return fmt.Errorf("初始化目录结构失败: %w", err) } - upgrade.CleanupStaleFiles() + cleanupUpgradeStale() - client := upgrade.NewClient() + client := newUpgradeReleaseClient() var release *upgrade.ReleaseInfo var err error @@ -365,7 +412,7 @@ func runUpgrade(ctx context.Context, opts upgradeOptions) error { } currentVer := version - if !opts.force && !upgrade.NeedsUpgrade(currentVer, release.Version) { + if !opts.force && !upgradeNeedsUpgrade(currentVer, release.Version) { fmt.Printf("\n %s 已是最新版本 %s\n", ugBoldGrn("✔"), ugBold(ensureV(currentVer))) return nil } @@ -384,11 +431,11 @@ func runUpgrade(ctx context.Context, opts upgradeOptions) error { // any side effect (no backup, no download, no replace). Matches the global // flag's contract: "预览操作内容,不实际执行". if opts.dryRun { - binaryAsset, err := upgrade.FindBinaryAsset(release.Assets) + binaryAsset, err := findUpgradeBinary(release.Assets) if err != nil { return err } - hasSkills := upgrade.FindSkillsAsset(release.Assets) != nil && !opts.skipSkills + hasSkills := findUpgradeSkills(release.Assets) != nil && !opts.skipSkills writeDryRunPlan(os.Stdout, currentVer, binaryAsset.Name, hasSkills) return nil } @@ -404,21 +451,21 @@ func runUpgrade(ctx context.Context, opts upgradeOptions) error { } } - binaryAsset, err := upgrade.FindBinaryAsset(release.Assets) + binaryAsset, err := findUpgradeBinary(release.Assets) if err != nil { return err } - tmpDir, err := os.MkdirTemp(upgrade.DownloadCacheDir(), "upgrade-*") + tmpDir, err := upgradeMkdirTemp(upgrade.DownloadCacheDir(), "upgrade-*") if err != nil { - tmpDir, err = os.MkdirTemp("", "dws-upgrade-*") + tmpDir, err = upgradeMkdirTemp("", "dws-upgrade-*") if err != nil { return fmt.Errorf("创建临时目录失败: %w", err) } } - defer os.RemoveAll(tmpDir) + defer upgradeRemoveAll(tmpDir) - hasSkills := upgrade.FindSkillsAsset(release.Assets) != nil && !opts.skipSkills + hasSkills := findUpgradeSkills(release.Assets) != nil && !opts.skipSkills // Steps: 1.备份 2.下载 3.校验 4.解压验证 5.替换+安装 const totalSteps = 5 stepFmt := func(n int) string { return ugBold(fmt.Sprintf("[%d/%d]", n, totalSteps)) } @@ -431,7 +478,7 @@ func runUpgrade(ctx context.Context, opts upgradeOptions) error { // --- Step 1: Backup --- fmt.Printf(" %s 备份当前版本...", stepFmt(1)) - rm := upgrade.NewRollbackManager() + rm := newUpgradeRollback() _, backupErr := rm.Backup(strings.TrimPrefix(currentVer, "v")) if backupErr != nil { fmt.Printf(" %s %v\n", ugYellow("⚠"), backupErr) @@ -441,11 +488,11 @@ func runUpgrade(ctx context.Context, opts upgradeOptions) error { // Fetch checksums.txt (needed for strict verification of both binary and skills) var checksumsContent string - checksumsAsset := upgrade.FindChecksumsAsset(release.Assets) + checksumsAsset := findUpgradeChecksums(release.Assets) if checksumsAsset != nil { checksumsPath := filepath.Join(tmpDir, "checksums.txt") - if _, dlErr := upgrade.Download(checksumsAsset.BrowserDownloadURL, checksumsPath); dlErr == nil { - if data, readErr := os.ReadFile(checksumsPath); readErr == nil { + if _, dlErr := downloadUpgradeFile(checksumsAsset.BrowserDownloadURL, checksumsPath); dlErr == nil { + if data, readErr := upgradeReadFile(checksumsPath); readErr == nil { checksumsContent = string(data) } } @@ -457,7 +504,7 @@ func runUpgrade(ctx context.Context, opts upgradeOptions) error { fmt.Print(progressPrefix) start := time.Now() binaryArchivePath := filepath.Join(tmpDir, binaryAsset.Name) - n, err := upgrade.DownloadWithProgress(ctx, binaryAsset.BrowserDownloadURL, binaryArchivePath, + n, err := downloadUpgradeProgress(ctx, binaryAsset.BrowserDownloadURL, binaryArchivePath, func(percent float64, downloaded, total int64) { bar := progressBar(percent) fmt.Printf("\r %s 下载 %s [%s] %5.1f%%", sl, ugCyan(binaryAsset.Name), ugCyan(bar), percent) @@ -471,12 +518,12 @@ func runUpgrade(ctx context.Context, opts upgradeOptions) error { var skillsZipPath string if hasSkills { - skillsAsset := upgrade.FindSkillsAsset(release.Assets) + skillsAsset := findUpgradeSkills(release.Assets) fmt.Printf("\r%s\r%s %s %s\n", clearLine, progressPrefix, ugGreen("✓"), ugDim(fmt.Sprintf("(%.1fMB, %.1fs)", float64(n)/1024/1024, elapsed.Seconds()))) fmt.Printf(" 下载 %s...", ugCyan("dws-skills.zip")) skillsZipPath = filepath.Join(tmpDir, "dws-skills.zip") - if _, dlErr := upgrade.Download(skillsAsset.BrowserDownloadURL, skillsZipPath); dlErr != nil { + if _, dlErr := downloadUpgradeFile(skillsAsset.BrowserDownloadURL, skillsZipPath); dlErr != nil { fmt.Printf(" %s\n", ugRed("✗")) return fmt.Errorf("技能包下载失败: %w", dlErr) } @@ -486,12 +533,12 @@ func runUpgrade(ctx context.Context, opts upgradeOptions) error { } // --- Step 3: Verify SHA256 (binary + skills together) --- - if err := strictVerifyFile(stepFmt(3), binaryArchivePath, binaryAsset.Name, binaryAsset.Digest, checksumsContent); err != nil { + if err := verifyUpgradeFile(stepFmt(3), binaryArchivePath, binaryAsset.Name, binaryAsset.Digest, checksumsContent); err != nil { return err } if hasSkills { - skillsAsset := upgrade.FindSkillsAsset(release.Assets) - if err := strictVerifyFile(" ", skillsZipPath, "dws-skills.zip", skillsAsset.Digest, checksumsContent); err != nil { + skillsAsset := findUpgradeSkills(release.Assets) + if err := verifyUpgradeFile(" ", skillsZipPath, "dws-skills.zip", skillsAsset.Digest, checksumsContent); err != nil { return err } } @@ -500,22 +547,22 @@ func runUpgrade(ctx context.Context, opts upgradeOptions) error { fmt.Printf(" %s 解压并验证...", stepFmt(4)) extractDir := filepath.Join(tmpDir, "extracted") if strings.HasSuffix(binaryAsset.Name, ".zip") { - if err := upgrade.ExtractZip(binaryArchivePath, extractDir); err != nil { + if err := extractUpgradeZip(binaryArchivePath, extractDir); err != nil { fmt.Println() return fmt.Errorf("解压失败: %w", err) } } else { - if err := extractTarGz(binaryArchivePath, extractDir); err != nil { + if err := extractUpgradeTarGz(binaryArchivePath, extractDir); err != nil { fmt.Println() return fmt.Errorf("解压失败: %w", err) } } - binaryPath := upgrade.FindBinaryInDir(extractDir) + binaryPath := findExtractedBinary(extractDir) if binaryPath == "" { fmt.Println() return fmt.Errorf("在解压目录中未找到 dws 二进制文件") } - if err := validateNewBinary(binaryPath, release.Version); err != nil { + if err := validateUpgradeBinary(binaryPath, release.Version); err != nil { fmt.Println() return fmt.Errorf("验证失败: %w", err) } @@ -523,12 +570,12 @@ func runUpgrade(ctx context.Context, opts upgradeOptions) error { var skillSrc string if hasSkills { skillsExtractDir := filepath.Join(tmpDir, "skills-extracted") - os.MkdirAll(skillsExtractDir, 0755) - if err := upgrade.ExtractZip(skillsZipPath, skillsExtractDir); err != nil { + _ = upgradeMkdirAll(skillsExtractDir, 0755) + if err := extractUpgradeZip(skillsZipPath, skillsExtractDir); err != nil { fmt.Println() return fmt.Errorf("技能包解压失败 (文件可能损坏,请检查网络后重试): %w", err) } - skillSrc = upgrade.LocateSkillMD(skillsExtractDir) + skillSrc = locateUpgradeSkill(skillsExtractDir) if skillSrc == "" { fmt.Println() return fmt.Errorf("技能包结构异常 (未找到 SKILL.md),请反馈到 GitHub Issues") @@ -542,13 +589,13 @@ func runUpgrade(ctx context.Context, opts upgradeOptions) error { // --- Step 5: Replace binary + install skills --- fmt.Printf(" %s 替换并安装...", stepFmt(5)) - if err := upgrade.ReplaceSelf(binaryPath); err != nil { + if err := replaceUpgradeSelf(binaryPath); err != nil { fmt.Printf(" %s\n", ugRed("✗")) return fmt.Errorf("替换二进制失败: %w", err) } if hasSkills { - result, installErr := upgrade.UpgradeSkillLocations(skillSrc) + result, installErr := installUpgradeSkills(skillSrc) if installErr != nil { fmt.Printf(" %s\n", ugRed("✗")) return fmt.Errorf("技能包安装失败: %w", installErr) @@ -626,24 +673,24 @@ func strictVerifyFile(label, filePath, fileName, assetDigest, checksumsContent s // validateNewBinary checks the downloaded binary is valid. func validateNewBinary(binaryPath, expectedVersion string) error { - info, err := os.Stat(binaryPath) + info, err := upgradeStat(binaryPath) if err != nil { return fmt.Errorf("文件不存在: %w", err) } if info.Size() == 0 { return fmt.Errorf("文件为空") } - if err := os.Chmod(binaryPath, 0755); err != nil { + if err := upgradeChmod(binaryPath, 0755); err != nil { return fmt.Errorf("设置执行权限失败: %w", err) } - out, err := tryExecVersion(binaryPath) + out, err := upgradeTryExecVersion(binaryPath) if err != nil { // Apple Silicon kills unsigned arm64 binaries with SIGKILL via amfid. // Repair the binary in-place (ad-hoc codesign + drop quarantine) and retry once. if runtime.GOOS == "darwin" && isLikelyAMFIKill(err) { - if repairErr := repairDarwinBinary(binaryPath); repairErr == nil { - out, err = tryExecVersion(binaryPath) + if repairErr := upgradeRepairDarwin(binaryPath); repairErr == nil { + out, err = upgradeTryExecVersion(binaryPath) } } if err != nil { @@ -678,12 +725,12 @@ func isLikelyAMFIKill(err error) bool { // Used as a self-heal step when an unsigned binary is killed by amfid on Apple Silicon. func repairDarwinBinary(binaryPath string) error { // Best-effort: strip quarantine. Failure is fine (attribute often absent). - _ = exec.Command("xattr", "-d", "com.apple.quarantine", binaryPath).Run() + _, _ = upgradeCommandOutput("xattr", "-d", "com.apple.quarantine", binaryPath) - if _, err := exec.LookPath("codesign"); err != nil { + if _, err := upgradeLookPath("codesign"); err != nil { return fmt.Errorf("codesign 不可用: %w", err) } - out, err := exec.Command("codesign", "--force", "--sign", "-", binaryPath).CombinedOutput() + out, err := upgradeCommandOutput("codesign", "--force", "--sign", "-", binaryPath) if err != nil { return fmt.Errorf("codesign 失败: %v: %s", err, strings.TrimSpace(string(out))) } @@ -692,9 +739,8 @@ func repairDarwinBinary(binaryPath string) error { // extractTarGz extracts a .tar.gz file using the system tar command. func extractTarGz(archivePath, destDir string) error { - os.MkdirAll(destDir, 0755) - cmd := exec.Command("tar", "xzf", archivePath, "-C", destDir) - if out, err := cmd.CombinedOutput(); err != nil { + _ = upgradeMkdirAll(destDir, 0755) + if out, err := upgradeCommandOutput("tar", "xzf", archivePath, "-C", destDir); err != nil { return fmt.Errorf("tar 解压失败: %v: %s", err, string(out)) } return nil @@ -867,7 +913,7 @@ func writeJSON(w interface{ Write([]byte) (int, error) }, v any) error { } func shortenHome(path string) string { - homeDir, err := os.UserHomeDir() + homeDir, err := upgradeUserHomeDir() if err != nil { return path } diff --git a/internal/app/upgrade_full_coverage_test.go b/internal/app/upgrade_full_coverage_test.go new file mode 100644 index 00000000..53c971a7 --- /dev/null +++ b/internal/app/upgrade_full_coverage_test.go @@ -0,0 +1,437 @@ +package app + +import ( + "context" + "errors" + "io" + "os" + "runtime" + "strings" + "testing" + "time" + + upgradepkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" + "github.com/spf13/cobra" +) + +type fakeUpgradeClient struct { + latest *upgradepkg.ReleaseInfo + latestErr error + tagged *upgradepkg.ReleaseInfo + taggedErr error + versions []upgradepkg.VersionEntry + versionsErr error +} + +func (c *fakeUpgradeClient) FetchLatestReleaseForTrack(upgradepkg.ReleaseTrack) (*upgradepkg.ReleaseInfo, error) { + return c.latest, c.latestErr +} + +func (c *fakeUpgradeClient) FetchReleaseByTag(string) (*upgradepkg.ReleaseInfo, error) { + return c.tagged, c.taggedErr +} + +func (c *fakeUpgradeClient) FetchReleaseVersions(upgradepkg.ReleaseTrack) ([]upgradepkg.VersionEntry, error) { + return c.versions, c.versionsErr +} + +type fakeUpgradeRollback struct { + backups []upgradepkg.BackupInfo + listErr error + backupErr error + rollbackErr error + cleaned bool +} + +func (r *fakeUpgradeRollback) ListBackups() ([]upgradepkg.BackupInfo, error) { + return r.backups, r.listErr +} +func (r *fakeUpgradeRollback) RollbackTo(upgradepkg.BackupInfo) error { return r.rollbackErr } +func (r *fakeUpgradeRollback) Backup(string) (string, error) { return "backup", r.backupErr } +func (r *fakeUpgradeRollback) Cleanup(int) error { + r.cleaned = true + return nil +} + +type upgradeFileInfo struct{ size int64 } + +func (i upgradeFileInfo) Name() string { return "dws" } +func (i upgradeFileInfo) Size() int64 { return i.size } +func (i upgradeFileInfo) Mode() os.FileMode { return 0o755 } +func (i upgradeFileInfo) ModTime() time.Time { return time.Time{} } +func (i upgradeFileInfo) IsDir() bool { return false } +func (i upgradeFileInfo) Sys() any { return nil } + +func TestUpgradeRollbackAndCommandBranchesCoverage(t *testing.T) { + oldClient, oldRollback := newUpgradeReleaseClient, newUpgradeRollback + oldEdition := edition.Get() + oldStdin := os.Stdin + oldVersion := version + t.Cleanup(func() { + newUpgradeReleaseClient, newUpgradeRollback = oldClient, oldRollback + edition.Override(oldEdition) + os.Stdin = oldStdin + version = oldVersion + }) + fail := errors.New("failure") + rb := &fakeUpgradeRollback{listErr: fail} + newUpgradeRollback = func() upgradeRollbackManager { return rb } + if err := runUpgradeRollback(true); !errors.Is(err, fail) { + t.Fatalf("rollback list error = %v", err) + } + rb.listErr = nil + if err := runUpgradeRollback(true); err == nil { + t.Fatal("rollback without backups succeeded") + } + rb.backups = []upgradepkg.BackupInfo{{Version: "1.0.0", CreatedAt: time.Now()}} + read, write, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + _, _ = io.WriteString(write, "n\n") + _ = write.Close() + os.Stdin = read + if err := runUpgradeRollback(false); err != nil { + t.Fatalf("cancel rollback = %v", err) + } + _ = read.Close() + rb.rollbackErr = fail + if err := runUpgradeRollback(true); !errors.Is(err, fail) { + t.Fatalf("rollback apply error = %v", err) + } + rb.rollbackErr = nil + if err := runUpgradeRollback(true); err != nil { + t.Fatalf("rollback success = %v", err) + } + + client := &fakeUpgradeClient{ + latest: &upgradepkg.ReleaseInfo{Version: "9.9.9"}, + tagged: &upgradepkg.ReleaseInfo{Version: "9.9.9"}, + versions: []upgradepkg.VersionEntry{{Version: "9.9.9"}, {Version: "9.9.8"}}, + } + newUpgradeReleaseClient = func() upgradeReleaseClient { return client } + client.latestErr = fail + cmd := &cobra.Command{Use: "upgrade"} + cmd.SetOut(io.Discard) + if err := runUpgradeCheck(cmd, "json", upgradepkg.ReleaseTrackRelease); !errors.Is(err, fail) { + t.Fatalf("upgrade check error = %v", err) + } + client.latestErr = nil + client.versionsErr = fail + if err := runUpgradeList(cmd, "json", 1, upgradepkg.ReleaseTrackRelease); !errors.Is(err, fail) { + t.Fatalf("upgrade list error = %v", err) + } + client.versionsErr = nil + version = "9.9.9" + if err := runUpgradeList(cmd, "json", 1, upgradepkg.ReleaseTrackRelease); err != nil { + t.Fatal(err) + } + if err := runUpgradeList(cmd, "table", 1, upgradepkg.ReleaseTrackRelease); err != nil { + t.Fatal(err) + } + + edition.Override(&edition.Hooks{IsEmbedded: true}) + embedded := newUpgradeCommand() + if err := embedded.Execute(); err == nil || !strings.Contains(err.Error(), "embedded") { + t.Fatalf("unnamed embedded upgrade error = %v", err) + } + edition.Override(&edition.Hooks{}) + for _, args := range [][]string{{"--list", "--all"}, {"--rollback", "--yes"}, {"--check"}} { + command := newUpgradeCommand() + if args[0] == "--rollback" { + command.Flags().Bool("yes", false, "") + } + command.SetOut(io.Discard) + command.SetArgs(args) + if err := command.Execute(); err != nil { + t.Fatalf("upgrade command %v = %v", args, err) + } + } +} + +func TestRunUpgradeAllStagesCoverage(t *testing.T) { + oldClient, oldRollback := newUpgradeReleaseClient, newUpgradeRollback + oldEnsure, oldCleanup := ensureUpgradeDirs, cleanupUpgradeStale + oldNeeds, oldBinary, oldSkills, oldChecksums := upgradeNeedsUpgrade, findUpgradeBinary, findUpgradeSkills, findUpgradeChecksums + oldDownload, oldProgress := downloadUpgradeFile, downloadUpgradeProgress + oldExtract, oldFind, oldLocate := extractUpgradeZip, findExtractedBinary, locateUpgradeSkill + oldReplace, oldInstall := replaceUpgradeSelf, installUpgradeSkills + oldTemp, oldRemove, oldRead, oldMkdir := upgradeMkdirTemp, upgradeRemoveAll, upgradeReadFile, upgradeMkdirAll + oldVerify, oldTar, oldValidate := verifyUpgradeFile, extractUpgradeTarGz, validateUpgradeBinary + oldStdin := os.Stdin + t.Cleanup(func() { + newUpgradeReleaseClient, newUpgradeRollback = oldClient, oldRollback + ensureUpgradeDirs, cleanupUpgradeStale = oldEnsure, oldCleanup + upgradeNeedsUpgrade, findUpgradeBinary, findUpgradeSkills, findUpgradeChecksums = oldNeeds, oldBinary, oldSkills, oldChecksums + downloadUpgradeFile, downloadUpgradeProgress = oldDownload, oldProgress + extractUpgradeZip, findExtractedBinary, locateUpgradeSkill = oldExtract, oldFind, oldLocate + replaceUpgradeSelf, installUpgradeSkills = oldReplace, oldInstall + upgradeMkdirTemp, upgradeRemoveAll, upgradeReadFile, upgradeMkdirAll = oldTemp, oldRemove, oldRead, oldMkdir + verifyUpgradeFile, extractUpgradeTarGz, validateUpgradeBinary = oldVerify, oldTar, oldValidate + os.Stdin = oldStdin + }) + fail := errors.New("stage failure") + binary := upgradepkg.GitHubAsset{Name: "dws.zip", BrowserDownloadURL: "binary"} + skills := upgradepkg.GitHubAsset{Name: "dws-skills.zip", BrowserDownloadURL: "skills"} + checksums := upgradepkg.GitHubAsset{Name: "checksums.txt", BrowserDownloadURL: "checksums"} + release := &upgradepkg.ReleaseInfo{Version: "9.9.9", Date: "2026-01-01", Prerelease: true, Assets: []upgradepkg.GitHubAsset{binary, skills, checksums}} + client := &fakeUpgradeClient{latest: release, tagged: release} + rb := &fakeUpgradeRollback{} + + configure := func(stage string) { + client.latestErr, client.taggedErr = nil, nil + newUpgradeReleaseClient = func() upgradeReleaseClient { return client } + newUpgradeRollback = func() upgradeRollbackManager { return rb } + rb.backupErr, rb.cleaned = nil, false + ensureUpgradeDirs = func() error { + if stage == "ensure" { + return fail + } + return nil + } + cleanupUpgradeStale = func() {} + upgradeNeedsUpgrade = func(string, string) bool { return stage != "not-needed" } + findUpgradeBinary = func([]upgradepkg.GitHubAsset) (*upgradepkg.GitHubAsset, error) { + if stage == "find-binary" { + return nil, fail + } + asset := binary + if stage == "extract-tar" { + asset.Name = "dws.tar.gz" + } + return &asset, nil + } + findUpgradeSkills = func([]upgradepkg.GitHubAsset) *upgradepkg.GitHubAsset { asset := skills; return &asset } + findUpgradeChecksums = func([]upgradepkg.GitHubAsset) *upgradepkg.GitHubAsset { asset := checksums; return &asset } + tempCalls := 0 + upgradeMkdirTemp = func(string, string) (string, error) { + tempCalls++ + if stage == "temp-both" || (stage == "temp-fallback" && tempCalls == 1) { + return "", fail + } + return "/tmp/dws-upgrade-coverage", nil + } + upgradeRemoveAll = func(string) error { return nil } + if stage == "backup" { + rb.backupErr = fail + } + downloadUpgradeFile = func(url, _ string) (int64, error) { + if stage == "checksum-download" && url == "checksums" { + return 0, fail + } + if stage == "skills-download" && url == "skills" { + return 0, fail + } + return 4, nil + } + upgradeReadFile = func(string) ([]byte, error) { + if stage == "checksum-read" { + return nil, fail + } + return []byte("checksum"), nil + } + downloadUpgradeProgress = func(_ context.Context, _, _ string, progress func(float64, int64, int64)) (int64, error) { + progress(150, 10, 10) + if stage == "binary-download" || stage == "temp-fallback" { + return 0, fail + } + return 1024, nil + } + verifyCalls := 0 + verifyUpgradeFile = func(string, string, string, string, string) error { + verifyCalls++ + if stage == "verify-binary" && verifyCalls == 1 || stage == "verify-skills" && verifyCalls == 2 { + return fail + } + return nil + } + extractCalls := 0 + extractUpgradeZip = func(string, string) error { + extractCalls++ + if stage == "extract-binary" && extractCalls == 1 || stage == "extract-skills" && extractCalls == 2 { + return fail + } + return nil + } + extractUpgradeTarGz = func(string, string) error { + if stage == "extract-tar" { + return fail + } + return nil + } + findExtractedBinary = func(string) string { + if stage == "binary-missing" { + return "" + } + return "/tmp/new-dws" + } + validateUpgradeBinary = func(string, string) error { + if stage == "validate" { + return fail + } + return nil + } + upgradeMkdirAll = func(string, os.FileMode) error { return nil } + locateUpgradeSkill = func(string) string { + if stage == "skill-missing" { + return "" + } + return "/tmp/SKILL.md" + } + replaceUpgradeSelf = func(string) error { + if stage == "replace" { + return fail + } + return nil + } + installUpgradeSkills = func(string) (*upgradepkg.SkillUpgradeResult, error) { + if stage == "install" { + return nil, fail + } + if stage == "install-failed-dir" { + return &upgradepkg.SkillUpgradeResult{Results: []upgradepkg.SkillDirResult{{Dir: "/failed", Status: upgradepkg.SkillDirFailed, Err: fail}}}, nil + } + return &upgradepkg.SkillUpgradeResult{Results: []upgradepkg.SkillDirResult{{Dir: "/ok", Status: upgradepkg.SkillDirOK}}}, nil + } + } + + for _, stage := range []string{ + "ensure", "tag-error", "latest-error", "not-needed", "cancel", "find-binary", "temp-fallback", "temp-both", + "backup", "checksum-download", "checksum-read", "binary-download", "skills-download", "verify-binary", "verify-skills", + "extract-binary", "extract-tar", "binary-missing", "validate", "extract-skills", "skill-missing", "replace", "install", "install-failed-dir", + "success", "success-no-skills", + } { + t.Run(stage, func(t *testing.T) { + configure(stage) + opts := upgradeOptions{force: true, yes: true} + if stage == "tag-error" { + opts.targetVersion = "v9.9.9" + client.taggedErr = fail + } + if stage == "latest-error" { + client.latestErr = fail + } + if stage == "not-needed" { + opts.force = false + } + if stage == "cancel" { + opts.yes = false + read, write, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + _, _ = io.WriteString(write, "n\n") + _ = write.Close() + os.Stdin = read + defer read.Close() + } + if stage == "success-no-skills" { + opts.skipSkills = true + } + err := runUpgrade(context.Background(), opts) + wantError := stage != "not-needed" && stage != "cancel" && stage != "backup" && stage != "checksum-download" && stage != "checksum-read" && stage != "success" && stage != "success-no-skills" + if wantError && err == nil { + t.Fatalf("stage %s succeeded", stage) + } + if !wantError && err != nil { + t.Fatalf("stage %s failed: %v", stage, err) + } + if (stage == "success" || stage == "success-no-skills") && !rb.cleaned { + t.Fatal("successful upgrade did not clean backups") + } + if stage == "success" { + command := newUpgradeCommand() + command.Flags().Bool("yes", false, "") + command.Flags().Bool("dry-run", false, "") + _ = command.Flags().Set("yes", "true") + _ = command.Flags().Set("force", "true") + _ = command.Flags().Set("skip-skills", "true") + if err := command.RunE(command, nil); err != nil { + t.Fatalf("default upgrade command = %v", err) + } + } + }) + } +} + +func TestUpgradeBinaryHelpersFailureCoverage(t *testing.T) { + oldStat, oldChmod := upgradeStat, upgradeChmod + oldTry, oldRepair := upgradeTryExecVersion, upgradeRepairDarwin + oldLook, oldCommand, oldMkdir, oldHome := upgradeLookPath, upgradeCommandOutput, upgradeMkdirAll, upgradeUserHomeDir + t.Cleanup(func() { + upgradeStat, upgradeChmod = oldStat, oldChmod + upgradeTryExecVersion, upgradeRepairDarwin = oldTry, oldRepair + upgradeLookPath, upgradeCommandOutput, upgradeMkdirAll, upgradeUserHomeDir = oldLook, oldCommand, oldMkdir, oldHome + }) + fail := errors.New("failure") + upgradeStat = func(string) (os.FileInfo, error) { return nil, fail } + if err := validateNewBinary("binary", "1.0"); !errors.Is(err, fail) { + t.Fatalf("binary stat error = %v", err) + } + upgradeStat = func(string) (os.FileInfo, error) { return upgradeFileInfo{}, nil } + if err := validateNewBinary("binary", "1.0"); err == nil || !strings.Contains(err.Error(), "为空") { + t.Fatalf("empty binary error = %v", err) + } + upgradeStat = func(string) (os.FileInfo, error) { return upgradeFileInfo{size: 1}, nil } + upgradeChmod = func(string, os.FileMode) error { return fail } + if err := validateNewBinary("binary", "1.0"); !errors.Is(err, fail) { + t.Fatalf("binary chmod error = %v", err) + } + upgradeChmod = func(string, os.FileMode) error { return nil } + upgradeTryExecVersion = func(string) ([]byte, error) { return nil, fail } + upgradeRepairDarwin = func(string) error { return fail } + if err := validateNewBinary("binary", "1.0"); err == nil { + t.Fatal("unexecutable binary succeeded") + } + if runtime.GOOS == "darwin" { + calls := 0 + upgradeTryExecVersion = func(string) ([]byte, error) { + calls++ + if calls == 1 { + return nil, errors.New("signal: killed") + } + return []byte("version 1.0"), nil + } + upgradeRepairDarwin = func(string) error { return nil } + if err := validateNewBinary("binary", "1.0"); err != nil { + t.Fatalf("repaired binary = %v", err) + } + } + upgradeTryExecVersion = func(string) ([]byte, error) { return []byte("different"), nil } + if err := validateNewBinary("binary", "1.0"); err != nil { + t.Fatalf("version mismatch warning = %v", err) + } + + upgradeLookPath = func(string) (string, error) { return "", fail } + upgradeCommandOutput = func(string, ...string) ([]byte, error) { return nil, nil } + if err := repairDarwinBinary("binary"); !errors.Is(err, fail) { + t.Fatalf("codesign lookup error = %v", err) + } + upgradeLookPath = func(string) (string, error) { return "/codesign", nil } + upgradeCommandOutput = func(name string, _ ...string) ([]byte, error) { + if name == "codesign" { + return []byte("denied"), fail + } + return nil, nil + } + if err := repairDarwinBinary("binary"); err == nil || !strings.Contains(err.Error(), "denied") { + t.Fatalf("codesign execution error = %v", err) + } + upgradeCommandOutput = func(string, ...string) ([]byte, error) { return nil, nil } + if err := repairDarwinBinary("binary"); err != nil { + t.Fatalf("codesign success = %v", err) + } + 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") { + t.Fatalf("tar error = %v", err) + } + upgradeUserHomeDir = func() (string, error) { return "", fail } + if shortenHome("/path") != "/path" { + t.Fatal("home lookup failure shortened path") + } + if got := parseChangelogEntries("abcdef12", 1); len(got) != 0 { + t.Fatalf("empty changelog entry = %#v", got) + } +} diff --git a/internal/helpers/agoal.go b/internal/helpers/agoal.go index e3171f8a..ec894341 100644 --- a/internal/helpers/agoal.go +++ b/internal/helpers/agoal.go @@ -8,6 +8,8 @@ import ( "github.com/spf13/cobra" ) +var agoalLoadLocation = time.LoadLocation + // ────────────────────────────────────────────────────────── // dws agoal — Agoal 管理 // ────────────────────────────────────────────────────────── @@ -595,7 +597,7 @@ func parseISO8601ToMillis(value string) (int64, error) { } func shanghaiLocation() *time.Location { - loc, err := time.LoadLocation("Asia/Shanghai") + loc, err := agoalLoadLocation("Asia/Shanghai") if err != nil { return time.FixedZone("CST", 8*3600) } diff --git a/internal/helpers/agoal_coverage_more_test.go b/internal/helpers/agoal_coverage_more_test.go new file mode 100644 index 00000000..27883999 --- /dev/null +++ b/internal/helpers/agoal_coverage_more_test.go @@ -0,0 +1,43 @@ +package helpers + +import ( + "errors" + "testing" + "time" +) + +func TestAgoalUpdateRemainingCoverage(t *testing.T) { + installScriptedCaller(t, &scriptedToolCaller{dry: true}) + if err := executeFilterCoverage(t, newAgoalCommand(), + "strategy", "update", "--profile-id", "profile", "--content", `[]`, "--request-id", "request", + ); err != nil { + t.Fatalf("strategy update: %v", err) + } + if err := executeFilterCoverage(t, newAgoalCommand(), + "contract", "update", "--contract-id", "contract", "--dimensions", `[]`, + "--request-id", "request", "--audit-config", `{}`, "--objective-template", `{}`, + ); err != nil { + t.Fatalf("contract update: %v", err) + } + if err := executeFilterCoverage(t, newAgoalCommand(), + "scorecard", "update", "--dept-id", "dept", "--selected-time", "bad", "--id", "card", + "--tracking-period-type", "MONTHLY", "--content", `[]`, + ); err == nil { + t.Fatal("invalid scorecard time returned nil") + } + if err := executeFilterCoverage(t, newAgoalCommand(), + "scorecard", "update", "--dept-id", "dept", "--selected-time", "2026-01-01T00:00:00", "--id", "card", + "--tracking-period-type", "MONTHLY", "--content", `[]`, "--request-id", "request", + ); err != nil { + t.Fatalf("scorecard update: %v", err) + } +} + +func TestAgoalLocationFallbackCoverage(t *testing.T) { + original := agoalLoadLocation + agoalLoadLocation = func(string) (*time.Location, error) { return nil, errors.New("zoneinfo unavailable") } + t.Cleanup(func() { agoalLoadLocation = original }) + if got := shanghaiLocation(); got == nil || got.String() != "CST" { + t.Fatalf("fallback location = %v", got) + } +} diff --git a/internal/helpers/aisearch_coverage_more_test.go b/internal/helpers/aisearch_coverage_more_test.go new file mode 100644 index 00000000..f4841d5a --- /dev/null +++ b/internal/helpers/aisearch_coverage_more_test.go @@ -0,0 +1,30 @@ +package helpers + +import ( + "testing" + + "github.com/spf13/cobra" +) + +func TestAISearchRemainingFallbackBranches(t *testing.T) { + if got := flagValue(&cobra.Command{Use: "empty"}, "missing"); got != "" { + t.Fatalf("missing flag=%q", got) + } + if got := parseDimensions(""); len(got) != 1 || got[0] != "all" { + t.Fatalf("empty dimensions=%v", got) + } + if got := parseDimensions(" , "); len(got) != 1 || got[0] != "all" { + t.Fatalf("blank dimensions=%v", got) + } + + installScriptedCaller(t, &scriptedToolCaller{dry: true}) + for _, args := range [][]string{ + {"--query", "alice"}, + {"enterprise", "--types", ","}, + {"behavior", "--types", ","}, + } { + if err := executeFilterCoverage(t, newAisearchCommand(), args...); err != nil { + t.Fatalf("args=%v err=%v", args, err) + } + } +} diff --git a/internal/helpers/atomicwrite.go b/internal/helpers/atomicwrite.go index 0e077e92..611fbd66 100644 --- a/internal/helpers/atomicwrite.go +++ b/internal/helpers/atomicwrite.go @@ -20,6 +20,23 @@ import ( "path/filepath" ) +type atomicTempFile interface { + io.Writer + Name() string + Chmod(os.FileMode) error + Sync() error + Close() error +} + +var ( + atomicMkdirAll = os.MkdirAll + atomicCreateTemp = func(dir, pattern string) (atomicTempFile, error) { + return os.CreateTemp(dir, pattern) + } + atomicRemove = os.Remove + atomicRename = os.Rename +) + // AtomicWrite writes data to path atomically by creating a temp file in the // same directory, writing and fsyncing the data, then renaming over the target. // It replaces os.WriteFile for all config and download file writes. @@ -29,7 +46,7 @@ import ( // AtomicWrite avoids this: on any failure the temp file is cleaned up and the // original file remains untouched. func AtomicWrite(path string, data []byte, perm os.FileMode) error { - return atomicWrite(path, perm, func(tmp *os.File) error { + return atomicWrite(path, perm, func(tmp atomicTempFile) error { _, err := tmp.Write(data) return err }) @@ -38,7 +55,7 @@ func AtomicWrite(path string, data []byte, perm os.FileMode) error { // AtomicWriteFromReader atomically copies reader contents into path. func AtomicWriteFromReader(path string, reader io.Reader, perm os.FileMode) (int64, error) { var copied int64 - err := atomicWrite(path, perm, func(tmp *os.File) error { + err := atomicWrite(path, perm, func(tmp atomicTempFile) error { n, err := io.Copy(tmp, reader) copied = n return err @@ -55,15 +72,15 @@ func AtomicWriteJSON(path string, data []byte) error { return AtomicWrite(path, data, 0600) } -func atomicWrite(path string, perm os.FileMode, writeFn func(tmp *os.File) error) error { +func atomicWrite(path string, perm os.FileMode, writeFn func(tmp atomicTempFile) error) error { dir := filepath.Dir(path) // Ensure directory exists with secure permissions - if err := os.MkdirAll(dir, 0700); err != nil { + if err := atomicMkdirAll(dir, 0700); err != nil { return fmt.Errorf("create directory: %w", err) } - tmp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".*.tmp") + tmp, err := atomicCreateTemp(dir, "."+filepath.Base(path)+".*.tmp") if err != nil { return fmt.Errorf("create temp file: %w", err) } @@ -73,7 +90,7 @@ func atomicWrite(path string, perm os.FileMode, writeFn func(tmp *os.File) error defer func() { if !success { tmp.Close() - os.Remove(tmpName) + atomicRemove(tmpName) } }() @@ -89,7 +106,7 @@ func atomicWrite(path string, perm os.FileMode, writeFn func(tmp *os.File) error if err := tmp.Close(); err != nil { return fmt.Errorf("close temp file: %w", err) } - if err := os.Rename(tmpName, path); err != nil { + if err := atomicRename(tmpName, path); err != nil { return fmt.Errorf("rename to final: %w", err) } success = true diff --git a/internal/helpers/atomicwrite_coverage_more_test.go b/internal/helpers/atomicwrite_coverage_more_test.go new file mode 100644 index 00000000..02b7a598 --- /dev/null +++ b/internal/helpers/atomicwrite_coverage_more_test.go @@ -0,0 +1,80 @@ +package helpers + +import ( + "errors" + "io" + "os" + "path/filepath" + "strings" + "testing" +) + +type atomicFakeTemp struct { + chmodErr error + writeErr error + syncErr error + closeErr error +} + +func (*atomicFakeTemp) Name() string { return "fake.tmp" } +func (f *atomicFakeTemp) Chmod(os.FileMode) error { return f.chmodErr } +func (f *atomicFakeTemp) Write(p []byte) (int, error) { return len(p), f.writeErr } +func (f *atomicFakeTemp) Sync() error { return f.syncErr } +func (f *atomicFakeTemp) Close() error { return f.closeErr } + +type atomicErrorReader struct{} + +func (atomicErrorReader) Read([]byte) (int, error) { return 0, errors.New("read") } + +func TestAtomicWriteRemainingFailures(t *testing.T) { + origMkdir, origCreate := atomicMkdirAll, atomicCreateTemp + origRemove, origRename := atomicRemove, atomicRename + t.Cleanup(func() { + atomicMkdirAll, atomicCreateTemp = origMkdir, origCreate + atomicRemove, atomicRename = origRemove, origRename + }) + + atomicMkdirAll = func(string, os.FileMode) error { return errors.New("mkdir") } + if err := AtomicWrite("out/file", nil, 0o600); err == nil || !strings.Contains(err.Error(), "create directory") { + t.Fatalf("mkdir err=%v", err) + } + atomicMkdirAll = origMkdir + atomicCreateTemp = func(string, string) (atomicTempFile, error) { return nil, errors.New("create") } + if err := AtomicWrite(filepath.Join(t.TempDir(), "file"), nil, 0o600); err == nil || !strings.Contains(err.Error(), "create temp") { + t.Fatalf("create err=%v", err) + } + + for _, tc := range []struct { + name string + file *atomicFakeTemp + write func(atomicTempFile) error + want string + }{ + {"chmod", &atomicFakeTemp{chmodErr: errors.New("chmod")}, func(atomicTempFile) error { return nil }, "set permissions"}, + {"write", &atomicFakeTemp{}, func(atomicTempFile) error { return errors.New("write") }, "write data"}, + {"sync", &atomicFakeTemp{syncErr: errors.New("sync")}, func(atomicTempFile) error { return nil }, "sync to disk"}, + {"close", &atomicFakeTemp{closeErr: errors.New("close")}, func(atomicTempFile) error { return nil }, "close temp"}, + } { + t.Run(tc.name, func(t *testing.T) { + atomicCreateTemp = func(string, string) (atomicTempFile, error) { return tc.file, nil } + atomicRemove = func(string) error { return nil } + if err := atomicWrite(filepath.Join(t.TempDir(), "file"), 0o600, tc.write); err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("err=%v want %q", err, tc.want) + } + }) + } + + atomicCreateTemp = func(string, string) (atomicTempFile, error) { return &atomicFakeTemp{}, nil } + atomicRename = func(string, string) error { return errors.New("rename") } + if err := atomicWrite(filepath.Join(t.TempDir(), "file"), 0o600, func(atomicTempFile) error { return nil }); err == nil || !strings.Contains(err.Error(), "rename to final") { + t.Fatalf("rename err=%v", err) + } + + atomicCreateTemp, atomicRename, atomicRemove = origCreate, origRename, origRemove + if n, err := AtomicWriteFromReader(filepath.Join(t.TempDir(), "file"), atomicErrorReader{}, 0o600); err == nil || n != 0 { + t.Fatalf("reader failure n=%d err=%v", n, err) + } + + // Keep the io.Reader contract visible in this coverage-focused fake. + var _ io.Reader = atomicErrorReader{} +} diff --git a/internal/helpers/calendar_command_coverage_more_test.go b/internal/helpers/calendar_command_coverage_more_test.go new file mode 100644 index 00000000..df1ad194 --- /dev/null +++ b/internal/helpers/calendar_command_coverage_more_test.go @@ -0,0 +1,61 @@ +package helpers + +import ( + "os" + "testing" + + "github.com/spf13/cobra" +) + +func TestCalendarOptionalFlagsRemainingCoverage(t *testing.T) { + installScriptedCaller(t, &scriptedToolCaller{dry: true}) + start := "2030-01-01T09:00:00+08:00" + end := "2030-01-01T10:00:00+08:00" + if err := executeFilterCoverage(t, newCalendarCommand(), + "event", "create", "--title", "meeting", "--start", start, "--end", end, + "--rich-text-desc", "rich", "--location", "room", "--free-busy", "busy", + "--calendar-id", "calendar", "--remind-minutes", "5,10", + ); err != nil { + t.Fatalf("event create options: %v", err) + } + if err := executeFilterCoverage(t, newCalendarCommand(), "event", "respond", "--id", "event", "--status", "accepted", "--calendar-id", "calendar"); err != nil { + t.Fatalf("event response calendar: %v", err) + } + if err := executeFilterCoverage(t, newCalendarCommand(), "attendee", "list", "--event", "event", "--calendar-id", "calendar"); err != nil { + t.Fatalf("attendee list calendar: %v", err) + } + if err := executeFilterCoverage(t, newCalendarCommand(), "attendee", "add", "--event", "event"); err == nil { + t.Fatal("attendee add without users returned nil") + } + if err := executeFilterCoverage(t, newCalendarCommand(), "attendee", "add", "--event", "event", "--attendees", "u1", "--calendar-id", "calendar"); err != nil { + t.Fatalf("attendee add calendar: %v", err) + } + if err := executeFilterCoverage(t, newCalendarCommand(), "attendee", "delete", "--event", "event"); err == nil { + t.Fatal("attendee delete without users returned nil") + } + if err := executeFilterCoverage(t, newCalendarCommand(), "attendee", "delete", "--event", "event", "--attendees", "u1", "--calendar-id", "calendar"); err != nil { + t.Fatalf("attendee delete calendar: %v", err) + } + if err := executeFilterCoverage(t, newCalendarCommand(), "busy", "search", "--users", "u1", "--start", end, "--end", start); err == nil { + t.Fatal("reversed busy range returned nil") + } + if err := executeFilterCoverage(t, newCalendarCommand(), "attachment", "add", "--event", "event", "--files", "file:report.pdf", "--calendar-id", "calendar"); err != nil { + t.Fatalf("attachment calendar: %v", err) + } +} + +func TestCalendarUnknownFlagAndSuggestionRemainingCoverage(t *testing.T) { + root := &cobra.Command{Use: "calendar"} + group := &cobra.Command{Use: "room"} + group.SuggestionsMinimumDistance = 3 + group.AddCommand(&cobra.Command{Use: "search", SuggestFor: []string{"serach"}, Run: func(*cobra.Command, []string) {}}) + root.AddCommand(group) + installUnknownVerbFallback(group) + oldArgs := os.Args + os.Args = []string{"dws", "calendar", "room", "--unknown"} + t.Cleanup(func() { os.Args = oldArgs }) + if err := group.RunE(group, nil); err != nil { + t.Fatalf("unknown flag fallback: %v", err) + } + printUnknownSubcmdError(group, "serach") +} diff --git a/internal/helpers/chat_coverage_more_test.go b/internal/helpers/chat_coverage_more_test.go index b919d75c..e7813f1d 100644 --- a/internal/helpers/chat_coverage_more_test.go +++ b/internal/helpers/chat_coverage_more_test.go @@ -331,10 +331,7 @@ func TestChatFileUtilityCoverage(t *testing.T) { deps.Out.w = io.Discard deps.Out.errW = io.Discard t.Cleanup(func() { deps = previous }) - _ = printMCPText(`{"ok":true}`) - _ = printMCPText(`not-json`) caller.format = "raw" - _ = printMCPText(`{"ok":true}`) } func TestUploadConversationLocalFileCoverage(t *testing.T) { diff --git a/internal/helpers/chat_media_upload.go b/internal/helpers/chat_media_upload.go index 46ceb9cc..576dfee2 100644 --- a/internal/helpers/chat_media_upload.go +++ b/internal/helpers/chat_media_upload.go @@ -29,6 +29,14 @@ import ( "github.com/spf13/cobra" ) +var ( + chatMediaResolveAppToken = mediaResolveAppToken + chatMediaUploadFile = mediaUploadFile + mediaCreateFormFile = func(w *multipart.Writer, field, name string) (io.Writer, error) { return w.CreateFormFile(field, name) } + mediaOpenFile = os.Open + mediaCopyFile = io.Copy +) + func newChatMediaGroup() *cobra.Command { media := &cobra.Command{ Use: "media", @@ -72,12 +80,12 @@ func newChatMediaUploadCommand() *cobra.Command { ctx, cancel := context.WithTimeout(cmd.Context(), 2*time.Minute) defer cancel() - token, err := mediaResolveAppToken(ctx) + token, err := chatMediaResolveAppToken(ctx) if err != nil { return err } - mediaID, err := mediaUploadFile(ctx, token, filePath, mediaType) + mediaID, err := chatMediaUploadFile(ctx, token, filePath, mediaType) if err != nil { return err } @@ -102,10 +110,7 @@ func mediaResolveAppToken(ctx context.Context) (string, error) { "请使用 dws auth login --client-id --client-secret 登录。") } url := "https://oapi.dingtalk.com/gettoken?appkey=" + appKey + "&appsecret=" + appSecret - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - return "", err - } + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req) if err != nil { return "", apperrors.NewAuth("获取访问令牌失败: " + err.Error()) @@ -136,27 +141,24 @@ func mediaUploadFile(ctx context.Context, token, filePath, mediaType string) (st go func() { defer pw.Close() defer writer.Close() - part, err := writer.CreateFormFile("media", filepath.Base(filePath)) + part, err := mediaCreateFormFile(writer, "media", filepath.Base(filePath)) if err != nil { pw.CloseWithError(err) return } - f, err := os.Open(filePath) + f, err := mediaOpenFile(filePath) if err != nil { pw.CloseWithError(err) return } defer f.Close() - if _, err := io.Copy(part, f); err != nil { + if _, err := mediaCopyFile(part, f); err != nil { pw.CloseWithError(err) } }() url := "https://oapi.dingtalk.com/media/upload?access_token=" + token + "&type=" + mediaType - req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, pr) - if err != nil { - return "", err - } + req, _ := http.NewRequestWithContext(ctx, http.MethodPost, url, pr) req.Header.Set("Content-Type", writer.FormDataContentType()) resp, err := (&http.Client{Timeout: 2 * time.Minute}).Do(req) diff --git a/internal/helpers/chat_media_upload_coverage_more_test.go b/internal/helpers/chat_media_upload_coverage_more_test.go new file mode 100644 index 00000000..8b1e623b --- /dev/null +++ b/internal/helpers/chat_media_upload_coverage_more_test.go @@ -0,0 +1,87 @@ +package helpers + +import ( + "bytes" + "context" + "errors" + "io" + "mime/multipart" + "net/http" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestChatMediaUploadCommandRemainingCoverage(t *testing.T) { + file := filepath.Join(t.TempDir(), "image.png") + if err := os.WriteFile(file, []byte("image"), 0o600); err != nil { + t.Fatal(err) + } + if err := executeFilterCoverage(t, newChatMediaUploadCommand(), "--file", t.TempDir()); err == nil { + t.Fatal("directory upload returned nil") + } + + origResolve, origUpload := chatMediaResolveAppToken, chatMediaUploadFile + t.Cleanup(func() { + chatMediaResolveAppToken = origResolve + chatMediaUploadFile = origUpload + }) + chatMediaResolveAppToken = func(context.Context) (string, error) { return "", errors.New("token") } + if err := executeFilterCoverage(t, newChatMediaUploadCommand(), "--file", file); err == nil { + t.Fatal("token failure returned nil") + } + chatMediaResolveAppToken = func(context.Context) (string, error) { return "token", nil } + chatMediaUploadFile = func(context.Context, string, string, string) (string, error) { return "", errors.New("upload") } + if err := executeFilterCoverage(t, newChatMediaUploadCommand(), "--file", file); err == nil { + t.Fatal("upload failure returned nil") + } + var mediaType string + chatMediaUploadFile = func(_ context.Context, _, _, typ string) (string, error) { + mediaType = typ + return "media-id", nil + } + cmd := newChatMediaUploadCommand() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(io.Discard) + cmd.SetArgs([]string{"--file", file, "--type", " "}) + if err := cmd.Execute(); err != nil || mediaType != "image" || !strings.Contains(out.String(), "media-id") { + t.Fatalf("success type=%q output=%q err=%v", mediaType, out.String(), err) + } +} + +func TestMediaUploadMultipartWriterRemainingFailures(t *testing.T) { + file := filepath.Join(t.TempDir(), "image.png") + if err := os.WriteFile(file, []byte("image"), 0o600); err != nil { + t.Fatal(err) + } + origTransport := http.DefaultTransport + origCreate, origOpen, origCopy := mediaCreateFormFile, mediaOpenFile, mediaCopyFile + t.Cleanup(func() { + http.DefaultTransport = origTransport + mediaCreateFormFile, mediaOpenFile, mediaCopyFile = origCreate, origOpen, origCopy + }) + http.DefaultTransport = roundTripFunc(func(req *http.Request) (*http.Response, error) { + _, err := io.ReadAll(req.Body) + if err != nil { + return nil, err + } + return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"media_id":"id"}`)), Request: req}, nil + }) + + mediaCreateFormFile = func(*multipart.Writer, string, string) (io.Writer, error) { return nil, errors.New("part") } + if _, err := mediaUploadFile(context.Background(), "token", file, "image"); err == nil { + t.Fatal("form part failure returned nil") + } + mediaCreateFormFile = origCreate + mediaOpenFile = func(string) (*os.File, error) { return nil, errors.New("open") } + if _, err := mediaUploadFile(context.Background(), "token", file, "image"); err == nil { + t.Fatal("open failure returned nil") + } + mediaOpenFile = origOpen + mediaCopyFile = func(io.Writer, io.Reader) (int64, error) { return 0, errors.New("copy") } + if _, err := mediaUploadFile(context.Background(), "token", file, "image"); err == nil { + t.Fatal("copy failure returned nil") + } +} diff --git a/internal/helpers/common.go b/internal/helpers/common.go index 218852e2..b1c9adee 100644 --- a/internal/helpers/common.go +++ b/internal/helpers/common.go @@ -36,15 +36,8 @@ func commandBoolFlag(cmd *cobra.Command, name string) bool { if cmd == nil { return false } - root := cmd.Root() - var rootFlags *pflag.FlagSet - if root != nil { - rootFlags = root.PersistentFlags() - } + rootFlags := cmd.Root().PersistentFlags() for _, flags := range []*pflag.FlagSet{cmd.Flags(), cmd.InheritedFlags(), rootFlags} { - if flags == nil { - continue - } flag := flags.Lookup(name) if flag == nil { continue diff --git a/internal/helpers/connect_approval.go b/internal/helpers/connect_approval.go index 3741d171..3806b2ca 100644 --- a/internal/helpers/connect_approval.go +++ b/internal/helpers/connect_approval.go @@ -30,6 +30,18 @@ import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config" ) +var ( + approvalMkdirAll = os.MkdirAll + approvalCreateTemp = os.CreateTemp + approvalFileChmod = func(f *os.File, mode os.FileMode) error { return f.Chmod(mode) } + approvalFileWrite = func(f *os.File, data []byte) (int, error) { return f.Write(data) } + approvalFileClose = func(f *os.File) error { return f.Close() } + approvalRename = os.Rename + approvalRemove = os.Remove + approvalReadDir = os.ReadDir + approvalReadFile = os.ReadFile +) + // The "digital twin" confirmation gate. When someone @-mentions the bot with an // *action* request ("create a todo for me"), the bot does NOT execute it: it // records an ApprovalRequest, sends an interactive card with [Approve]/[Reject] @@ -416,7 +428,7 @@ func (g *approvalGate) persist(r *ApprovalRequest) { if dir == "" { return } - if err := os.MkdirAll(dir, 0o700); err != nil { + if err := approvalMkdirAll(dir, 0o700); err != nil { fmt.Fprintf(os.Stderr, "[connect][approval][warn] 创建审批目录失败,跳过落盘:%v\n", err) return } @@ -426,31 +438,31 @@ func (g *approvalGate) persist(r *ApprovalRequest) { return } path := filepath.Join(dir, sanitizeLockID(r.ID)+".json") - tmp, err := os.CreateTemp(dir, "approval-*.json.tmp") + tmp, err := approvalCreateTemp(dir, "approval-*.json.tmp") if err != nil { fmt.Fprintf(os.Stderr, "[connect][approval][warn] 创建审批临时文件失败,跳过落盘:%v\n", err) return } tmpName := tmp.Name() - if err := tmp.Chmod(config.FilePerm); err != nil { - tmp.Close() - _ = os.Remove(tmpName) + if err := approvalFileChmod(tmp, config.FilePerm); err != nil { + _ = approvalFileClose(tmp) + _ = approvalRemove(tmpName) fmt.Fprintf(os.Stderr, "[connect][approval][warn] 设置审批文件权限失败,跳过落盘:%v\n", err) return } - if _, err := tmp.Write(data); err != nil { - tmp.Close() - _ = os.Remove(tmpName) + if _, err := approvalFileWrite(tmp, data); err != nil { + _ = approvalFileClose(tmp) + _ = approvalRemove(tmpName) fmt.Fprintf(os.Stderr, "[connect][approval][warn] 写入审批临时文件失败,跳过落盘:%v\n", err) return } - if err := tmp.Close(); err != nil { - _ = os.Remove(tmpName) + if err := approvalFileClose(tmp); err != nil { + _ = approvalRemove(tmpName) fmt.Fprintf(os.Stderr, "[connect][approval][warn] 关闭审批临时文件失败,跳过落盘:%v\n", err) return } - if err := os.Rename(tmpName, path); err != nil { - _ = os.Remove(tmpName) + if err := approvalRename(tmpName, path); err != nil { + _ = approvalRemove(tmpName) fmt.Fprintf(os.Stderr, "[connect][approval][warn] 原子替换审批存档失败,跳过落盘:%v\n", err) } } @@ -463,7 +475,7 @@ func (g *approvalGate) loadAll() { if dir == "" { return } - entries, err := os.ReadDir(dir) + entries, err := approvalReadDir(dir) if err != nil { if !os.IsNotExist(err) { fmt.Fprintf(os.Stderr, "[connect][approval][warn] 读取审批目录失败,按空起:%v\n", err) @@ -474,7 +486,7 @@ func (g *approvalGate) loadAll() { if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") { continue } - raw, err := os.ReadFile(filepath.Join(dir, e.Name())) + raw, err := approvalReadFile(filepath.Join(dir, e.Name())) if err != nil { fmt.Fprintf(os.Stderr, "[connect][approval][warn] 读取审批存档 %s 失败,跳过:%v\n", e.Name(), err) continue diff --git a/internal/helpers/connect_approval_card.go b/internal/helpers/connect_approval_card.go index c7274bb0..f8086d93 100644 --- a/internal/helpers/connect_approval_card.go +++ b/internal/helpers/connect_approval_card.go @@ -49,6 +49,10 @@ const ( approvalDecisionReject = "reject" ) +var approvalOwnerDecide = func(g *approvalGate, id string, approve bool, decidedBy string) (*ApprovalRequest, bool) { + return g.Decide(id, approve, decidedBy) +} + // approvalCardSender delivers and updates the owner-facing confirmation card. // It is an interface so the orchestrator and its tests never touch the network: // the real sender talks to the DingTalk card API, the fake records calls. @@ -502,7 +506,7 @@ func (o *approvalOrchestrator) handleOwnerDecision(ctx context.Context, senderSt if req == nil { return false } - decided, deciding := o.gate.Decide(req.ID, approve, senderStaffID) + decided, deciding := approvalOwnerDecide(o.gate, req.ID, approve, senderStaffID) if !deciding { // Already decided (e.g. a double reply): consume the keyword, do not // re-execute. diff --git a/internal/helpers/connect_approval_card_coverage_more_test.go b/internal/helpers/connect_approval_card_coverage_more_test.go new file mode 100644 index 00000000..3ad24469 --- /dev/null +++ b/internal/helpers/connect_approval_card_coverage_more_test.go @@ -0,0 +1,199 @@ +package helpers + +import ( + "context" + "errors" + "strings" + "sync" + "testing" + "time" + + "github.com/open-dingtalk/dingtalk-stream-sdk-go/card" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor" +) + +type approvalSequenceRunner struct { + mu sync.Mutex + errors []error + index int +} + +func (r *approvalSequenceRunner) Run(_ context.Context, inv executor.Invocation) (executor.Result, error) { + r.mu.Lock() + defer r.mu.Unlock() + var err error + if r.index < len(r.errors) { + err = r.errors[r.index] + } + r.index++ + inv.Implemented = true + return executor.Result{Invocation: inv}, err +} + +type approvalErrorNotifier struct{ err error } + +func (n approvalErrorNotifier) sendOTOText(context.Context, []string, string) error { return n.err } + +type approvalUnimplementedRunner struct{} + +func (approvalUnimplementedRunner) Run(_ context.Context, inv executor.Invocation) (executor.Result, error) { + return executor.Result{Invocation: inv}, nil +} + +func TestSheetAuditSinkRemainingEdges(t *testing.T) { + originalSleep := helperSleep + helperSleep = func(time.Duration) {} + defer func() { helperSleep = originalSleep }() + + (&sheetAuditSink{}).record(context.Background(), nil) + req := &ApprovalRequest{ + ID: "id", Summary: "summary", Requester: "requester", State: approvalExecuted, + AutoApproved: true, CreatedAt: time.Now(), DecidedAt: time.Now(), DecidedBy: "owner", + } + (&sheetAuditSink{runner: &approvalSequenceRunner{}}).record(context.Background(), req) + (&sheetAuditSink{runner: &approvalSequenceRunner{errors: []error{errors.New("THREADPOOL_BUSY"), nil}}}).record(context.Background(), req) + (&sheetAuditSink{runner: &approvalSequenceRunner{errors: []error{errors.New("permanent")}}}).record(context.Background(), req) +} + +func TestApprovalDecisionAndReplyRemainingEdges(t *testing.T) { + o := &approvalOrchestrator{ownerUserID: "owner", confirmPolicy: "remember", remembered: map[string]bool{"todo.create": false}} + if got := o.gateDecision("requester", "todo.create"); got != "reject" { + t.Fatalf("decision=%q", got) + } + if got, handled := (&approvalOrchestrator{}).handleReply(context.Background(), "r", "c", "plain", nil); handled || got != "plain" { + t.Fatalf("disabled reply=(%q,%v)", got, handled) + } + + gate := newApprovalGate("") + o = newTextApprovalOrchestrator(gate, &fakeRunner{}, "owner", &fakeNotifier{}) + if got, handled := o.handleReply(context.Background(), "r", "c", "plain", nil); handled || got != "plain" { + t.Fatalf("plain action=(%q,%v)", got, handled) + } + if got, handled := o.handleReply(context.Background(), "r", "c", `before [[ACTION:todo.create]] after`, nil); handled || strings.Contains(got, "[[ACTION") { + t.Fatalf("unknown action=(%q,%v)", got, handled) + } + o.confirmPolicy = "remember" + o.remembered = map[string]bool{"todo.create": false} + if got, handled := o.handleReply(context.Background(), "r", "c", `[[ACTION:todo.create title="x"]]`, nil); handled || !strings.Contains(got, "此前已拒绝") { + t.Fatalf("remember reject=(%q,%v)", got, handled) + } + + missing := &ApprovalRequest{ID: "missing", Summary: "x", Action: plannedAction{Product: "todo", Tool: "tool"}} + o.autoApproveAndExecute(context.Background(), missing, "c", nil) + o.notifier = nil + o.notifyOwnerDeferred(context.Background(), missing, errors.New("ignored")) + + o.notifier = approvalErrorNotifier{err: errors.New("notify")} + if got, handled := o.handleReplyText(context.Background(), &ApprovalRequest{ID: "id", Summary: "x"}); !handled || got != "" { + t.Fatalf("text notify error=(%q,%v)", got, handled) + } + if o.handleOwnerDecision(context.Background(), "owner", "同意") { + t.Fatal("decision without pending was consumed") + } +} + +func TestApprovalConcurrentDecisionSeamEdge(t *testing.T) { + original := approvalOwnerDecide + defer func() { approvalOwnerDecide = original }() + gate := newApprovalGate("") + notifier := &fakeNotifier{} + o := newTextApprovalOrchestrator(gate, &fakeRunner{}, "owner", notifier) + req := gate.Submit(ApprovalRequest{Requester: "r", Verb: "todo.create", Summary: "x"}) + approvalOwnerDecide = func(*approvalGate, string, bool, string) (*ApprovalRequest, bool) { return req, false } + if !o.handleOwnerDecision(context.Background(), "owner", "同意") { + t.Fatal("already decided keyword was not consumed") + } +} + +func TestApprovalFlushAndExecuteRemainingEdges(t *testing.T) { + gate := newApprovalGate("") + notifier := &fakeNotifier{} + o := newTextApprovalOrchestrator(gate, &fakeRunner{}, "owner", notifier) + if !o.flushDeferred(context.Background()) { + t.Fatal("empty flush was not consumed") + } + + stuckRunner := &fakeRunner{fail: true} + o = newTextApprovalOrchestrator(gate, stuckRunner, "owner", notifier) + req := gate.Submit(ApprovalRequest{Requester: "r", Summary: "stuck", Action: plannedAction{Product: "todo", Tool: "tool"}}) + gate.Decide(req.ID, true, "owner") + gate.markDeferred(req.ID, "old") + if !o.flushDeferred(context.Background()) { + t.Fatal("stuck flush was not consumed") + } + + if _, err := (&approvalOrchestrator{}).execute(context.Background(), req); err == nil { + t.Fatal("nil runner returned nil") + } + o.runner = approvalUnimplementedRunner{} + if got, err := o.execute(context.Background(), req); err != nil || !strings.Contains(got, "未实际执行") { + t.Fatalf("unimplemented=%q err=%v", got, err) + } +} + +func TestApprovalCardFlowRemainingEdges(t *testing.T) { + reply := func(context.Context, string, string) error { return nil } + req := ApprovalRequest{Requester: "r", Summary: "x", Action: plannedAction{Product: "todo", Tool: "tool"}} + + gate := newApprovalGate("") + pending := gate.Submit(req) + o := newApprovalOrchestrator(gate, &fakeCardSender{failSend: true}, &fakeRunner{}, "owner") + if got, handled := o.handleReplyCard(context.Background(), pending, "c", reply); handled || !strings.Contains(got, "发送失败") { + t.Fatalf("send fail=(%q,%v)", got, handled) + } + + gate = newApprovalGate("") + pending = gate.Submit(req) + o = newApprovalOrchestrator(gate, &fakeCardSender{}, &fakeRunner{}, "owner") + o.awaitWindow = time.Nanosecond + if _, handled := o.handleReplyCard(context.Background(), pending, "c", reply); !handled { + t.Fatal("timeout was not handled") + } + + gate = newApprovalGate("") + pending = gate.Submit(req) + gate.Decide(pending.ID, true, "owner") + o = newApprovalOrchestrator(gate, &fakeCardSender{}, &fakeRunner{fail: true}, "owner") + if _, handled := o.handleReplyCard(context.Background(), pending, "c", reply); !handled { + t.Fatal("execution failure was not handled") + } +} + +func TestApprovalCardCallbackRemainingEdges(t *testing.T) { + var nilOrchestrator *approvalOrchestrator + if _, err := nilOrchestrator.handleCardCallback(context.Background(), nil); err != nil { + t.Fatal(err) + } + gate := newApprovalGate("") + o := newApprovalOrchestrator(gate, &fakeCardSender{}, &fakeRunner{}, "owner") + if _, err := o.handleCardCallback(context.Background(), &card.CardRequest{OutTrackId: "malformed"}); err != nil { + t.Fatal(err) + } + noAssociation := cardClickReq("missing", "", approvalDecisionApprove, approvalActionApprove, "owner") + if _, err := o.handleCardCallback(context.Background(), noAssociation); err != nil { + t.Fatal(err) + } + + req := gate.Submit(ApprovalRequest{Summary: "x"}) + gate.Decide(req.ID, false, "owner") + duplicate := cardClickReq("", req.ID, approvalDecisionReject, approvalActionReject, "owner") + if response, err := o.handleCardCallback(context.Background(), duplicate); err != nil || response.UserPrivateData.CardParamMap["dwsDecision"] != "拒绝" { + t.Fatalf("duplicate response=%+v err=%v", response, err) + } + + fallbackReject := &card.CardRequest{CardActionData: card.PrivateCardActionData{CardPrivateData: card.CardPrivateData{ActionIdList: []string{approvalActionReject}, Params: map[string]any{approvalParamID: "id"}}}} + if id, approve, ok := decodeCardAction(fallbackReject); !ok || approve || id != "id" { + t.Fatalf("fallback reject=(%q,%v,%v)", id, approve, ok) + } +} + +func TestDingTalkApprovalCardDeliveryFailureEdge(t *testing.T) { + recorder, server := newCardAPIServer(t) + withCardAPIBase(t, server.URL) + recorder.fail["POST /v1.0/card/instances/deliver"] = 500 + sender := newDingtalkApprovalCardSender("client", "secret", "template") + if _, err := sender.SendApprovalCard(context.Background(), "owner", &ApprovalRequest{ID: "id", Summary: "x"}); err == nil { + t.Fatal("delivery failure returned nil") + } +} diff --git a/internal/helpers/connect_approval_coverage_more_test.go b/internal/helpers/connect_approval_coverage_more_test.go new file mode 100644 index 00000000..97a53340 --- /dev/null +++ b/internal/helpers/connect_approval_coverage_more_test.go @@ -0,0 +1,177 @@ +package helpers + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func installApprovalIODefaults(t *testing.T) func() { + t.Helper() + originalMkdirAll := approvalMkdirAll + originalCreateTemp := approvalCreateTemp + originalChmod := approvalFileChmod + originalWrite := approvalFileWrite + originalClose := approvalFileClose + originalRename := approvalRename + originalRemove := approvalRemove + originalReadDir := approvalReadDir + originalReadFile := approvalReadFile + t.Cleanup(func() { + approvalMkdirAll = originalMkdirAll + approvalCreateTemp = originalCreateTemp + approvalFileChmod = originalChmod + approvalFileWrite = originalWrite + approvalFileClose = originalClose + approvalRename = originalRename + approvalRemove = originalRemove + approvalReadDir = originalReadDir + approvalReadFile = originalReadFile + }) + return func() { + approvalMkdirAll = os.MkdirAll + approvalCreateTemp = os.CreateTemp + approvalFileChmod = func(f *os.File, mode os.FileMode) error { return f.Chmod(mode) } + approvalFileWrite = func(f *os.File, data []byte) (int, error) { return f.Write(data) } + approvalFileClose = func(f *os.File) error { return f.Close() } + approvalRename = os.Rename + approvalRemove = os.Remove + approvalReadDir = os.ReadDir + approvalReadFile = os.ReadFile + } +} + +func TestApprovalGateLookupMutationAndAwaitEdges(t *testing.T) { + g := newApprovalGate("") + g.setOutTrackID("missing", "track") + if got := g.findByOutTrackID(" "); got != nil { + t.Fatalf("blank track=%v", got) + } + if got := g.pendingForConv(" "); got != nil { + t.Fatalf("blank conversation=%v", got) + } + if got := g.latestPending(); got != nil { + t.Fatalf("empty latest=%v", got) + } + if got := g.Get("missing"); got != nil { + t.Fatalf("missing get=%v", got) + } + if got, ok := g.Await(context.Background(), "missing", time.Millisecond); ok || got != nil { + t.Fatalf("missing await=(%v,%v)", got, ok) + } + + first := g.Submit(ApprovalRequest{ID: "first", CreatedAt: time.Unix(1, 0)}) + g.Decide(first.ID, true, "owner") + if got, ok := g.Await(context.Background(), first.ID, 0); !ok || got == nil { + t.Fatalf("decided await=(%v,%v)", got, ok) + } + g.markFailed(first.ID, strings.Repeat("failure", 100)) + if got := g.Get(first.ID); got.State != approvalFailed || got.ExecErr == "" { + t.Fatalf("failed state=%+v", got) + } + + pending := g.Submit(ApprovalRequest{ID: "pending"}) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if got, ok := g.Await(ctx, pending.ID, 0); ok || got == nil { + t.Fatalf("cancelled await=(%v,%v)", got, ok) + } + + older := g.Submit(ApprovalRequest{ID: "older", CreatedAt: time.Unix(2, 0)}) + newer := g.Submit(ApprovalRequest{ID: "newer", CreatedAt: time.Unix(3, 0)}) + g.Decide(older.ID, true, "owner") + g.Decide(newer.ID, true, "owner") + g.markDeferred(newer.ID, "x") + g.markDeferred(older.ID, "x") + deferred := g.allDeferred() + if len(deferred) != 2 || deferred[0].ID != "older" { + t.Fatalf("deferred order=%v", deferred) + } +} + +func TestApprovalGatePersistFailureEdges(t *testing.T) { + t.Setenv("DWS_CONFIG_DIR", t.TempDir()) + defaults := installApprovalIODefaults(t) + g := &approvalGate{clientID: "io"} + req := &ApprovalRequest{ID: "id", CreatedAt: time.Now()} + boom := errors.New("io") + + approvalMkdirAll = func(string, os.FileMode) error { return boom } + g.persist(req) + defaults() + + bad := *req + bad.ID = "marshal" + bad.Action.Params = map[string]any{"bad": make(chan int)} + g.persist(&bad) + + approvalCreateTemp = func(string, string) (*os.File, error) { return nil, boom } + g.persist(req) + defaults() + + approvalFileChmod = func(*os.File, os.FileMode) error { return boom } + g.persist(req) + defaults() + + approvalFileWrite = func(*os.File, []byte) (int, error) { return 0, boom } + g.persist(req) + defaults() + + approvalFileClose = func(f *os.File) error { _ = f.Close(); return boom } + g.persist(req) + defaults() + + approvalRename = func(string, string) error { return boom } + g.persist(req) +} + +func TestApprovalGateLoadRemainingEdges(t *testing.T) { + t.Setenv("DWS_CONFIG_DIR", t.TempDir()) + defaults := installApprovalIODefaults(t) + g := &approvalGate{clientID: "load", reqs: make(map[string]*ApprovalRequest), waiters: make(map[string]chan struct{})} + boom := errors.New("read dir") + approvalReadDir = func(string) ([]os.DirEntry, error) { return nil, boom } + g.loadAll() + defaults() + + dir := g.approvalDir() + if err := os.MkdirAll(filepath.Join(dir, "subdir"), 0o700); err != nil { + t.Fatal(err) + } + files := map[string]string{ + "note.txt": "ignored", + "corrupt.json": "{", + "blank.json": `{"id":" "}`, + "read-error.json": `{"id":"unread"}`, + "valid.json": `{"id":"valid","state":"pending"}`, + } + for name, body := range files { + if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o600); err != nil { + t.Fatal(err) + } + } + approvalReadFile = func(path string) ([]byte, error) { + if strings.HasSuffix(path, "read-error.json") { + return nil, errors.New("read") + } + return os.ReadFile(path) + } + g.loadAll() + if g.Get("valid") == nil || g.Get("unread") != nil { + t.Fatalf("loaded=%v", g.reqs) + } +} + +func TestApprovalMarkerAndDueRemainingEdges(t *testing.T) { + act, cleaned, found := parseActionMarker(`before [[ACTION:todo.create title='single quoted']] after`) + if !found || act.Args["title"] != "single quoted" || strings.Contains(cleaned, "ACTION") { + t.Fatalf("action=%+v cleaned=%q found=%v", act, cleaned, found) + } + if _, err := parseDueToMillis("not-a-date"); err == nil { + t.Fatal("invalid due returned nil") + } +} diff --git a/internal/helpers/connect_at_poll.go b/internal/helpers/connect_at_poll.go index 85e9772f..4d60e5f9 100644 --- a/internal/helpers/connect_at_poll.go +++ b/internal/helpers/connect_at_poll.go @@ -32,6 +32,15 @@ const atPollInterval = 5 * time.Second // atPollWindow is how far back we look on each poll tick. const atPollWindow = 30 * time.Second +var ( + atPollExecutable = os.Executable + atPollCommandContext = exec.CommandContext + atPollTicker = func(interval time.Duration) (<-chan time.Time, func()) { + ticker := time.NewTicker(interval) + return ticker.C, ticker.Stop + } +) + // atMentionPoller periodically queries the "search_at_me_message" API // using the logged-in user's token and feeds new messages into the // connect forwarder — supplementing the Stream callback which DingTalk @@ -66,13 +75,13 @@ func (p *atMentionPoller) start(ctx context.Context) { return } fmt.Fprintf(os.Stderr, "[connect][at-poll] 已启动 @消息轮询(间隔 %s)\n", atPollInterval) - ticker := time.NewTicker(atPollInterval) - defer ticker.Stop() + ticks, stopTicker := atPollTicker(atPollInterval) + defer stopTicker() for { select { case <-ctx.Done(): return - case <-ticker.C: + case <-ticks: p.poll(ctx) } } @@ -80,7 +89,7 @@ func (p *atMentionPoller) start(ctx context.Context) { } func (p *atMentionPoller) poll(ctx context.Context) { - exe, err := os.Executable() + exe, err := atPollExecutable() if err != nil { return } @@ -88,7 +97,7 @@ func (p *atMentionPoller) poll(ctx context.Context) { start := now.Add(-atPollWindow).Format("2006-01-02T15:04:05+08:00") end := now.Format("2006-01-02T15:04:05+08:00") - cmd := exec.CommandContext(ctx, exe, "chat", "message", "list-mentions", + cmd := atPollCommandContext(ctx, exe, "chat", "message", "list-mentions", "--start", start, "--end", end, "--limit", "50", @@ -255,20 +264,3 @@ func extractAtPollText(content, contentType string) string { } return content } - -// Keep-alive references: this poller was introduced alongside the connect -// daemon work but is pending wiring into the daemon startup path. Remove -// this block once atMentionPoller is instantiated by the daemon (or delete -// the file if the feature is abandoned). -var _ = func() any { - _ = atPollInterval - _ = atPollWindow - _ = atMentionMessage{} - p := (*atMentionPoller)(nil) - _ = p.start - _ = p.poll - _ = p.handleMessage - _ = p.sendGroupReply - _ = extractAtPollText - return nil -} diff --git a/internal/helpers/connect_at_poll_coverage_more_test.go b/internal/helpers/connect_at_poll_coverage_more_test.go new file mode 100644 index 00000000..7db88ff6 --- /dev/null +++ b/internal/helpers/connect_at_poll_coverage_more_test.go @@ -0,0 +1,220 @@ +package helpers + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "os/exec" + "sync" + "testing" + "time" +) + +type atPollSignalForwarder struct { + mu sync.Mutex + reply string + err error + delay time.Duration + prompts []string + done chan struct{} +} + +func (f *atPollSignalForwarder) forward(_ context.Context, _ string, text string) (string, error) { + if f.delay > 0 { + time.Sleep(f.delay) + } + f.mu.Lock() + f.prompts = append(f.prompts, text) + f.mu.Unlock() + select { + case f.done <- struct{}{}: + default: + } + return f.reply, f.err +} + +func (*atPollSignalForwarder) label() string { return "poll-test" } + +type atPollStreamingForwarder struct{ *atPollSignalForwarder } + +func (*atPollStreamingForwarder) canStream() bool { return true } +func (f *atPollStreamingForwarder) forwardStream(ctx context.Context, convID, text string, _ func(string)) (string, error) { + return f.forward(ctx, convID, text) +} + +func waitAtPollForward(t *testing.T, f *atPollSignalForwarder) { + t.Helper() + select { + case <-f.done: + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for at-poll forward") + } +} + +func newAtPollForTest(fwd forwarder, extras *connectExtras) *atMentionPoller { + return &atMentionPoller{ + clientID: "client", botClient: newAICardClient("client", "secret", ""), + fwd: fwd, queue: newConvQueue(), dedup: newMsgDedup(20), + health: nil, extras: extras, channel: "test", + } +} + +func TestAtMentionPollerStartAndPollCoverage(t *testing.T) { + origAfter := helperAfter + origExecutable := atPollExecutable + origCommand := atPollCommandContext + origTicker := atPollTicker + ticks, stopTicker := origTicker(time.Hour) + _ = ticks + stopTicker() + t.Cleanup(func() { + helperAfter = origAfter + atPollExecutable = origExecutable + atPollCommandContext = origCommand + atPollTicker = origTicker + }) + helperAfter = func(time.Duration) <-chan time.Time { + ch := make(chan time.Time, 1) + ch <- time.Now() + return ch + } + atPollTicker = func(time.Duration) (<-chan time.Time, func()) { + ch := make(chan time.Time, 1) + ch <- time.Now() + return ch, func() {} + } + ctx, cancel := context.WithCancel(context.Background()) + p := newAtPollForTest(&atPollSignalForwarder{done: make(chan struct{}, 1)}, &connectExtras{}) + p.start(ctx) + time.Sleep(10 * time.Millisecond) + cancel() + time.Sleep(10 * time.Millisecond) + + atPollExecutable = func() (string, error) { return "", errors.New("executable") } + p.poll(context.Background()) + atPollExecutable = func() (string, error) { return "test", nil } + atPollCommandContext = func(context.Context, string, ...string) *exec.Cmd { + return exec.Command("sh", "-c", "exit 3") + } + p.poll(context.Background()) + + outputs := []string{ + `{`, + `{"success":false}`, + `{"success":true,"result":{"items":[{"msgId":""},{"msgId":"dup","conversationType":"2"},{"msgId":"dup","conversationType":"2"},{"msgId":"other","conversationType":"1"},{"msgId":"valid","conversationType":"2","content":""}]}}`, + } + p.dedup.first("dup") + for _, output := range outputs { + value := output + atPollCommandContext = func(context.Context, string, ...string) *exec.Cmd { + cmd := exec.Command("sh", "-c", "printf %s \"$AT_POLL_OUTPUT\"") + cmd.Env = append(cmd.Environ(), "AT_POLL_OUTPUT="+value) + return cmd + } + p.poll(context.Background()) + } +} + +func TestAtMentionPollerHandleCoverage(t *testing.T) { + base := atMentionMessage{MsgID: "m", OpenConversationID: "conv", SenderStaffID: "user", SenderNick: "nick", Content: `{"text":"hello"}`, ConversationType: "2"} + + p := newAtPollForTest(&atPollSignalForwarder{done: make(chan struct{}, 1)}, &connectExtras{}) + empty := base + empty.Content = "" + p.handleMessage(context.Background(), empty) + + denied := &atPollSignalForwarder{done: make(chan struct{}, 1)} + p = newAtPollForTest(denied, &connectExtras{gate: newConnectGate([]string{"other"}, nil, 0)}) + p.handleMessage(context.Background(), base) + time.Sleep(20 * time.Millisecond) + select { + case <-denied.done: + t.Fatal("denied message reached forwarder") + default: + } + + plain := &atPollSignalForwarder{done: make(chan struct{}, 2)} + kb := &knowledgeBase{chunks: []knowledgeChunk{{text: "hello handbook", terms: map[string]int{"hello": 1}}}} + p = newAtPollForTest(plain, &connectExtras{kb: kb, persona: " persona "}) + fallback := base + fallback.SenderNick = "" + fallback.OpenConversationID = "" + p.handleMessage(context.Background(), fallback) + waitAtPollForward(t, plain) + + failing := &atPollSignalForwarder{err: errors.New("agent failed"), done: make(chan struct{}, 1)} + p = newAtPollForTest(failing, &connectExtras{}) + p.handleMessage(context.Background(), fallback) + waitAtPollForward(t, failing) + + origBase := dingtalkCardAPIBase + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/v1.0/oauth2/accessToken" { + _, _ = io.WriteString(w, `{"accessToken":"token","expireIn":7200}`) + return + } + http.Error(w, "send failed", http.StatusBadRequest) + })) + dingtalkCardAPIBase = server.URL + t.Cleanup(func() { + dingtalkCardAPIBase = origBase + server.Close() + }) + streamBase := &atPollSignalForwarder{reply: "reply", done: make(chan struct{}, 2)} + stream := &atPollStreamingForwarder{atPollSignalForwarder: streamBase} + p = newAtPollForTest(stream, &connectExtras{}) + p.handleMessage(context.Background(), base) + waitAtPollForward(t, streamBase) + + slow := &atPollSignalForwarder{delay: 30 * time.Millisecond, done: make(chan struct{}, 4)} + p = newAtPollForTest(slow, &connectExtras{}) + p.handleMessage(context.Background(), base) + second := base + second.MsgID = "m2" + second.Content = `{"text":"second"}` + third := base + third.MsgID = "m3" + third.Content = `{"text":"third"}` + p.handleMessage(context.Background(), second) + p.handleMessage(context.Background(), third) + waitAtPollForward(t, slow) + waitAtPollForward(t, slow) +} + +func TestAtMentionPollerSendGroupReplyCoverage(t *testing.T) { + origBase := dingtalkCardAPIBase + t.Cleanup(func() { dingtalkCardAPIBase = origBase }) + p := newAtPollForTest(&atPollSignalForwarder{done: make(chan struct{}, 1)}, &connectExtras{}) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v1.0/oauth2/accessToken": + _, _ = io.WriteString(w, `{"accessToken":"token","expireIn":7200}`) + case "/v1.0/robot/groupMessages/send": + w.WriteHeader(http.StatusNoContent) + } + })) + dingtalkCardAPIBase = server.URL + if err := p.sendGroupReply(context.Background(), "conv", "reply"); err != nil { + t.Fatalf("send success: %v", err) + } + server.Close() + if err := p.sendGroupReply(context.Background(), "conv", "reply"); err == nil { + t.Fatal("transport error returned nil") + } + + p.botClient.token = "token" + p.botClient.tokenExp = time.Now().Add(time.Hour) + dingtalkCardAPIBase = "%" + if err := p.sendGroupReply(context.Background(), "conv", "reply"); err == nil { + t.Fatal("invalid request URL returned nil") + } + + p.botClient.token = "" + p.botClient.tokenExp = time.Time{} + if err := p.sendGroupReply(context.Background(), "conv", "reply"); err == nil { + t.Fatal("token request error returned nil") + } +} diff --git a/internal/helpers/connect_card.go b/internal/helpers/connect_card.go index f1097aff..fbc82d32 100644 --- a/internal/helpers/connect_card.go +++ b/internal/helpers/connect_card.go @@ -119,13 +119,10 @@ func (c *aiCardClient) accessToken(ctx context.Context) (string, error) { if c.token != "" && time.Now().Before(c.tokenExp.Add(-5*time.Minute)) { return c.token, nil } - body, err := json.Marshal(map[string]string{ + body, _ := json.Marshal(map[string]string{ "appKey": c.clientID, "appSecret": c.clientSecret, }) - if err != nil { - return "", err - } req, err := http.NewRequestWithContext(ctx, http.MethodPost, dingtalkCardAPIBase+"/v1.0/oauth2/accessToken", bytes.NewReader(body)) if err != nil { @@ -211,10 +208,7 @@ func (c *aiCardClient) sendOTOText(ctx context.Context, userIDs []string, text s if len(userIDs) == 0 { return nil } - param, err := json.Marshal(map[string]string{"content": text}) - if err != nil { - return err - } + param, _ := json.Marshal(map[string]string{"content": text}) return c.call(ctx, http.MethodPost, "/v1.0/robot/oToMessages/batchSend", map[string]any{ "robotCode": c.clientID, "userIds": userIDs, @@ -304,6 +298,8 @@ func (c *aiCardClient) streamingUpdate(ctx context.Context, card *aiCardInstance // a short gap lets the client subscribe before the closing frame lands. var aiCardFrameGap = 500 * time.Millisecond +var aiCardSleepCtx = sleepCtx + // cardContentParams is the cardParamMap contract of the openclaw template. func cardContentParams(flowStatus, content string) map[string]any { return map[string]any{ @@ -365,7 +361,7 @@ func (c *aiCardClient) finish(ctx context.Context, card *aiCardInstance, content if err := c.streamFrame(ctx, card, content, true); err != nil { return err } - if err := sleepCtx(ctx, aiCardFrameGap); err != nil { + if err := aiCardSleepCtx(ctx, aiCardFrameGap); err != nil { return err } return c.setFlowStatus(ctx, card, aiCardFlowFinished, normalized, true) @@ -374,7 +370,7 @@ func (c *aiCardClient) finish(ctx context.Context, card *aiCardInstance, content // finalize is the one-shot path for channels without incremental output: // INPUTING → final frame → FINISHED in one call. func (c *aiCardClient) finalize(ctx context.Context, card *aiCardInstance, content string) error { - if err := sleepCtx(ctx, aiCardFrameGap); err != nil { + if err := aiCardSleepCtx(ctx, aiCardFrameGap); err != nil { return err } return c.finish(ctx, card, content) @@ -385,7 +381,7 @@ func (c *aiCardClient) finalize(ctx context.Context, card *aiCardInstance, conte // next; our burst of two frames has no such retry, and a missed fetch shows // "内容加载失败" until a new frame arrives. Best-effort by design. func (c *aiCardClient) repair(ctx context.Context, card *aiCardInstance, content string) { - if err := sleepCtx(ctx, aiCardRepairDelay); err != nil { + if err := aiCardSleepCtx(ctx, aiCardRepairDelay); err != nil { return } _ = c.setFlowStatus(ctx, card, aiCardFlowFinished, normalizeForCard(content), true) diff --git a/internal/helpers/connect_card_coverage_more_test.go b/internal/helpers/connect_card_coverage_more_test.go new file mode 100644 index 00000000..0b0fb0a7 --- /dev/null +++ b/internal/helpers/connect_card_coverage_more_test.go @@ -0,0 +1,160 @@ +package helpers + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/open-dingtalk/dingtalk-stream-sdk-go/chatbot" +) + +func TestAICardAccessTokenRemainingEdges(t *testing.T) { + originalBase := dingtalkCardAPIBase + defer func() { dingtalkCardAPIBase = originalBase }() + + client := newAICardClient("client", "secret", "template") + client.httpClient = &http.Client{Transport: mailRoundTripFunc(func(*http.Request) (*http.Response, error) { + return nil, errors.New("transport") + })} + dingtalkCardAPIBase = "https://api.invalid" + if _, err := client.accessToken(context.Background()); err == nil { + t.Fatal("token transport error returned nil") + } + + for _, tc := range []struct { + name string + status int + body string + ok bool + }{ + {"http", http.StatusInternalServerError, "failure", false}, + {"invalid-json", http.StatusOK, "{", false}, + {"empty-token", http.StatusOK, `{}`, false}, + {"default-expiry", http.StatusOK, `{"accessToken":"token","expireIn":0}`, true}, + } { + t.Run(tc.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(tc.status) + _, _ = w.Write([]byte(tc.body)) + })) + defer server.Close() + dingtalkCardAPIBase = server.URL + client := newAICardClient("client", "secret", "template") + got, err := client.accessToken(context.Background()) + if tc.ok && (err != nil || got != "token") { + t.Fatalf("token=%q err=%v", got, err) + } + if !tc.ok && err == nil { + t.Fatal("error case returned nil") + } + }) + } +} + +func TestAICardCallRawAndCheckedRemainingEdges(t *testing.T) { + originalBase := dingtalkCardAPIBase + defer func() { dingtalkCardAPIBase = originalBase }() + cached := func() *aiCardClient { + c := newAICardClient("client", "secret", "template") + c.token = "token" + c.tokenExp = time.Now().Add(time.Hour) + return c + } + + c := cached() + if _, err := c.callRaw(context.Background(), http.MethodPost, "/path", map[string]any{"bad": make(chan int)}); err == nil { + t.Fatal("payload marshal error returned nil") + } + dingtalkCardAPIBase = "%" + if _, err := newAICardClient("client", "secret", "template").callRaw(context.Background(), http.MethodGet, "/path", nil); err == nil { + t.Fatal("access token error returned nil") + } + if _, err := cached().callRaw(context.Background(), http.MethodGet, "/path", nil); err == nil { + t.Fatal("request construction error returned nil") + } + if err := cached().callChecked(context.Background(), http.MethodGet, "/path", nil); err == nil { + t.Fatal("checked transport error returned nil") + } + + dingtalkCardAPIBase = "https://api.invalid" + c = cached() + c.httpClient = &http.Client{Transport: mailRoundTripFunc(func(*http.Request) (*http.Response, error) { + return nil, errors.New("transport") + })} + if _, err := c.callRaw(context.Background(), http.MethodGet, "/path", nil); err == nil { + t.Fatal("call transport error returned nil") + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"success":false,"errorMsg":"bad"}`)) + })) + defer server.Close() + dingtalkCardAPIBase = server.URL + if err := cached().callChecked(context.Background(), http.MethodPost, "/deliver", nil); err == nil { + t.Fatal("business failure returned nil") + } +} + +func TestAICardDeliveryStreamingAndSleepEdges(t *testing.T) { + recorder, server := newCardAPIServer(t) + withCardAPIBase(t, server.URL) + c := newAICardClient("client", "secret", defaultAICardTemplateID) + recorder.fail["POST /v1.0/card/instances/deliver"] = 500 + if _, err := c.createAndDeliver(context.Background(), groupCallback()); err == nil { + t.Fatal("deliver error returned nil") + } + recorder.fail = map[string]int{} + if _, err := c.createAndDeliver(context.Background(), &chatbot.BotCallbackDataModel{}); err == nil { + t.Fatal("one-to-one missing sender returned nil") + } + + long := strings.Repeat("界", aiCardMaxContent+1) + if err := c.streamingUpdate(context.Background(), &aiCardInstance{outTrackID: "track"}, long, false, false); err != nil { + t.Fatalf("long streaming update: %v", err) + } + if err := sleepCtx(context.Background(), time.Nanosecond); err != nil { + t.Fatalf("timer sleep: %v", err) + } + + originalGap := aiCardFrameGap + aiCardFrameGap = time.Hour + defer func() { aiCardFrameGap = originalGap }() + cancelled, cancel := context.WithCancel(context.Background()) + cancel() + if err := c.finalize(cancelled, &aiCardInstance{outTrackID: "track"}, "content"); !errors.Is(err, context.Canceled) { + t.Fatalf("finalize cancel=%v", err) + } + + finishCtx := context.Background() + finishServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{}`)) + })) + defer finishServer.Close() + dingtalkCardAPIBase = finishServer.URL + finishClient := newAICardClient("client", "secret", "template") + finishClient.token, finishClient.tokenExp = "token", time.Now().Add(time.Hour) + originalSleep := aiCardSleepCtx + aiCardSleepCtx = func(context.Context, time.Duration) error { return context.Canceled } + defer func() { aiCardSleepCtx = originalSleep }() + if err := finishClient.finish(finishCtx, &aiCardInstance{outTrackID: "track", inputing: true}, "content"); !errors.Is(err, context.Canceled) { + t.Fatalf("finish cancel=%v", err) + } +} + +func TestCardMarkdownRemainingEdges(t *testing.T) { + table := "intro\n| A | B |\n|---|---|\nend" + if got := ensureCardTableBlankLines(table); !strings.Contains(got, "intro\n\n| A") { + t.Fatalf("table normalization=%q", got) + } + input := "plain\nnext\n> quote one\n> quote two\nafter\n```go\ncode\n```\n\n- item" + got := fixCardNewlines(input) + for _, want := range []string{"plain
next", "> quote one
quote two", "```go\ncode\n```", "\n- item"} { + if !strings.Contains(got, want) { + t.Fatalf("normalized output missing %q: %q", want, got) + } + } +} diff --git a/internal/helpers/connect_codex_appserver.go b/internal/helpers/connect_codex_appserver.go index a1a88968..5aefdc3c 100644 --- a/internal/helpers/connect_codex_appserver.go +++ b/internal/helpers/connect_codex_appserver.go @@ -32,6 +32,12 @@ import ( const codexRobotDeveloperInstructions = "你是钉钉群聊里的智能助手,请用简洁、自然的中文直接回答用户问题;不要提及系统提示、内部协议或运行时细节;不要主动读写文件或执行命令。仅当用户消息明确附带了本地附件路径时,可以只读该附件或运行分析该附件所必需的只读命令,不得访问其它文件。" +var ( + codexAbsPath = filepath.Abs + codexExecCommandContext = exec.CommandContext + codexNewAppServerClient = newCodexAppServerClient +) + // codexAppServerForwarder uses Codex's official app-server JSON-RPC protocol to // keep one Codex thread per DingTalk conversation. type codexAppServerForwarder struct { @@ -123,7 +129,7 @@ func (f *codexAppServerForwarder) forwardAppServer(ctx context.Context, convID, defer state.mu.Unlock() } - cli, err := newCodexAppServerClient(ctx, f.bin, f.env, f.cwd()) + cli, err := codexNewAppServerClient(ctx, f.bin, f.env, f.cwd()) if err != nil { return "", err } @@ -170,7 +176,7 @@ func (f *codexAppServerForwarder) cwd() string { if f.workDir == "" { return connectWorkDir() } - if abs, err := filepath.Abs(f.workDir); err == nil { + if abs, err := codexAbsPath(f.workDir); err == nil { return abs } return f.workDir @@ -329,7 +335,7 @@ type codexRPCError struct { } func newCodexAppServerClient(ctx context.Context, bin string, env []string, cwd string) (*codexAppServerClient, error) { - cmd := exec.CommandContext(ctx, bin, "app-server", "--stdio") + cmd := codexExecCommandContext(ctx, bin, "app-server", "--stdio") cmd.Dir = cwd if len(env) > 0 { cmd.Env = append(os.Environ(), env...) diff --git a/internal/helpers/connect_codex_appserver_coverage_more_test.go b/internal/helpers/connect_codex_appserver_coverage_more_test.go new file mode 100644 index 00000000..002d1ce3 --- /dev/null +++ b/internal/helpers/connect_codex_appserver_coverage_more_test.go @@ -0,0 +1,263 @@ +package helpers + +import ( + "context" + "encoding/json" + "errors" + "io" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +type codexNthFailWriter struct { + writes int + failAt int +} + +func (w *codexNthFailWriter) Write(p []byte) (int, error) { + w.writes++ + if w.writes >= w.failAt { + return 0, errors.New("write") + } + return len(p), nil +} +func (*codexNthFailWriter) Close() error { return nil } + +type codexErrorReader struct{ err error } + +func (r codexErrorReader) Read([]byte) (int, error) { return 0, r.err } + +func codexIntPtr(v int) *int { return &v } + +func unitCodexClient(stdin io.WriteCloser, messages ...codexRPCMessage) *codexAppServerClient { + msgs := make(chan codexRPCMessage, len(messages)+1) + for _, msg := range messages { + msgs <- msg + } + return &codexAppServerClient{ + cmd: &exec.Cmd{}, stdin: stdin, msgs: msgs, readErr: make(chan error, 1), + done: make(chan struct{}), stderr: &lockedBuffer{}, nextID: 1, + } +} + +func TestCodexForwardAppServerFailureAndResumeEdges(t *testing.T) { + originalFactory := codexNewAppServerClient + defer func() { codexNewAppServerClient = originalFactory }() + boom := errors.New("factory") + codexNewAppServerClient = func(context.Context, string, []string, string) (*codexAppServerClient, error) { + return nil, boom + } + if _, err := (&codexAppServerForwarder{bin: "codex"}).forwardAppServer(context.Background(), "conv", "text", nil, nil); !errors.Is(err, boom) { + t.Fatalf("factory error=%v", err) + } + + turnDone := json.RawMessage(`{"threadId":"new-thread","turn":{"status":"completed","items":[{"type":"agentMessage","text":"done"}]}}`) + client := unitCodexClient(&bufferWriteCloser{}, + codexRPCMessage{ID: codexIntPtr(1), Result: json.RawMessage(`{}`)}, + codexRPCMessage{ID: codexIntPtr(2), Error: &codexRPCError{Message: "resume"}}, + codexRPCMessage{ID: codexIntPtr(3), Result: json.RawMessage(`{"thread":{"id":"new-thread"}}`)}, + codexRPCMessage{Method: "turn/completed", Params: turnDone}, + ) + codexNewAppServerClient = func(context.Context, string, []string, string) (*codexAppServerClient, error) { return client, nil } + sessions := newCodexThreadSessions("") + sessions.setThreadID("conv", "old-thread") + fwd := &codexAppServerForwarder{bin: "codex", sessions: sessions} + if reply, err := fwd.forwardAppServer(context.Background(), "conv", "text", nil, nil); err != nil || reply != "done" { + t.Fatalf("resume recovery reply=%q err=%v", reply, err) + } + if sessions.threadID("conv") != "new-thread" { + t.Fatalf("thread=%q", sessions.threadID("conv")) + } + + for _, tc := range []struct { + name string + messages []codexRPCMessage + }{ + {"start", []codexRPCMessage{{ID: codexIntPtr(1), Result: json.RawMessage(`{}`)}, {ID: codexIntPtr(2), Error: &codexRPCError{Message: "start"}}}}, + {"turn", []codexRPCMessage{{ID: codexIntPtr(1), Result: json.RawMessage(`{}`)}, {ID: codexIntPtr(2), Result: json.RawMessage(`{"thread":{"id":"thread"}}`)}, {ID: codexIntPtr(3), Error: &codexRPCError{Message: "turn"}}}}, + } { + t.Run(tc.name, func(t *testing.T) { + client := unitCodexClient(&bufferWriteCloser{}, tc.messages...) + codexNewAppServerClient = func(context.Context, string, []string, string) (*codexAppServerClient, error) { return client, nil } + if _, err := (&codexAppServerForwarder{bin: "codex"}).forwardAppServer(context.Background(), "conv", "text", nil, nil); err == nil { + t.Fatal("failure returned nil") + } + }) + } +} + +func TestCodexCwdConstructorAndReadLoopEdges(t *testing.T) { + originalAbs := codexAbsPath + originalExec := codexExecCommandContext + defer func() { + codexAbsPath = originalAbs + codexExecCommandContext = originalExec + }() + codexAbsPath = func(string) (string, error) { return "", errors.New("abs") } + if got := (&codexAppServerForwarder{workDir: "relative"}).cwd(); got != "relative" { + t.Fatalf("cwd=%q", got) + } + codexAbsPath = filepath.Abs + + cases := []func() *exec.Cmd{ + func() *exec.Cmd { + cmd := exec.Command("sh", "-c", "exit 0") + cmd.Stdin = strings.NewReader("") + return cmd + }, + func() *exec.Cmd { cmd := exec.Command("sh", "-c", "exit 0"); cmd.Stdout = io.Discard; return cmd }, + func() *exec.Cmd { return exec.Command(filepath.Join(t.TempDir(), "missing")) }, + } + for _, makeCmd := range cases { + codexExecCommandContext = func(context.Context, string, ...string) *exec.Cmd { return makeCmd() } + if client, err := newCodexAppServerClient(context.Background(), "codex", []string{"A=B"}, t.TempDir()); err == nil || client != nil { + t.Fatalf("constructor client=%v err=%v", client, err) + } + } + + c := unitCodexClient(&bufferWriteCloser{}) + c.readLoop(strings.NewReader("\n{\n")) + if err := <-c.readErr; err == nil || !strings.Contains(err.Error(), "parse") { + t.Fatalf("parse read error=%v", err) + } + c = unitCodexClient(&bufferWriteCloser{}) + c.readLoop(codexErrorReader{err: errors.New("scan")}) + if err := <-c.readErr; err == nil || !strings.Contains(err.Error(), "scan") { + t.Fatalf("scanner error=%v", err) + } +} + +func TestCodexClientSendInitializeAndRequestEdges(t *testing.T) { + if err := unitCodexClient(&codexNthFailWriter{failAt: 1}).initialize(context.Background()); err == nil { + t.Fatal("initialize first send error returned nil") + } + c := unitCodexClient(&codexNthFailWriter{failAt: 2}, codexRPCMessage{ID: codexIntPtr(1), Result: json.RawMessage(`{}`)}) + if err := c.initialize(context.Background()); err == nil { + t.Fatal("initialized notification error returned nil") + } + if err := unitCodexClient(&codexNthFailWriter{failAt: 1}).send(make(chan int)); err == nil { + t.Fatal("marshal error returned nil") + } + for _, call := range []func(*codexAppServerClient) error{ + func(c *codexAppServerClient) error { _, err := c.startThread(context.Background(), nil); return err }, + func(c *codexAppServerClient) error { _, err := c.resumeThread(context.Background(), nil); return err }, + func(c *codexAppServerClient) error { + _, err := c.runTurn(context.Background(), "thread", "text", nil, nil) + return err + }, + } { + if err := call(unitCodexClient(&codexNthFailWriter{failAt: 1})); err == nil { + t.Fatal("request send error returned nil") + } + } +} + +func TestCodexRunTurnRemainingEdges(t *testing.T) { + serverID := 99 + for _, tc := range []struct { + name string + messages []codexRPCMessage + close bool + want string + wantErr bool + }{ + {"closed", nil, true, "", true}, + {"server-request-and-turn-error", []codexRPCMessage{{ID: &serverID, Method: "request/input"}, {ID: codexIntPtr(1), Error: &codexRPCError{Message: "turn failed"}}}, false, "", true}, + {"wrong-completion-then-notification", []codexRPCMessage{{Method: "turn/completed", Params: json.RawMessage(`{"threadId":"other","turn":{}}`)}, {Method: "error", Params: json.RawMessage(`{"message":"bad"}`)}}, false, "", true}, + {"failed-without-message", []codexRPCMessage{{Method: "turn/completed", Params: json.RawMessage(`{"threadId":"thread","turn":{"status":"failed"}}`)}}, false, "", true}, + {"delta-fallback", []codexRPCMessage{{Method: "item/agentMessage/delta", Params: json.RawMessage(`{"threadId":"thread","delta":"partial"}`)}, {Method: "turn/completed", Params: json.RawMessage(`{"threadId":"thread","turn":{"status":"completed"}}`)}}, false, "partial", false}, + {"empty", []codexRPCMessage{{Method: "turn/completed", Params: json.RawMessage(`{"threadId":"thread","turn":{"status":"completed"}}`)}}, false, "", true}, + } { + t.Run(tc.name, func(t *testing.T) { + client := unitCodexClient(&bufferWriteCloser{}, tc.messages...) + if tc.close { + close(client.msgs) + } + var delta string + got, err := client.runTurn(context.Background(), "thread", "text", nil, func(s string) { delta = s }) + if (err != nil) != tc.wantErr || got != tc.want { + t.Fatalf("got=%q delta=%q err=%v", got, delta, err) + } + }) + } +} + +func TestCodexWaitResponseAndNextEdges(t *testing.T) { + serverID, otherID, wantID := 99, 2, 1 + c := unitCodexClient(&bufferWriteCloser{}, + codexRPCMessage{ID: &serverID, Method: "request/input"}, + codexRPCMessage{}, + codexRPCMessage{ID: &otherID}, + codexRPCMessage{ID: &wantID, Error: &codexRPCError{Message: "response"}}, + ) + if _, err := c.waitResponse(context.Background(), wantID); err == nil { + t.Fatal("response error returned nil") + } + c = unitCodexClient(&bufferWriteCloser{}) + close(c.msgs) + if _, err := c.waitResponse(context.Background(), wantID); err == nil { + t.Fatal("closed response returned nil") + } + + closed := unitCodexClient(&bufferWriteCloser{}) + close(closed.msgs) + if _, err := closed.next(context.Background()); err == nil { + t.Fatal("closed buffered channel returned nil") + } + delayed := unitCodexClient(&bufferWriteCloser{}) + go func() { + time.Sleep(time.Millisecond) + delayed.msgs <- codexRPCMessage{Method: "delayed"} + }() + if msg, err := delayed.next(context.Background()); err != nil || msg.Method != "delayed" { + t.Fatalf("delayed msg=%+v err=%v", msg, err) + } + delayedClose := unitCodexClient(&bufferWriteCloser{}) + go func() { + time.Sleep(time.Millisecond) + close(delayedClose.msgs) + }() + if _, err := delayedClose.next(context.Background()); err == nil { + t.Fatal("delayed close returned nil") + } + readFailure := unitCodexClient(&bufferWriteCloser{}) + readFailure.readErr <- errors.New("read") + if _, err := readFailure.next(context.Background()); err == nil { + t.Fatal("read error returned nil") + } + done := unitCodexClient(&bufferWriteCloser{}) + close(done.done) + if _, err := done.next(context.Background()); err == nil { + t.Fatal("done returned nil") + } + cancelled := unitCodexClient(&bufferWriteCloser{}) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := cancelled.next(ctx); !errors.Is(err, context.Canceled) { + t.Fatalf("cancel error=%v", err) + } +} + +func TestCodexResultParsingAndDefaultKeyEdges(t *testing.T) { + if got := codexConvKey(" "); got != "_default" { + t.Fatalf("default key=%q", got) + } + for _, tc := range []struct { + result json.RawMessage + err error + }{ + {nil, errors.New("upstream")}, + {json.RawMessage(`{`), nil}, + {json.RawMessage(`{}`), nil}, + } { + if _, err := codexThreadIDFromResult(tc.result, tc.err); err == nil { + t.Fatalf("result=%s upstream=%v returned nil", tc.result, tc.err) + } + } + if _, _, _, ok := codexTurnCompletedText(json.RawMessage(`{`), "thread"); ok { + t.Fatal("invalid completed payload accepted") + } +} diff --git a/internal/helpers/connect_coverage_more_test.go b/internal/helpers/connect_coverage_more_test.go index 3fd45949..232a5bf1 100644 --- a/internal/helpers/connect_coverage_more_test.go +++ b/internal/helpers/connect_coverage_more_test.go @@ -39,8 +39,6 @@ func TestConnectForwarderPureCoverage(t *testing.T) { streamSDKLogger{}.Errorf("error %s", "x") streamSDKLogger{}.Fatalf("fatal %s", "x") _ = agentNotInstalled("channel", "app", "install") - _ = insertBeforeArg([]string{"bin", "--marker", "tail"}, "--marker", "inserted") - _ = insertBeforeArg([]string{"bin", "tail"}, "--missing", "inserted") sessions := newConvSessions("") execFwd := &execForwarder{name: "agent", argv: []string{"bin"}, sessions: sessions} @@ -295,11 +293,11 @@ func TestConnectStreamRemainingPureEdges(t *testing.T) { turns := []connectQueuedTurn{ {}, - {picCode: "picture-code"}, - {fileInfo: fileInboundInfo{FileName: " named.txt ", DownloadCode: "download"}}, - {fileInfo: fileInboundInfo{DownloadCode: "download"}}, + {picCodes: []string{"picture-code"}}, + {fileInfos: []fileInboundInfo{{FileName: " named.txt ", DownloadCode: "download"}}}, + {fileInfos: []fileInboundInfo{{DownloadCode: "download"}}}, } - wants := []string{"[空消息]", "[图片]", "[文件: named.txt]", "[文件]"} + wants := []string{"[空消息]", "[图片]", "[附件: named.txt]", "[1 个附件]"} for i, turn := range turns { if got := connectTurnSummary(turn); got != wants[i] { t.Errorf("summary %d = %q, want %q", i, got, wants[i]) @@ -313,17 +311,17 @@ func TestConnectStreamRemainingPureEdges(t *testing.T) { t.Fatalf("single merge = %#v", got) } mergedPicture := mergeConnectQueuedTurns([]connectQueuedTurn{ - {picCode: "old-picture", msgID: "m1"}, + {picCodes: []string{"old-picture"}, msgID: "m1"}, {text: "latest", msgID: "m2"}, }) - if mergedPicture.picCode != "old-picture" { + if len(mergedPicture.picCodes) != 1 || mergedPicture.picCodes[0] != "old-picture" { t.Fatalf("merged picture = %#v", mergedPicture) } mergedFile := mergeConnectQueuedTurns([]connectQueuedTurn{ - {fileInfo: fileInboundInfo{FileName: "old", DownloadCode: "code"}, msgID: "m1"}, + {fileInfos: []fileInboundInfo{{FileName: "old", DownloadCode: "code"}}, msgID: "m1"}, {text: "latest", msgID: "m2"}, }) - if !mergedFile.fileInfo.hasActionable() { + if len(mergedFile.fileInfos) != 1 || !mergedFile.fileInfos[0].hasActionable() { t.Fatalf("merged file = %#v", mergedFile) } } diff --git a/internal/helpers/connect_fdlimit_unix.go b/internal/helpers/connect_fdlimit_unix.go index 1fc55d89..97cf9584 100644 --- a/internal/helpers/connect_fdlimit_unix.go +++ b/internal/helpers/connect_fdlimit_unix.go @@ -8,9 +8,11 @@ import ( "syscall" ) +var connectGetrlimit = syscall.Getrlimit + func checkFDLimit() { var rlim syscall.Rlimit - if err := syscall.Getrlimit(syscall.RLIMIT_NOFILE, &rlim); err != nil { + if err := connectGetrlimit(syscall.RLIMIT_NOFILE, &rlim); err != nil { return } if rlim.Cur < 512 { diff --git a/internal/helpers/connect_gemini_api.go b/internal/helpers/connect_gemini_api.go index eaff9046..67f94b19 100644 --- a/internal/helpers/connect_gemini_api.go +++ b/internal/helpers/connect_gemini_api.go @@ -115,14 +115,8 @@ func (f *geminiAPIForwarder) forwardWithAttachments(ctx context.Context, _ strin }, Contents: []geminiContent{{Role: "user", Parts: parts}}, } - raw, err := json.Marshal(body) - if err != nil { - return "", err - } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(raw)) - if err != nil { - return "", err - } + raw, _ := json.Marshal(body) + req, _ := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(raw)) req.Header.Set("Content-Type", "application/json") req.Header.Set("x-goog-api-key", f.apiKey) diff --git a/internal/helpers/connect_health.go b/internal/helpers/connect_health.go index ee223e42..2ca28240 100644 --- a/internal/helpers/connect_health.go +++ b/internal/helpers/connect_health.go @@ -184,10 +184,7 @@ func (h *connectHealth) flush() error { snapshot := h.hb h.mu.Unlock() - data, err := json.MarshalIndent(snapshot, "", " ") - if err != nil { - return err - } + data, _ := json.MarshalIndent(snapshot, "", " ") path := connectHeartbeatPath(h.dir) tmp := path + ".tmp" if err := os.WriteFile(tmp, data, config.FilePerm); err != nil { diff --git a/internal/helpers/connect_health_coverage_more_test.go b/internal/helpers/connect_health_coverage_more_test.go new file mode 100644 index 00000000..0c9d1427 --- /dev/null +++ b/internal/helpers/connect_health_coverage_more_test.go @@ -0,0 +1,78 @@ +package helpers + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestConnectHealthRemainingIOAndListingBranches(t *testing.T) { + origOverride := connectDaemonDirOverride + t.Cleanup(func() { connectDaemonDirOverride = origOverride }) + + blockingFile := filepath.Join(t.TempDir(), "file") + if err := os.WriteFile(blockingFile, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + connectDaemonDirOverride = blockingFile + if h := newConnectHealth("client", "codex"); h != nil { + t.Fatalf("health with invalid base=%+v", h) + } + if _, err := listConnectors(time.Now()); err == nil { + t.Fatal("list against non-directory base returned nil") + } + + h := &connectHealth{dir: filepath.Join(t.TempDir(), "missing"), hb: connectHeartbeat{UpdatedUnix: 1}} + h.onError(nil) + if err := h.flush(); err == nil { + t.Fatal("heartbeat write failure returned nil") + } + + renameDir := t.TempDir() + if err := os.Mkdir(connectHeartbeatPath(renameDir), 0o700); err != nil { + t.Fatal(err) + } + h = &connectHealth{dir: renameDir, hb: connectHeartbeat{UpdatedUnix: 1}} + if err := h.flush(); err == nil { + t.Fatal("heartbeat rename failure returned nil") + } + if _, err := readConnectHeartbeat(renameDir); err == nil { + t.Fatal("heartbeat read I/O failure returned nil") + } + + root := t.TempDir() + connectDaemonDirOverride = root + base := connectBaseDir() + if err := os.MkdirAll(base, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(base, "not-a-connector"), []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + badDir := filepath.Join(base, "bad-heartbeat") + if err := os.MkdirAll(connectHeartbeatPath(badDir), 0o700); err != nil { + t.Fatal(err) + } + daemonDir := filepath.Join(base, "daemon-only") + if err := os.MkdirAll(daemonDir, 0o700); err != nil { + t.Fatal(err) + } + if err := writeDaemonState(daemonDir, daemonState{Pid: os.Getpid(), UnifiedAppID: "app-id"}); err != nil { + t.Fatal(err) + } + reports, err := listConnectors(time.Now()) + if err != nil || len(reports) != 1 || reports[0].ClientID != "daemon-only" || reports[0].UnifiedAppID != "app-id" { + t.Fatalf("reports=%+v err=%v", reports, err) + } + + dead := &connectHeartbeat{Pid: -1} + if got := deriveConnectHealth(dead, true, time.Now()); got.Detail != "connector process not alive; supervisor should restart it" { + t.Fatalf("supervised dead detail=%q", got.Detail) + } + + connectDaemonDirOverride = "" + if got := connectBaseDir(); got == "" { + t.Fatal("default connect base is empty") + } +} diff --git a/internal/helpers/connect_knowledge.go b/internal/helpers/connect_knowledge.go index 4c55bbac..745a81a8 100644 --- a/internal/helpers/connect_knowledge.go +++ b/internal/helpers/connect_knowledge.go @@ -51,6 +51,8 @@ type knowledgeBase struct { // knowledgeTextExts is the set of plain-text extensions the retriever indexes. var knowledgeTextExts = []string{".md", ".markdown", ".mdx", ".txt", ".text"} +var knowledgeReadFile = os.ReadFile + // isKnowledgeTextExt reports whether ext is an indexable plain-text extension. func isKnowledgeTextExt(ext string) bool { ext = strings.ToLower(ext) @@ -84,7 +86,7 @@ func loadKnowledgeBase(dir string) (*knowledgeBase, error) { fmt.Fprintf(os.Stderr, "[connect][knowledge] 跳过超大文件 %s\n", path) return nil } - raw, rerr := os.ReadFile(path) + raw, rerr := knowledgeReadFile(path) if rerr != nil { return rerr } diff --git a/internal/helpers/connect_knowledge_coverage_more_test.go b/internal/helpers/connect_knowledge_coverage_more_test.go new file mode 100644 index 00000000..8252b48e --- /dev/null +++ b/internal/helpers/connect_knowledge_coverage_more_test.go @@ -0,0 +1,64 @@ +package helpers + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestKnowledgeLoadAndAugmentRemainingCoverage(t *testing.T) { + if _, err := loadKnowledgeBase(filepath.Join(t.TempDir(), "missing")); err == nil { + t.Fatal("missing knowledge directory returned nil") + } + dir := t.TempDir() + if err := os.Mkdir(filepath.Join(dir, ".hidden"), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, ".hidden", "secret.md"), []byte("hidden"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "visible.md"), []byte("# First\nbody\n# Second\n"+strings.Repeat("x", knowledgeMaxChunkRunes)), 0o600); err != nil { + t.Fatal(err) + } + large := filepath.Join(dir, "large.txt") + f, err := os.Create(large) + if err != nil { + t.Fatal(err) + } + if err := f.Truncate(knowledgeMaxFileBytes + 1); err != nil { + t.Fatal(err) + } + if err := f.Close(); err != nil { + t.Fatal(err) + } + kb, err := loadKnowledgeBase(dir) + if err != nil || len(kb.chunks) < 2 { + t.Fatalf("load chunks=%d err=%v", len(kb.chunks), err) + } + + origRead := knowledgeReadFile + knowledgeReadFile = func(string) ([]byte, error) { return nil, errors.New("read failed") } + if _, err := loadKnowledgeBase(dir); err == nil || !strings.Contains(err.Error(), "read failed") { + t.Fatalf("read failure err=%v", err) + } + knowledgeReadFile = origRead + t.Cleanup(func() { knowledgeReadFile = origRead }) + + if got := kb.augment("!"); got != "!" { + t.Fatalf("term-free question=%q", got) + } + + long := strings.Repeat("alpha beta ", 150) + ranked := &knowledgeBase{chunks: []knowledgeChunk{ + {source: "one", text: long, terms: map[string]int{"alpha": 1, "beta": 1}}, + {source: "two", text: long, terms: map[string]int{"alpha": 3}}, + {source: "three", text: long, terms: map[string]int{"alpha": 2}}, + {source: "four", text: long, terms: map[string]int{"alpha": 1}}, + }} + got := ranked.augment("alpha beta") + if !strings.Contains(got, "用户问题:alpha beta") { + t.Fatalf("augmented prompt missing question: %q", got) + } +} diff --git a/internal/helpers/connect_knowledge_wiki.go b/internal/helpers/connect_knowledge_wiki.go index e2f92d0f..1a7eab9e 100644 --- a/internal/helpers/connect_knowledge_wiki.go +++ b/internal/helpers/connect_knowledge_wiki.go @@ -51,6 +51,13 @@ const ( knowledgeSourceDoc ) +var ( + wikiLoadKnowledgeBase = loadKnowledgeBase + wikiRemoveAll = os.RemoveAll + wikiMkdirAll = os.MkdirAll + wikiWriteFile = os.WriteFile +) + type knowledgeSource struct { kind knowledgeSourceKind // ref is the directory path (dir), the spaceId (wiki) or the nodeId (doc). @@ -141,9 +148,6 @@ func (f runnerWikiFetcher) listNodes(ctx context.Context, spaceID string) ([]wik return nil, err } for _, n := range collectWikiNodes(res.Response) { - if n.id == "" { - continue - } if _, dup := seen[n.id]; dup { continue } @@ -184,7 +188,7 @@ func loadWikiKnowledgeBase(ctx context.Context, fetcher wikiFetcher, src knowled docs, err := pullWikiDocs(ctx, fetcher, src) if err != nil { // Pull failed: try the previous cache before giving up. - if kb, lerr := loadKnowledgeBase(cacheDir); lerr == nil { + if kb, lerr := wikiLoadKnowledgeBase(cacheDir); lerr == nil { fmt.Fprintf(logw, "[connect][knowledge] 拉取知识库失败(%v),使用旧缓存兜底:%s\n", err, cacheDir) return kb } @@ -199,7 +203,7 @@ func loadWikiKnowledgeBase(ctx context.Context, fetcher wikiFetcher, src knowled return buildKnowledgeBaseFromDocs(docs) } - kb, lerr := loadKnowledgeBase(cacheDir) + kb, lerr := wikiLoadKnowledgeBase(cacheDir) if lerr != nil { // Cache wrote but produced no usable chunks (e.g. all docs empty): // degrade to an empty base rather than failing the connector. @@ -279,10 +283,10 @@ func buildKnowledgeBaseFromDocs(docs []wikiDoc) *knowledgeBase { // .md per document) so loadKnowledgeBase can index it like any local directory. // It clears stale files first so deleted wiki docs do not linger in answers. func writeWikiCache(cacheDir string, docs []wikiDoc) error { - if err := os.RemoveAll(cacheDir); err != nil { + if err := wikiRemoveAll(cacheDir); err != nil { return err } - if err := os.MkdirAll(cacheDir, 0o755); err != nil { + if err := wikiMkdirAll(cacheDir, 0o755); err != nil { return err } used := map[string]int{} @@ -299,7 +303,7 @@ func writeWikiCache(cacheDir string, docs []wikiDoc) error { used[stem] = 1 } path := filepath.Join(cacheDir, stem+".md") - if err := os.WriteFile(path, []byte(d.text), 0o644); err != nil { + if err := wikiWriteFile(path, []byte(d.text), 0o644); err != nil { return err } } @@ -434,21 +438,17 @@ func loadConnectKnowledgeSource(cmd *cobra.Command, runner executor.Runner, clie return nil, err } logw := cmd.ErrOrStderr() - switch src.kind { - case knowledgeSourceDir: + if src.kind == knowledgeSourceDir { kb, lerr := loadKnowledgeBase(src.ref) if lerr != nil { return nil, lerr } fmt.Fprintf(logw, "[connect] 知识库已加载:%d 个片段(%s)\n", len(kb.chunks), src.ref) return kb, nil - case knowledgeSourceWiki, knowledgeSourceDoc: - fetcher := runnerWikiFetcher{runner: runner} - cacheRoot := connectKnowledgeCacheRoot(clientID) - return loadWikiKnowledgeBase(cmd.Context(), fetcher, src, cacheRoot, logw), nil - default: - return nil, fmt.Errorf("unsupported knowledge source kind %d", src.kind) } + fetcher := runnerWikiFetcher{runner: runner} + cacheRoot := connectKnowledgeCacheRoot(clientID) + return loadWikiKnowledgeBase(cmd.Context(), fetcher, src, cacheRoot, logw), nil } // connectKnowledgeCacheRoot is the per-robot knowledge cache root: @@ -456,8 +456,5 @@ func loadConnectKnowledgeSource(cmd *cobra.Command, runner executor.Runner, clie // the directory; an empty clientId degrades to a shared "default" bucket. func connectKnowledgeCacheRoot(clientID string) string { id := sanitizeWikiStem(clientID) - if id == "" { - id = "default" - } return filepath.Join(homeDir(), ".dws", "connect", id, "knowledge") } diff --git a/internal/helpers/connect_knowledge_wiki_coverage_more_test.go b/internal/helpers/connect_knowledge_wiki_coverage_more_test.go new file mode 100644 index 00000000..252cd485 --- /dev/null +++ b/internal/helpers/connect_knowledge_wiki_coverage_more_test.go @@ -0,0 +1,77 @@ +package helpers + +import ( + "context" + "errors" + "os" + "testing" +) + +func TestWikiPullRemainingCoverage(t *testing.T) { + ctx := context.Background() + if _, err := pullWikiDocs(ctx, fakeWikiFetcher{readErrFor: map[string]error{"doc": errors.New("read")}}, knowledgeSource{kind: knowledgeSourceDoc, ref: "doc"}); err == nil { + t.Fatal("single doc read error returned nil") + } + if _, err := pullWikiDocs(ctx, fakeWikiFetcher{content: map[string]string{"doc": " "}}, knowledgeSource{kind: knowledgeSourceDoc, ref: "doc"}); err == nil { + t.Fatal("empty single doc returned nil") + } + if _, err := pullWikiDocs(ctx, fakeWikiFetcher{listErr: errors.New("list")}, knowledgeSource{kind: knowledgeSourceWiki, ref: "space"}); err == nil { + t.Fatal("wiki list error returned nil") + } + if _, err := pullWikiDocs(ctx, fakeWikiFetcher{}, knowledgeSource{kind: knowledgeSourceWiki, ref: "space"}); err == nil { + t.Fatal("empty wiki node list returned nil") + } + if _, err := pullWikiDocs(ctx, fakeWikiFetcher{nodes: []wikiNode{{id: "node"}}, content: map[string]string{"node": " "}}, knowledgeSource{kind: knowledgeSourceWiki, ref: "space"}); err == nil { + t.Fatal("wiki without readable docs returned nil") + } + docs, err := pullWikiDocs(ctx, fakeWikiFetcher{nodes: []wikiNode{{id: "node"}}, content: map[string]string{"node": "body"}}, knowledgeSource{kind: knowledgeSourceWiki, ref: "space"}) + if err != nil || len(docs) != 1 || docs[0].stem != "node" { + t.Fatalf("unnamed node docs = %#v, %v", docs, err) + } + if _, err := pullWikiDocs(ctx, fakeWikiFetcher{}, knowledgeSource{kind: knowledgeSourceDir, ref: "dir"}); err == nil { + t.Fatal("local source passed to wiki pull returned nil") + } +} + +func TestWikiCacheFailureRemainingCoverage(t *testing.T) { + originalLoad := wikiLoadKnowledgeBase + originalRemove := wikiRemoveAll + originalMkdir := wikiMkdirAll + originalWrite := wikiWriteFile + t.Cleanup(func() { + wikiLoadKnowledgeBase = originalLoad + wikiRemoveAll = originalRemove + wikiMkdirAll = originalMkdir + wikiWriteFile = originalWrite + }) + boom := errors.New("cache failure") + docs := []wikiDoc{{stem: "doc", text: "body"}} + + wikiRemoveAll = func(string) error { return boom } + if err := writeWikiCache(t.TempDir(), docs); err == nil { + t.Fatal("remove cache error returned nil") + } + wikiRemoveAll = func(string) error { return nil } + wikiMkdirAll = func(string, os.FileMode) error { return boom } + if err := writeWikiCache(t.TempDir(), docs); err == nil { + t.Fatal("mkdir cache error returned nil") + } + wikiMkdirAll = func(string, os.FileMode) error { return nil } + wikiWriteFile = func(string, []byte, os.FileMode) error { return boom } + if err := writeWikiCache(t.TempDir(), docs); err == nil { + t.Fatal("write cache error returned nil") + } + + fetcher := fakeWikiFetcher{content: map[string]string{"doc": "body"}} + wikiRemoveAll = func(string) error { return boom } + wikiMkdirAll = originalMkdir + wikiWriteFile = originalWrite + if kb := loadWikiKnowledgeBase(context.Background(), fetcher, knowledgeSource{kind: knowledgeSourceDoc, ref: "doc"}, t.TempDir(), nil); len(kb.chunks) == 0 { + t.Fatal("write failure did not build in memory") + } + wikiRemoveAll = originalRemove + wikiLoadKnowledgeBase = func(string) (*knowledgeBase, error) { return nil, boom } + if kb := loadWikiKnowledgeBase(context.Background(), fetcher, knowledgeSource{kind: knowledgeSourceDoc, ref: "doc"}, t.TempDir(), nil); kb == nil || len(kb.chunks) != 0 { + t.Fatalf("load failure base = %#v", kb) + } +} diff --git a/internal/helpers/connect_lock.go b/internal/helpers/connect_lock.go index c443bc2c..c392b5dd 100644 --- a/internal/helpers/connect_lock.go +++ b/internal/helpers/connect_lock.go @@ -26,6 +26,12 @@ import ( // connectLockDir is a var so tests can isolate lock files. var connectLockDir = os.TempDir() +var ( + connectLockOpenFile = os.OpenFile + connectLockReadFile = os.ReadFile + connectLockRemove = os.Remove +) + // acquireConnectLock guards against two local connectors holding Stream // connections for the same robot. DingTalk load-balances pushes across // connections sharing one clientId, so a duplicate connector makes the bot @@ -35,23 +41,23 @@ var connectLockDir = os.TempDir() func acquireConnectLock(clientID string) (release func(), err error) { path := filepath.Join(connectLockDir, "dws-connect-"+sanitizeLockID(clientID)+".pid") for attempt := 0; attempt < 2; attempt++ { - f, openErr := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) + f, openErr := connectLockOpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) if openErr == nil { fmt.Fprintf(f, "%d", os.Getpid()) f.Close() - return func() { _ = os.Remove(path) }, nil + return func() { _ = connectLockRemove(path) }, nil } if !os.IsExist(openErr) { return nil, openErr } - raw, readErr := os.ReadFile(path) + raw, readErr := connectLockReadFile(path) if readErr == nil { if pid, perr := strconv.Atoi(strings.TrimSpace(string(raw))); perr == nil && pid > 0 && processAlive(pid) { return nil, fmt.Errorf("机器人 %s 已有连接器在本机运行 (pid %d)。同一机器人的多个 Stream 连接会随机分流消息,表现为机器人时灵时不灵;请先停掉旧进程,或确认后删除 %s", clientID, pid, path) } } // Stale lock (owner gone or file unreadable): take over. - _ = os.Remove(path) + _ = connectLockRemove(path) } return nil, fmt.Errorf("无法获取连接器锁 %s", path) } @@ -70,11 +76,8 @@ func sanitizeLockID(s string) string { // processAlive reports whether pid refers to a live process (unix signal-0 // probe; a permission error still means "alive"). func processAlive(pid int) bool { - proc, err := os.FindProcess(pid) - if err != nil { - return false - } - err = proc.Signal(syscall.Signal(0)) + proc, _ := os.FindProcess(pid) + err := proc.Signal(syscall.Signal(0)) if err == nil { return true } diff --git a/internal/helpers/connect_media.go b/internal/helpers/connect_media.go index b9e94e80..1ed60902 100644 --- a/internal/helpers/connect_media.go +++ b/internal/helpers/connect_media.go @@ -65,6 +65,13 @@ func connectAttachmentMIME(path string) string { return http.DetectContentType(buf[:n]) } +var ( + mediaMkdirAll = os.MkdirAll + mediaCreate = os.Create + mediaCopy = io.Copy + mediaRemove = os.Remove +) + // pictureDownloadCode digs the downloadCode out of a picture callback's // loosely-typed content payload (the stream SDK models Content as // interface{}). @@ -635,7 +642,7 @@ func (c *aiCardClient) downloadMessageFileNamed(ctx context.Context, robotCode, return "", fmt.Errorf("媒体文件过大:%d 字节,最大允许 %d 字节", resp.ContentLength, mediaMaxDownloadBytes) } dir := filepath.Join(os.TempDir(), "dws-connect-media") - if err := os.MkdirAll(dir, 0o755); err != nil { + if err := mediaMkdirAll(dir, 0o755); err != nil { return "", err } ext := filepath.Ext(strings.TrimSpace(fileName)) @@ -705,7 +712,7 @@ func (c *aiCardClient) downloadDentryFile(ctx context.Context, spaceID, dentryID } dir := filepath.Join(os.TempDir(), "dws-connect-media") - if err := os.MkdirAll(dir, 0o755); err != nil { + if err := mediaMkdirAll(dir, 0o755); err != nil { return "", err } ext := filepath.Ext(fileName) @@ -724,22 +731,22 @@ func (c *aiCardClient) downloadDentryFile(ctx context.Context, spaceID, dentryID // from a larger file; the latter is removed instead of leaving a corrupt local // artifact that an agent could mistake for the original. func writeCompleteMediaFile(dest string, src io.Reader) error { - f, err := os.Create(dest) + f, err := mediaCreate(dest) if err != nil { return err } - n, copyErr := io.Copy(f, io.LimitReader(src, mediaMaxDownloadBytes+1)) + n, copyErr := mediaCopy(f, io.LimitReader(src, mediaMaxDownloadBytes+1)) closeErr := f.Close() if copyErr != nil { - _ = os.Remove(dest) + _ = mediaRemove(dest) return copyErr } if closeErr != nil { - _ = os.Remove(dest) + _ = mediaRemove(dest) return closeErr } if n > mediaMaxDownloadBytes { - _ = os.Remove(dest) + _ = mediaRemove(dest) return fmt.Errorf("媒体文件超过最大允许大小 %d 字节,未保存截断文件", mediaMaxDownloadBytes) } return nil diff --git a/internal/helpers/connect_media_coverage_more_test.go b/internal/helpers/connect_media_coverage_more_test.go new file mode 100644 index 00000000..8a740b7a --- /dev/null +++ b/internal/helpers/connect_media_coverage_more_test.go @@ -0,0 +1,211 @@ +package helpers + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "os" + "strings" + "testing" + "time" +) + +func mediaCoverageResponse(status int, body string, headers map[string]string) *http.Response { + header := make(http.Header) + for key, value := range headers { + header.Set(key, value) + } + return &http.Response{StatusCode: status, Header: header, Body: io.NopCloser(strings.NewReader(body))} +} + +func mediaCoverageClient(transport roundTripFunc) *aiCardClient { + client := newAICardClient("client", "secret", "") + client.token = "token" + client.tokenExp = time.Now().Add(time.Hour) + client.httpClient = &http.Client{Transport: transport} + return client +} + +func TestConnectMediaPureFunctionRemainingCoverage(t *testing.T) { + info := parseFileInbound(map[string]interface{}{ + "filePath": " /tmp/file ", "fileType": " text/plain ", + "dentryID": int64(1), "spaceID": int(2), "size": json.Number("3"), + }) + if info.FilePath != "/tmp/file" || info.FileType != "text/plain" || info.DentryID != 1 || info.SpaceID != 2 || info.FileSize != 3 { + t.Fatalf("parsed alternate fields = %#v", info) + } + for _, value := range []interface{}{json.Number("bad"), "bad", true} { + if got := readInt64Field(map[string]interface{}{"value": value}, "missing", "value"); got != 0 { + t.Errorf("readInt64Field(%v) = %d", value, got) + } + } + if got := summarizeContent(make(chan int)); !strings.Contains(got, "unmarshalable") { + t.Fatalf("unmarshalable summary = %q", got) + } + if got := summarizeContent(strings.Repeat("x", 500)); len(got) <= 400 || !strings.HasSuffix(got, "…") { + t.Fatalf("long summary length/suffix = %d/%q", len(got), got[len(got)-3:]) + } + leaves := cardContentLeaves([]interface{}{1, map[string]interface{}{"value": "text"}}) + if len(leaves) != 1 || leaves[0] != "text" { + t.Fatalf("card leaves = %#v", leaves) + } + for _, tc := range []struct{ rawURL, contentType, want string }{ + {"https://x/y", "image/gif", ".gif"}, + {"https://x/y", "image/webp", ".webp"}, + {"https://x/file.toolong", "", ".png"}, + {"%", "", ".png"}, + } { + if got := mediaExt(tc.rawURL, tc.contentType); got != tc.want { + t.Errorf("mediaExt(%q, %q) = %q, want %q", tc.rawURL, tc.contentType, got, tc.want) + } + } +} + +func TestDownloadMessageFileRemainingCoverage(t *testing.T) { + withCardAPIBase(t, "https://api.test") + boom := errors.New("media failure") + originalMkdir := mediaMkdirAll + originalCreate := mediaCreate + originalCopy := mediaCopy + originalRemove := mediaRemove + t.Cleanup(func() { + mediaMkdirAll = originalMkdir + mediaCreate = originalCreate + mediaCopy = originalCopy + mediaRemove = originalRemove + }) + + client := mediaCoverageClient(func(*http.Request) (*http.Response, error) { return nil, boom }) + if _, err := client.downloadMessageFile(context.Background(), "robot", "code"); err == nil { + t.Fatal("API error returned nil") + } + + client = mediaCoverageClient(func(req *http.Request) (*http.Response, error) { + return mediaCoverageResponse(200, `{"downloadUrl":":"}`, nil), nil + }) + if _, err := client.downloadMessageFile(context.Background(), "robot", "code"); err == nil { + t.Fatal("invalid download URL returned nil") + } + + client = mediaCoverageClient(func(req *http.Request) (*http.Response, error) { + if req.URL.Host == "api.test" { + return mediaCoverageResponse(200, `{"downloadUrl":"https://download.test/file"}`, nil), nil + } + return nil, boom + }) + if _, err := client.downloadMessageFile(context.Background(), "robot", "code"); err == nil { + t.Fatal("download transport error returned nil") + } + + statusClient := func(status int) *aiCardClient { + return mediaCoverageClient(func(req *http.Request) (*http.Response, error) { + if req.URL.Host == "api.test" { + return mediaCoverageResponse(200, `{"downloadUrl":"https://download.test/file"}`, nil), nil + } + return mediaCoverageResponse(status, "body", map[string]string{"Content-Type": "image/png"}), nil + }) + } + if _, err := statusClient(500).downloadMessageFile(context.Background(), "robot", "code"); err == nil { + t.Fatal("HTTP status error returned nil") + } + + mediaMkdirAll = func(string, os.FileMode) error { return boom } + if _, err := statusClient(200).downloadMessageFile(context.Background(), "robot", "code"); err == nil { + t.Fatal("mkdir error returned nil") + } + mediaMkdirAll = originalMkdir + mediaCreate = func(string) (*os.File, error) { return nil, boom } + if _, err := statusClient(200).downloadMessageFile(context.Background(), "robot", "code"); err == nil { + t.Fatal("create error returned nil") + } + mediaCreate = originalCreate + mediaCopy = func(io.Writer, io.Reader) (int64, error) { return 0, boom } + if _, err := statusClient(200).downloadMessageFile(context.Background(), "robot", "code"); err == nil { + t.Fatal("copy error returned nil") + } + mediaCopy = originalCopy +} + +func TestDentryMediaRemainingCoverage(t *testing.T) { + withCardAPIBase(t, "https://api.test") + boom := errors.New("dentry failure") + + client := mediaCoverageClient(func(*http.Request) (*http.Response, error) { return nil, boom }) + if _, err := client.getUserUnionID(context.Background(), "user"); err == nil { + t.Fatal("union API error returned nil") + } + client = mediaCoverageClient(func(*http.Request) (*http.Response, error) { + return mediaCoverageResponse(200, `{}`, nil), nil + }) + if _, err := client.getUserUnionID(context.Background(), "user"); err == nil { + t.Fatal("missing union ID returned nil") + } + + client = mediaCoverageClient(func(*http.Request) (*http.Response, error) { return nil, boom }) + if _, err := client.downloadDentryFile(context.Background(), 1, 2, "union", "file"); err == nil { + t.Fatal("download info API error returned nil") + } + client = mediaCoverageClient(func(*http.Request) (*http.Response, error) { + return mediaCoverageResponse(200, `{}`, nil), nil + }) + if _, err := client.downloadDentryFile(context.Background(), 1, 2, "union", "file"); err == nil { + t.Fatal("missing resource URL returned nil") + } + client = mediaCoverageClient(func(*http.Request) (*http.Response, error) { + return mediaCoverageResponse(200, `{"resourceUrl":":"}`, nil), nil + }) + if _, err := client.downloadDentryFile(context.Background(), 1, 2, "union", "file"); err == nil { + t.Fatal("invalid resource URL returned nil") + } + + dentryClient := func(status int, downloadErr error) *aiCardClient { + return mediaCoverageClient(func(req *http.Request) (*http.Response, error) { + if req.URL.Host == "api.test" { + return mediaCoverageResponse(200, `{"resourceUrl":"https://download.test/file","headers":{"X-Test":"value"}}`, nil), nil + } + if downloadErr != nil { + return nil, downloadErr + } + if req.Header.Get("X-Test") != "value" { + t.Errorf("download header = %q", req.Header.Get("X-Test")) + } + return mediaCoverageResponse(status, "body", map[string]string{"Content-Type": "image/gif"}), nil + }) + } + if _, err := dentryClient(200, boom).downloadDentryFile(context.Background(), 1, 2, "union", "file"); err == nil { + t.Fatal("download transport error returned nil") + } + if _, err := dentryClient(500, nil).downloadDentryFile(context.Background(), 1, 2, "union", "file"); err == nil { + t.Fatal("download HTTP status returned nil") + } + path, err := dentryClient(200, nil).downloadDentryFile(context.Background(), 1, 2, "union", "") + if err != nil { + t.Fatalf("extension fallback success: %v", err) + } + _ = os.Remove(path) + + originalMkdir := mediaMkdirAll + originalCreate := mediaCreate + originalCopy := mediaCopy + t.Cleanup(func() { + mediaMkdirAll = originalMkdir + mediaCreate = originalCreate + mediaCopy = originalCopy + }) + mediaMkdirAll = func(string, os.FileMode) error { return boom } + if _, err := dentryClient(200, nil).downloadDentryFile(context.Background(), 1, 2, "union", ""); err == nil { + t.Fatal("dentry mkdir error returned nil") + } + mediaMkdirAll = originalMkdir + mediaCreate = func(string) (*os.File, error) { return nil, boom } + if _, err := dentryClient(200, nil).downloadDentryFile(context.Background(), 1, 2, "union", "file.txt"); err == nil { + t.Fatal("dentry create error returned nil") + } + mediaCreate = originalCreate + mediaCopy = func(io.Writer, io.Reader) (int64, error) { return 0, boom } + if _, err := dentryClient(200, nil).downloadDentryFile(context.Background(), 1, 2, "union", "file.txt"); err == nil { + t.Fatal("dentry copy error returned nil") + } +} diff --git a/internal/helpers/connect_onboarding.go b/internal/helpers/connect_onboarding.go index 45e74e7e..0749366b 100644 --- a/internal/helpers/connect_onboarding.go +++ b/internal/helpers/connect_onboarding.go @@ -34,12 +34,17 @@ type connectCreds struct { source string // how they were resolved, for the [connect] log line } +var ( + connectStdinStat = func() (os.FileInfo, error) { return os.Stdin.Stat() } + connectOnboardingSleep = sleepCtx +) + // connectStdinInteractive reports whether stdin is a real terminal (char // device), so the onboarding prompt only runs interactively and never blocks a // script, a daemon, or a piped invocation — those keep the original // "need a flag" error. func connectStdinInteractive() bool { - fi, err := os.Stdin.Stat() + fi, err := connectStdinStat() if err != nil { return false } @@ -171,7 +176,7 @@ func pollRobotCreateResult(runner executor.Runner, cmd *cobra.Command, taskID st } if attempt < maxAttempts { fmt.Fprintf(out, " 建号进行中(%d/%d,状态=%s),稍候…\n", attempt, maxAttempts, status) - _ = sleepCtx(cmd.Context(), 3*time.Second) + _ = connectOnboardingSleep(cmd.Context(), 3*time.Second) } } return connectCreds{}, apperrors.NewInternal(fmt.Sprintf("建号结果轮询超时,taskId=%s;可稍后用 `dws dev app robot result --task-id %s` 继续查询", taskID, taskID)) diff --git a/internal/helpers/connect_onboarding_coverage_more_test.go b/internal/helpers/connect_onboarding_coverage_more_test.go new file mode 100644 index 00000000..482fcfce --- /dev/null +++ b/internal/helpers/connect_onboarding_coverage_more_test.go @@ -0,0 +1,87 @@ +package helpers + +import ( + "bufio" + "bytes" + "context" + "errors" + "os" + "strings" + "testing" + "time" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor" + "github.com/spf13/cobra" +) + +type onboardingCoverageRunner struct { + responses []map[string]any + errors []error + index int +} + +func (r *onboardingCoverageRunner) Run(context.Context, executor.Invocation) (executor.Result, error) { + index := r.index + r.index++ + if index < len(r.errors) && r.errors[index] != nil { + return executor.Result{}, r.errors[index] + } + if index < len(r.responses) { + return executor.Result{Response: r.responses[index]}, nil + } + return executor.Result{}, nil +} + +func TestConnectOnboardingFailureCoverage(t *testing.T) { + boom := errors.New("onboarding failure") + originalStat := connectStdinStat + originalSleep := connectOnboardingSleep + t.Cleanup(func() { + connectStdinStat = originalStat + connectOnboardingSleep = originalSleep + }) + connectStdinStat = func() (os.FileInfo, error) { return nil, boom } + if connectStdinInteractive() { + t.Fatal("stdin stat error reported interactive") + } + + var out bytes.Buffer + if _, err := runConnectOnboarding(&onboardingCoverageRunner{}, &cobra.Command{}, strings.NewReader(""), &out); err == nil { + t.Fatal("choice EOF returned nil") + } + if _, err := onboardExistingApp(&onboardingCoverageRunner{}, &cobra.Command{}, bufio.NewReader(strings.NewReader("")), &out); err == nil { + t.Fatal("existing app EOF returned nil") + } + if _, err := onboardExistingApp(&onboardingCoverageRunner{errors: []error{boom}}, &cobra.Command{}, bufio.NewReader(strings.NewReader("app\n")), &out); err == nil { + t.Fatal("credential fetch error returned nil") + } + if _, err := onboardExistingApp(&onboardingCoverageRunner{responses: []map[string]any{{}}}, &cobra.Command{}, bufio.NewReader(strings.NewReader("app\n")), &out); err == nil { + t.Fatal("empty fetched credentials returned nil") + } + if _, err := onboardExistingApp(&onboardingCoverageRunner{}, &cobra.Command{}, bufio.NewReader(strings.NewReader("\nclient\n\n")), &out); err == nil { + t.Fatal("empty raw secret returned nil") + } + + input := func() *bufio.Reader { return bufio.NewReader(strings.NewReader("app\nrobot\ndescription\n")) } + if _, err := onboardNewApp(&onboardingCoverageRunner{errors: []error{boom}}, &cobra.Command{}, input(), &out); err == nil { + t.Fatal("submit error returned nil") + } + if _, err := onboardNewApp(&onboardingCoverageRunner{responses: []map[string]any{{}}}, &cobra.Command{}, input(), &out); err == nil { + t.Fatal("empty task ID returned nil") + } + + connectOnboardingSleep = func(context.Context, time.Duration) error { return nil } + if _, err := pollRobotCreateResult(&onboardingCoverageRunner{errors: []error{boom}}, &cobra.Command{}, "task", &out); err == nil { + t.Fatal("poll runner error returned nil") + } + if _, err := pollRobotCreateResult(&onboardingCoverageRunner{responses: []map[string]any{{"status": "failed"}}}, &cobra.Command{}, "task", &out); err == nil { + t.Fatal("failed task returned nil") + } + responses := make([]map[string]any, 10) + for index := range responses { + responses[index] = map[string]any{"status": "pending"} + } + if _, err := pollRobotCreateResult(&onboardingCoverageRunner{responses: responses}, &cobra.Command{}, "task", &out); err == nil { + t.Fatal("poll timeout returned nil") + } +} diff --git a/internal/helpers/connect_opencode.go b/internal/helpers/connect_opencode.go index 0cde9c66..f8244101 100644 --- a/internal/helpers/connect_opencode.go +++ b/internal/helpers/connect_opencode.go @@ -39,7 +39,6 @@ const ( opencodeServerUsername = "opencode" opencodeServerHealthPath = "/global/health" opencodeNoTextReply = "(本地 agent 无文本输出)" - opencodeServerStartupWait = 20 * time.Second opencodeServerPollInterval = 150 * time.Millisecond opencodeConfigContentEnv = "OPENCODE_CONFIG_CONTENT" opencodePermissionEnv = "OPENCODE_PERMISSION" @@ -51,6 +50,18 @@ const ( var errOpencodeSessionMissing = errors.New("opencode session missing") +var ( + opencodeServerStartupWait = 20 * time.Second + opencodeAbsPath = filepath.Abs + opencodeFreeLocalPort = freeLocalPort + opencodeExecCommand = exec.Command + opencodeRandRead = rand.Read + opencodeWaitHealthy = func(s *opencodeServer, ctx context.Context, client *opencodeHTTPClient) error { + return s.waitHealthy(ctx, client) + } + freeLocalPortListen = net.Listen +) + // opencodeForwarder forwards DingTalk messages to a local `opencode serve` // instance. DWS owns the local server process and keeps a per-conversation // mapping to opencode's native session id; opencode owns the actual message @@ -158,7 +169,7 @@ func (f *opencodeForwarder) cwd() string { if f.workDir == "" { return connectWorkDir() } - if abs, err := filepath.Abs(f.workDir); err == nil { + if abs, err := opencodeAbsPath(f.workDir); err == nil { return abs } return f.workDir @@ -241,13 +252,13 @@ func (s *opencodeServer) ensure(ctx context.Context) (*opencodeHTTPClient, error _ = s.closeLocked() } - port, err := freeLocalPort() + port, err := opencodeFreeLocalPort() if err != nil { return nil, err } password := randomHex(24) baseURL := fmt.Sprintf("http://127.0.0.1:%d", port) - cmd := exec.Command(s.bin, "serve", "--pure", "--hostname", "127.0.0.1", "--port", fmt.Sprint(port)) + cmd := opencodeExecCommand(s.bin, "serve", "--pure", "--hostname", "127.0.0.1", "--port", fmt.Sprint(port)) if s.workDir != "" { cmd.Dir = s.workDir } @@ -266,7 +277,7 @@ func (s *opencodeServer) ensure(ctx context.Context) (*opencodeHTTPClient, error s.done <- cmd.Wait() }() client = &opencodeHTTPClient{baseURL: s.baseURL, username: opencodeServerUsername, password: s.password, httpClient: s.httpClient} - if err := s.waitHealthy(ctx, client); err != nil { + if err := opencodeWaitHealthy(s, ctx, client); err != nil { _ = s.closeLocked() return nil, err } @@ -305,18 +316,12 @@ func opencodeNonInteractiveConfig(existing string) string { } cfg["permission"] = permission - data, err := json.Marshal(cfg) - if err != nil { - return `{"tools":{"question":false},"permission":{"*":"allow","read":"allow","edit":"allow","write":"allow","patch":"allow","apply_patch":"allow","glob":"allow","grep":"allow","bash":"allow","task":"allow","skill":"allow","lsp":"allow","webfetch":"allow","websearch":"allow","question":"deny","external_directory":"allow","doom_loop":"allow"}}` - } + data, _ := json.Marshal(cfg) return string(data) } func opencodeNonInteractivePermission() string { - data, err := json.Marshal(opencodeNonInteractivePermissionMap()) - if err != nil { - return `{"*":"allow","read":"allow","edit":"allow","write":"allow","patch":"allow","apply_patch":"allow","glob":"allow","grep":"allow","bash":"allow","task":"allow","skill":"allow","lsp":"allow","webfetch":"allow","websearch":"allow","question":"deny","external_directory":"allow","doom_loop":"allow"}` - } + data, _ := json.Marshal(opencodeNonInteractivePermissionMap()) return string(data) } @@ -497,9 +502,6 @@ func (c *opencodeHTTPClient) sendMessageWithAttachments(ctx context.Context, ses Parts []opencodePart `json:"parts"` } if err := c.doJSON(ctx, http.MethodPost, "/session/"+sessionID+"/message", body, &out); err != nil { - if errors.Is(err, errOpencodeSessionMissing) { - return "", err - } return "", err } return strings.TrimSpace(opencodePartsText(out.Parts)), nil @@ -607,7 +609,7 @@ func opencodeModelRef(model string) map[string]string { } func freeLocalPort() (int, error) { - ln, err := net.Listen("tcp", "127.0.0.1:0") + ln, err := freeLocalPortListen("tcp", "127.0.0.1:0") if err != nil { return 0, err } @@ -617,7 +619,7 @@ func freeLocalPort() (int, error) { func randomHex(bytesLen int) string { buf := make([]byte, bytesLen) - if _, err := rand.Read(buf); err != nil { + if _, err := opencodeRandRead(buf); err != nil { return fmt.Sprintf("%d", time.Now().UnixNano()) } return hex.EncodeToString(buf) diff --git a/internal/helpers/connect_opencode_coverage_more_test.go b/internal/helpers/connect_opencode_coverage_more_test.go new file mode 100644 index 00000000..fedf92e1 --- /dev/null +++ b/internal/helpers/connect_opencode_coverage_more_test.go @@ -0,0 +1,323 @@ +package helpers + +import ( + "context" + "errors" + "io" + "net" + "net/http" + "net/http/httptest" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +func opencodeTestClient(t *testing.T, handler http.HandlerFunc) (*opencodeHTTPClient, func()) { + t.Helper() + server := httptest.NewServer(handler) + return &opencodeHTTPClient{baseURL: server.URL, username: "user", password: "pass", httpClient: server.Client()}, server.Close +} + +func TestOpencodeForwarderRemainingEdges(t *testing.T) { + t.Run("create session error", func(t *testing.T) { + client, closeServer := opencodeTestClient(t, func(w http.ResponseWriter, _ *http.Request) { http.Error(w, "failed", http.StatusInternalServerError) }) + defer closeServer() + f := &opencodeForwarder{server: &opencodeServer{}, sessions: newOpencodeSessions("")} + if _, err := f.forwardWithClient(context.Background(), client, "conv", "text", nil); err == nil { + t.Fatal("create error returned nil") + } + }) + t.Run("message error and backend reply", func(t *testing.T) { + response := "error" + client, closeServer := opencodeTestClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/session" { + _, _ = io.WriteString(w, `{"id":"session"}`) + return + } + if response == "error" { + http.Error(w, "failed", http.StatusInternalServerError) + } else { + _, _ = io.WriteString(w, `{"parts":[{"type":"text","text":"API Error: backend"}]}`) + } + }) + defer closeServer() + f := &opencodeForwarder{server: &opencodeServer{}} + if _, err := f.forwardWithClient(context.Background(), client, "conv", "text", nil); err == nil { + t.Fatal("message error returned nil") + } + response = "backend" + if reply, err := f.forwardWithClient(context.Background(), client, "conv", "text", nil); err != nil || !strings.Contains(reply, "backend") { + t.Fatalf("backend reply=%q error=%v", reply, err) + } + }) + + origAbs := opencodeAbsPath + t.Cleanup(func() { opencodeAbsPath = origAbs }) + opencodeAbsPath = func(string) (string, error) { return "", errors.New("abs") } + f := &opencodeForwarder{workDir: "relative"} + if f.cwd() != "relative" { + t.Fatalf("cwd fallback = %q", f.cwd()) + } + opencodeAbsPath = filepath.Abs + if got := (&opencodeForwarder{workDir: "."}).cwd(); !filepath.IsAbs(got) { + t.Fatalf("absolute cwd = %q", got) + } + + if err := (&opencodeForwarder{}).clearSession(context.Background(), "conv"); err != nil { + t.Fatal(err) + } + f.sessions = newOpencodeSessions("") + if err := f.clearSession(context.Background(), "conv"); err != nil { + t.Fatal(err) + } + f.sessions.set("conv", "session") + f.server = newOpencodeServer(filepath.Join(t.TempDir(), "missing"), nil, "", false) + if err := f.clearSession(context.Background(), "conv"); err == nil { + t.Fatal("server ensure error returned nil") + } +} + +func TestOpencodeServerEnsureAndCloseEdges(t *testing.T) { + origPort := opencodeFreeLocalPort + origCommand := opencodeExecCommand + origWait := opencodeServerStartupWait + origHealthy := opencodeWaitHealthy + t.Cleanup(func() { + opencodeFreeLocalPort = origPort + opencodeExecCommand = origCommand + opencodeServerStartupWait = origWait + opencodeWaitHealthy = origHealthy + }) + + healthy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = io.WriteString(w, `{"healthy":true}`) + })) + defer healthy.Close() + s := &opencodeServer{baseURL: healthy.URL} + if _, err := s.ensure(context.Background()); err != nil || s.httpClient == nil { + t.Fatalf("existing server ensure: %v", err) + } + + opencodeFreeLocalPort = func() (int, error) { return 0, errors.New("port") } + if _, err := newOpencodeServer("bin", nil, "", false).ensure(context.Background()); err == nil { + t.Fatal("port error returned nil") + } + opencodeFreeLocalPort = origPort + if _, err := newOpencodeServer(filepath.Join(t.TempDir(), "missing"), nil, "", false).ensure(context.Background()); err == nil { + t.Fatal("start error returned nil") + } + + opencodeExecCommand = func(string, ...string) *exec.Cmd { return exec.Command("sh", "-c", "sleep 5") } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + s = newOpencodeServer("ignored", nil, t.TempDir(), false) + if _, err := s.ensure(ctx); err == nil { + t.Fatal("cancelled startup returned nil") + } + if s.cmd != nil || s.baseURL != "" { + t.Fatalf("failed startup not cleaned: %#v", s) + } + + // A successful injected readiness check covers the final state return; the + // real waitHealthy state machine is exercised independently below. + opencodeWaitHealthy = func(*opencodeServer, context.Context, *opencodeHTTPClient) error { return nil } + opencodeExecCommand = func(string, ...string) *exec.Cmd { return exec.Command("sh", "-c", "sleep 5") } + s = newOpencodeServer("ignored", nil, "", false) + if client, err := s.ensure(context.Background()); err != nil || client == nil { + t.Fatalf("successful startup: client=%v err=%v", client, err) + } + _ = s.close() + + // Existing unhealthy process is closed before attempting a replacement. + proc := exec.Command("sh", "-c", "sleep 5") + if err := proc.Start(); err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func(cmd *exec.Cmd) { done <- cmd.Wait() }(proc) + s = &opencodeServer{cmd: proc, done: done, baseURL: "http://127.0.0.1:1", httpClient: &http.Client{}} + opencodeFreeLocalPort = func() (int, error) { return 0, errors.New("after-close") } + if _, err := s.ensure(context.Background()); err == nil { + t.Fatal("replacement error returned nil") + } + opencodeFreeLocalPort = origPort + + proc = exec.Command("sh", "-c", "sleep 5") + if err := proc.Start(); err != nil { + t.Fatal(err) + } + done = make(chan error, 1) + go func(cmd *exec.Cmd) { done <- cmd.Wait() }(proc) + s = &opencodeServer{cmd: proc, done: done, baseURL: "url", password: "pw"} + if err := s.close(); err != nil { + t.Fatalf("close: %v", err) + } +} + +func TestOpencodeWaitHealthyRemainingEdges(t *testing.T) { + origWait := opencodeServerStartupWait + origSleep := helperSleep + t.Cleanup(func() { + opencodeServerStartupWait = origWait + helperSleep = origSleep + }) + helperSleep = func(time.Duration) { time.Sleep(time.Millisecond) } + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + s := &opencodeServer{} + if err := s.waitHealthy(ctx, &opencodeHTTPClient{}); !errors.Is(err, context.Canceled) { + t.Fatalf("cancel error=%v", err) + } + + for _, processErr := range []error{errors.New("exit"), nil} { + s = &opencodeServer{done: make(chan error, 1)} + s.done <- processErr + if err := s.waitHealthy(context.Background(), &opencodeHTTPClient{}); err == nil { + t.Fatal("process exit returned nil") + } + } + + opencodeServerStartupWait = 3 * time.Millisecond + bad := &opencodeHTTPClient{baseURL: "http://127.0.0.1:1", httpClient: &http.Client{}} + if err := s.waitHealthy(context.Background(), bad); err == nil || !strings.Contains(err.Error(), "超时") { + t.Fatalf("health timeout=%v", err) + } + opencodeServerStartupWait = 0 + if err := s.waitHealthy(context.Background(), bad); err == nil { + t.Fatal("empty timeout returned nil") + } +} + +func TestOpencodeHTTPClientRemainingEdges(t *testing.T) { + ctx := context.Background() + empty := &opencodeHTTPClient{httpClient: &http.Client{}} + if err := empty.health(ctx); err == nil { + t.Fatal("empty URL health returned nil") + } + if err := empty.deleteSession(ctx, " "); err != nil { + t.Fatal(err) + } + + client, closeServer := opencodeTestClient(t, func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/global/health": + _, _ = io.WriteString(w, `{"healthy":false}`) + case "/session-empty": + _, _ = io.WriteString(w, `{}`) + case "/session/missing": + http.NotFound(w, r) + case "/session/fail": + http.Error(w, "delete failed", http.StatusInternalServerError) + default: + http.NotFound(w, r) + } + }) + defer closeServer() + if err := client.health(ctx); err == nil { + t.Fatal("health=false returned nil") + } + // Point createSession at a server whose /session response has no id. + missingID, closeMissing := opencodeTestClient(t, func(w http.ResponseWriter, _ *http.Request) { _, _ = io.WriteString(w, `{}`) }) + defer closeMissing() + if _, err := missingID.createSession(ctx); err == nil { + t.Fatal("missing session id returned nil") + } + messageClient, closeMessage := opencodeTestClient(t, func(w http.ResponseWriter, _ *http.Request) { + _, _ = io.WriteString(w, `{"parts":[{"type":"text","text":"reply"}]}`) + }) + defer closeMessage() + if reply, err := messageClient.sendMessageWithAttachments(ctx, "session", "text", "provider/model", nil); err != nil || reply != "reply" { + t.Fatalf("modeled message reply=%q err=%v", reply, err) + } + if err := client.deleteSession(ctx, "missing"); err != nil { + t.Fatalf("missing delete: %v", err) + } + if err := client.deleteSession(ctx, "fail"); err == nil { + t.Fatal("failed delete returned nil") + } +} + +func TestOpencodeDoJSONAndPartsEdges(t *testing.T) { + ctx := context.Background() + client := &opencodeHTTPClient{baseURL: "http://example.invalid", httpClient: &http.Client{}} + if err := client.doJSON(ctx, http.MethodPost, "/x", make(chan int), nil); err == nil { + t.Fatal("marshal error returned nil") + } + client.baseURL = "%" + if err := client.doJSON(ctx, http.MethodGet, "/x", nil, nil); err == nil { + t.Fatal("request error returned nil") + } + + responses := []struct { + status int + body string + out any + err bool + }{ + {http.StatusInternalServerError, "", nil, true}, + {http.StatusInternalServerError, "failure", nil, true}, + {http.StatusNoContent, "", &map[string]any{}, false}, + {http.StatusOK, "{", &map[string]any{}, true}, + {http.StatusOK, `{}`, nil, false}, + } + for _, tc := range responses { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(tc.status) + _, _ = io.WriteString(w, tc.body) + })) + c := &opencodeHTTPClient{baseURL: server.URL, username: "u", password: "p", httpClient: server.Client()} + err := c.doJSON(ctx, http.MethodPost, "/x", map[string]any{"x": 1}, tc.out) + server.Close() + if (err != nil) != tc.err { + t.Fatalf("status=%d body=%q error=%v wantErr=%v", tc.status, tc.body, err, tc.err) + } + } + + closed := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + closedURL := closed.URL + closedClient := closed.Client() + closed.Close() + if err := (&opencodeHTTPClient{baseURL: closedURL, httpClient: closedClient}).doJSON(ctx, http.MethodGet, "/x", nil, nil); err == nil { + t.Fatal("transport error returned nil") + } + + parts := []opencodePart{ + {Type: "text", Delta: "delta", Data: []byte(`{"text":"data"}`)}, + {Type: "text", Data: []byte(`{`)}, + {Parts: []opencodePart{{Type: "text", Text: "nested"}}}, + } + if got := opencodePartsText(parts); !strings.Contains(got, "delta") || !strings.Contains(got, "data") || !strings.Contains(got, "nested") { + t.Fatalf("parts text=%q", got) + } + for _, model := range []string{"", "model", "/missing", "provider/", "provider/model"} { + _ = opencodeModelRef(model) + } +} + +func TestOpencodeSystemHelperEdges(t *testing.T) { + origPort := opencodeFreeLocalPort + origRand := opencodeRandRead + origListen := freeLocalPortListen + t.Cleanup(func() { + opencodeFreeLocalPort = origPort + opencodeRandRead = origRand + freeLocalPortListen = origListen + }) + opencodeRandRead = func([]byte) (int, error) { return 0, errors.New("rand") } + if randomHex(4) == "" { + t.Fatal("random fallback empty") + } + freeLocalPortListen = func(string, string) (net.Listener, error) { return nil, errors.New("listen") } + if _, err := freeLocalPort(); err == nil { + t.Fatal("listen error returned nil") + } + if opencodeConvKey(" ") != "_default" { + t.Fatal("blank conversation key") + } + sessions := newOpencodeSessions("") + sessions.set("conv", "session") + sessions.set("conv", "") +} diff --git a/internal/helpers/connect_qoder_stream.go b/internal/helpers/connect_qoder_stream.go index 4851614b..d59d0161 100644 --- a/internal/helpers/connect_qoder_stream.go +++ b/internal/helpers/connect_qoder_stream.go @@ -31,6 +31,12 @@ import ( const qoderInitializeTimeout = 60 * time.Second +var ( + qoderAbsPath = filepath.Abs + qoderExecCommand = exec.Command + qoderUUIDString = uuid.NewString +) + // qoderStreamForwarder keeps one qodercli stream-json subprocess alive for the // lifetime of `dws dev connect`. DWS sends each DingTalk turn as a JSON user // message with a per-conversation session_id, so Qoder keeps context without a @@ -142,7 +148,7 @@ func (f *qoderStreamForwarder) forwardStream(ctx context.Context, convID, text s if err := f.ensureLocked(ctx); err != nil { return "", err } - sessionID := uuid.NewString() + sessionID := qoderUUIDString() if f.sessions != nil { sessionID = f.sessions.id(convID) } @@ -234,7 +240,7 @@ func (f *qoderStreamForwarder) cwd() string { if f.workDir == "" { return connectWorkDir() } - if abs, err := filepath.Abs(f.workDir); err == nil { + if abs, err := qoderAbsPath(f.workDir); err == nil { return abs } return f.workDir @@ -263,7 +269,7 @@ func (f *qoderStreamForwarder) ensureLocked(ctx context.Context) error { } } - cmd := exec.Command(f.bin, f.commandArgs()...) + cmd := qoderExecCommand(f.bin, f.commandArgs()...) cmd.Dir = f.cwd() cmd.Env = append(os.Environ(), f.env...) stdin, err := cmd.StdinPipe() @@ -296,7 +302,7 @@ func (f *qoderStreamForwarder) ensureLocked(ctx context.Context) error { func (f *qoderStreamForwarder) initializeLocked(parent context.Context) error { ctx, cancel := context.WithTimeout(parent, qoderInitializeTimeout) defer cancel() - requestID := "dws_init_" + uuid.NewString() + requestID := "dws_init_" + qoderUUIDString() request := map[string]any{ "type": "control_request", "request_id": requestID, diff --git a/internal/helpers/connect_qoder_stream_coverage_more_test.go b/internal/helpers/connect_qoder_stream_coverage_more_test.go new file mode 100644 index 00000000..f18d742b --- /dev/null +++ b/internal/helpers/connect_qoder_stream_coverage_more_test.go @@ -0,0 +1,292 @@ +package helpers + +import ( + "bytes" + "context" + "errors" + "io" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +type qoderErrorWriteCloser struct{ err error } + +func (w qoderErrorWriteCloser) Write([]byte) (int, error) { return 0, w.err } +func (qoderErrorWriteCloser) Close() error { return nil } + +type qoderCloseRecorder struct{ closed bool } + +func (*qoderCloseRecorder) Write(p []byte) (int, error) { return len(p), nil } +func (w *qoderCloseRecorder) Close() error { w.closed = true; return nil } + +func activeQoderForwarder(stdin io.WriteCloser) *qoderStreamForwarder { + return &qoderStreamForwarder{ + name: "qoder", stdin: stdin, cmd: &exec.Cmd{}, + lines: make(chan string, 10), done: make(chan error), stderr: &lockedStringBuffer{}, + } +} + +func installImmediateQoderAfter(t *testing.T) { + t.Helper() + orig := helperAfter + helperAfter = func(time.Duration) <-chan time.Time { + ch := make(chan time.Time, 1) + ch <- time.Now() + return ch + } + t.Cleanup(func() { helperAfter = orig }) +} + +func TestQoderForwardStreamRemainingEdges(t *testing.T) { + installImmediateQoderAfter(t) + boom := errors.New("write failed") + f := activeQoderForwarder(qoderErrorWriteCloser{err: boom}) + if _, err := f.forwardStream(context.Background(), "conv", "text", nil); err == nil { + t.Fatal("write error returned nil") + } + + // Control request, assistant delta and empty final fall back to accumulated text. + stdin := &qoderTestWriteCloser{} + f = activeQoderForwarder(stdin) + f.lines <- `{"type":"control_request","request_id":"req","request":{"type":"permission"}}` + f.lines <- `{"type":"assistant","message":{"content":[{"type":"text","text":"delta"}]}}` + f.lines <- `{"type":"result","subtype":"success"}` + var deltas []string + reply, err := f.forwardStream(context.Background(), "conv", "text", func(delta string) { deltas = append(deltas, delta) }) + if err != nil || !strings.Contains(reply, "delta") || len(deltas) != 1 { + t.Fatalf("reply=%q deltas=%v err=%v", reply, deltas, err) + } + + // Backend error resets a remembered conversation. + sessions := newConvSessions("") + oldID := sessions.id("conv") + f = activeQoderForwarder(&qoderTestWriteCloser{}) + f.sessions = sessions + f.lines <- `{"type":"result","subtype":"error","error":"API Error: failed"}` + if reply, err := f.forwardStream(context.Background(), "conv", "text", nil); err != nil || !strings.Contains(reply, "failed") { + t.Fatalf("backend reply=%q err=%v", reply, err) + } + if sessions.id("conv") == oldID { + t.Fatal("backend error did not reset session") + } + + // A completed result with no result text and no deltas returns the no-text hint. + f = activeQoderForwarder(&qoderTestWriteCloser{}) + f.lines <- `{"type":"result","subtype":"success"}` + if reply, err := f.forwardStream(context.Background(), "conv", "text", nil); err != nil || reply != "(本地 agent 无文本输出)" { + t.Fatalf("empty reply=%q err=%v", reply, err) + } + + // Closed output is a read failure and clears remembered state. + f = activeQoderForwarder(&qoderTestWriteCloser{}) + f.sessions = sessions + close(f.lines) + if _, err := f.forwardStream(context.Background(), "conv", "text", nil); err == nil { + t.Fatal("closed output returned nil") + } +} + +func TestQoderEnsureLockedRemainingEdges(t *testing.T) { + origExec := qoderExecCommand + origAbs := qoderAbsPath + t.Cleanup(func() { + qoderExecCommand = origExec + qoderAbsPath = origAbs + }) + boom := errors.New("abs") + qoderAbsPath = func(string) (string, error) { return "", boom } + if got := (&qoderStreamForwarder{workDir: "relative"}).cwd(); got != "relative" { + t.Fatalf("cwd fallback=%q", got) + } + qoderAbsPath = filepath.Abs + if got := (&qoderStreamForwarder{workDir: "."}).cwd(); !filepath.IsAbs(got) { + t.Fatalf("absolute cwd=%q", got) + } + if args := (&qoderStreamForwarder{model: "model"}).commandArgs(); !strings.Contains(strings.Join(args, " "), "--model model") { + t.Fatalf("model args=%v", args) + } + + active := activeQoderForwarder(&qoderTestWriteCloser{}) + if err := active.ensureLocked(context.Background()); err != nil { + t.Fatalf("active ensure: %v", err) + } + + // A finished process is cleared and restart is attempted. Cover error/no-error + // exits and stderr/no-stderr diagnostics with a deterministic start failure. + for _, tc := range []struct { + err error + stderr string + }{{errors.New("exit"), "detail"}, {errors.New("exit"), ""}, {nil, ""}} { + f := activeQoderForwarder(&qoderTestWriteCloser{}) + f.done = make(chan error, 1) + f.done <- tc.err + _, _ = f.stderr.Write([]byte(tc.stderr)) + qoderExecCommand = func(string, ...string) *exec.Cmd { return exec.Command(filepath.Join(t.TempDir(), "missing")) } + if err := f.ensureLocked(context.Background()); err == nil { + t.Fatal("restart failure returned nil") + } + } + + // StdinPipe and StdoutPipe reject commands whose streams are preconfigured. + qoderExecCommand = func(string, ...string) *exec.Cmd { + cmd := exec.Command("sh", "-c", "exit 0") + cmd.Stdin = strings.NewReader("") + return cmd + } + if err := (&qoderStreamForwarder{name: "qoder"}).ensureLocked(context.Background()); err == nil { + t.Fatal("stdin pipe error returned nil") + } + qoderExecCommand = func(string, ...string) *exec.Cmd { + cmd := exec.Command("sh", "-c", "exit 0") + cmd.Stdout = io.Discard + return cmd + } + if err := (&qoderStreamForwarder{name: "qoder"}).ensureLocked(context.Background()); err == nil { + t.Fatal("stdout pipe error returned nil") + } +} + +func TestQoderInitializeAndIOEdges(t *testing.T) { + installImmediateQoderAfter(t) + origUUID := qoderUUIDString + qoderUUIDString = func() string { return "fixed" } + t.Cleanup(func() { qoderUUIDString = origUUID }) + boom := errors.New("write") + f := &qoderStreamForwarder{name: "qoder", stdin: qoderErrorWriteCloser{err: boom}} + if err := f.initializeLocked(context.Background()); err == nil { + t.Fatal("initialize write error returned nil") + } + + f = activeQoderForwarder(&qoderTestWriteCloser{}) + close(f.lines) + if err := f.initializeLocked(context.Background()); err == nil { + t.Fatal("initialize read error returned nil") + } + + f = activeQoderForwarder(&qoderTestWriteCloser{}) + f.lines <- `{"type":"control_request","request_id":"req","request":{"subtype":"permission"}}` + f.lines <- `{"type":"control_response","response":{"subtype":"success","request_id":"dws_init_fixed"}}` + if err := f.initializeLocked(context.Background()); err != nil { + t.Fatalf("initialize after control request: %v", err) + } + + f = activeQoderForwarder(&qoderTestWriteCloser{}) + f.lines <- `{"type":"control_response","response":{"subtype":"error","request_id":"wrong","error":"ignored"}}` + // The generated request ID is unknown, so cancel after covering the control and + // unmatched-response paths. + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if err := f.initializeLocked(ctx); err == nil { + t.Fatal("cancelled initialize returned nil") + } + + f = activeQoderForwarder(&qoderTestWriteCloser{}) + f.lines <- `{"type":"control_response","response":{"subtype":"error","request_id":"dws_init_fixed","error":"initialize failed"}}` + if err := f.initializeLocked(context.Background()); err == nil { + t.Fatal("initialize response error returned nil") + } + + if err := (&qoderStreamForwarder{stdin: &qoderTestWriteCloser{}}).writeJSONLocked(make(chan int)); err == nil { + t.Fatal("marshal error returned nil") + } + + // readLine skips noise, handles closed output with/without stderr, process + // exits with/without errors, and context cancellation. + f = activeQoderForwarder(&qoderTestWriteCloser{}) + f.lines <- "" + f.lines <- "noise" + f.lines <- `{"ok":true}` + if line, err := f.readLineLocked(context.Background()); err != nil || line == "" { + t.Fatalf("read JSON line=%q err=%v", line, err) + } + for _, stderr := range []string{"", "stderr detail"} { + f = activeQoderForwarder(&qoderTestWriteCloser{}) + _, _ = f.stderr.Write([]byte(stderr)) + close(f.lines) + if _, err := f.readLineLocked(context.Background()); err == nil { + t.Fatal("closed lines returned nil") + } + } + for _, processErr := range []error{errors.New("exit"), nil} { + f = activeQoderForwarder(&qoderTestWriteCloser{}) + f.done = make(chan error, 1) + f.done <- processErr + if _, err := f.readLineLocked(context.Background()); err == nil { + t.Fatal("process exit returned nil") + } + } + f = activeQoderForwarder(&qoderTestWriteCloser{}) + cancelled, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := f.readLineLocked(cancelled); !errors.Is(err, context.Canceled) { + t.Fatalf("cancel error=%v", err) + } +} + +func TestQoderControlAndParseRemainingEdges(t *testing.T) { + f := &qoderStreamForwarder{stdin: &qoderTestWriteCloser{}} + for _, line := range []string{ + `{`, `{"type":"other"}`, `{"type":"control_request"}`, + } { + if f.handleControlRequestLocked(line) { + t.Fatalf("unexpected handled line %q", line) + } + } + if !f.handleControlRequestLocked(`{"type":"control_request","request_id":"req","request":{"type":"permission"}}`) { + t.Fatal("type fallback control not handled") + } + + for _, tc := range []struct { + line, request string + matched bool + err bool + }{ + {`{`, "req", false, false}, + {`{"type":"other"}`, "req", false, false}, + {`{"type":"control_response","response":{"request_id":"other"}}`, "req", false, false}, + {`{"type":"control_response","response":{"request_id":"req","subtype":"error"}}`, "req", true, true}, + {`{"type":"control_response","response":{"request_id":"req","subtype":"error","error":"failed"}}`, "req", true, true}, + {`{"type":"control_response","response":{"request_id":"req","subtype":"success"}}`, "req", true, false}, + } { + matched, err := qoderControlResponse(tc.line, tc.request) + if matched != tc.matched || (err != nil) != tc.err { + t.Fatalf("control(%q)=(%v,%v)", tc.line, matched, err) + } + } + + for _, line := range []string{ + `{`, + `{"type":"assistant","message":{"content":[{"type":"tool","text":"hidden"}]}}`, + `{"type":"assistant","message":{"content":[{"type":"text","text":"visible"}]}}`, + `{"type":"result","subtype":"error","error":"failed"}`, + `{"type":"result","subtype":"error","message":{"content":[{"type":"text","text":"fallback"}]}}`, + `{"type":"result","subtype":"success","message":{"content":[{"type":"text","text":"done"}]}}`, + `{"type":"result","subtype":"success","result":"result"}`, + `{"type":"error","error":"failed"}`, + `{"type":"error"}`, + `{"type":"other"}`, + } { + _, _, _ = parseQoderPersistentLine(line) + } +} + +func TestQoderCloseAndScanEdges(t *testing.T) { + installImmediateQoderAfter(t) + closer := &qoderCloseRecorder{} + f := &qoderStreamForwarder{stdin: closer, done: make(chan error)} + if err := f.close(); err != nil || !closer.closed { + t.Fatalf("close err=%v closed=%v", err, closer.closed) + } + lines := make(chan string, 2) + scanQoderLines(bytes.NewBufferString("one\ntwo\n"), lines) + var got []string + for line := range lines { + got = append(got, line) + } + if strings.Join(got, ",") != "one,two" { + t.Fatalf("scanned=%v", got) + } +} diff --git a/internal/helpers/connect_sessions_store.go b/internal/helpers/connect_sessions_store.go index 21c9b8e6..cc0d5326 100644 --- a/internal/helpers/connect_sessions_store.go +++ b/internal/helpers/connect_sessions_store.go @@ -22,6 +22,21 @@ import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config" ) +type connectSessionTempFile interface { + Name() string + Chmod(os.FileMode) error + Write([]byte) (int, error) + Close() error +} + +var ( + connectSessionReadFile = os.ReadFile + connectSessionMkdirAll = os.MkdirAll + connectSessionCreateTemp = func(dir, pattern string) (connectSessionTempFile, error) { return os.CreateTemp(dir, pattern) } + connectSessionRename = os.Rename + connectSessionRemove = os.Remove +) + // connectSessionStorePath returns the on-disk location for a robot's // conversation→session map, scoped by clientId so multiple bots on one machine // stay isolated: /connect//sessions.json. An empty @@ -44,7 +59,7 @@ func loadConvSessionMap(path string) map[string]string { if path == "" { return make(map[string]string) } - raw, err := os.ReadFile(path) + raw, err := connectSessionReadFile(path) if err != nil { if !os.IsNotExist(err) { fmt.Fprintf(os.Stderr, "[connect][session][warn] 读取会话存档失败,按空会话起:%v\n", err) @@ -70,17 +85,12 @@ func saveConvSessionMap(path string, m map[string]string) { return } dir := filepath.Dir(path) - if err := os.MkdirAll(dir, 0o700); err != nil { + if err := connectSessionMkdirAll(dir, 0o700); err != nil { fmt.Fprintf(os.Stderr, "[connect][session][warn] 创建会话存档目录失败,跳过本次落盘:%v\n", err) return } - data, err := json.Marshal(m) - if err != nil { - // map[string]string never fails to marshal, but stay defensive. - fmt.Fprintf(os.Stderr, "[connect][session][warn] 序列化会话失败,跳过本次落盘:%v\n", err) - return - } - tmp, err := os.CreateTemp(dir, "sessions-*.json.tmp") + data, _ := json.Marshal(m) + tmp, err := connectSessionCreateTemp(dir, "sessions-*.json.tmp") if err != nil { fmt.Fprintf(os.Stderr, "[connect][session][warn] 创建会话临时文件失败,跳过本次落盘:%v\n", err) return @@ -88,23 +98,23 @@ func saveConvSessionMap(path string, m map[string]string) { tmpName := tmp.Name() if err := tmp.Chmod(config.FilePerm); err != nil { tmp.Close() - _ = os.Remove(tmpName) + _ = connectSessionRemove(tmpName) fmt.Fprintf(os.Stderr, "[connect][session][warn] 设置会话文件权限失败,跳过本次落盘:%v\n", err) return } if _, err := tmp.Write(data); err != nil { tmp.Close() - _ = os.Remove(tmpName) + _ = connectSessionRemove(tmpName) fmt.Fprintf(os.Stderr, "[connect][session][warn] 写入会话临时文件失败,跳过本次落盘:%v\n", err) return } if err := tmp.Close(); err != nil { - _ = os.Remove(tmpName) + _ = connectSessionRemove(tmpName) fmt.Fprintf(os.Stderr, "[connect][session][warn] 关闭会话临时文件失败,跳过本次落盘:%v\n", err) return } - if err := os.Rename(tmpName, path); err != nil { - _ = os.Remove(tmpName) + if err := connectSessionRename(tmpName, path); err != nil { + _ = connectSessionRemove(tmpName) fmt.Fprintf(os.Stderr, "[connect][session][warn] 原子替换会话存档失败,跳过本次落盘:%v\n", err) return } diff --git a/internal/helpers/connect_sessions_store_coverage_more_test.go b/internal/helpers/connect_sessions_store_coverage_more_test.go new file mode 100644 index 00000000..a17112c6 --- /dev/null +++ b/internal/helpers/connect_sessions_store_coverage_more_test.go @@ -0,0 +1,64 @@ +package helpers + +import ( + "errors" + "os" + "testing" +) + +type failingSessionTempFile struct { + chmodErr error + writeErr error + closeErr error +} + +func (*failingSessionTempFile) Name() string { return "sessions.tmp" } +func (f *failingSessionTempFile) Chmod(os.FileMode) error { return f.chmodErr } +func (f *failingSessionTempFile) Write(p []byte) (int, error) { + if f.writeErr != nil { + return 0, f.writeErr + } + return len(p), nil +} +func (f *failingSessionTempFile) Close() error { return f.closeErr } + +func TestConnectSessionStoreFailureCoverage(t *testing.T) { + originalRead := connectSessionReadFile + originalMkdir := connectSessionMkdirAll + originalCreate := connectSessionCreateTemp + originalRename := connectSessionRename + originalRemove := connectSessionRemove + t.Cleanup(func() { + connectSessionReadFile = originalRead + connectSessionMkdirAll = originalMkdir + connectSessionCreateTemp = originalCreate + connectSessionRename = originalRename + connectSessionRemove = originalRemove + }) + boom := errors.New("session store failure") + + connectSessionReadFile = func(string) ([]byte, error) { return nil, boom } + if got := loadConvSessionMap("sessions.json"); len(got) != 0 { + t.Fatalf("read failure map = %#v", got) + } + connectSessionReadFile = originalRead + + connectSessionMkdirAll = func(string, os.FileMode) error { return boom } + saveConvSessionMap("dir/sessions.json", map[string]string{"c": "s"}) + connectSessionMkdirAll = func(string, os.FileMode) error { return nil } + connectSessionCreateTemp = func(string, string) (connectSessionTempFile, error) { return nil, boom } + saveConvSessionMap("dir/sessions.json", map[string]string{"c": "s"}) + + connectSessionRemove = func(string) error { return nil } + for _, file := range []*failingSessionTempFile{ + {chmodErr: boom}, + {writeErr: boom}, + {closeErr: boom}, + } { + connectSessionCreateTemp = func(string, string) (connectSessionTempFile, error) { return file, nil } + saveConvSessionMap("dir/sessions.json", map[string]string{"c": "s"}) + } + connectSessionCreateTemp = func(string, string) (connectSessionTempFile, error) { return &failingSessionTempFile{}, nil } + connectSessionRename = func(string, string) error { return boom } + saveConvSessionMap("dir/sessions.json", map[string]string{"c": "s"}) +} diff --git a/internal/helpers/connect_stream.go b/internal/helpers/connect_stream.go index 3b52e31b..150be5dc 100644 --- a/internal/helpers/connect_stream.go +++ b/internal/helpers/connect_stream.go @@ -85,6 +85,8 @@ type connectChatReplier interface { type connectMediaClient interface { downloadMessageFile(context.Context, string, string) (string, error) + downloadMessageFileNamed(context.Context, string, string, string) (string, error) + downloadRecoveredChatRecordFile(context.Context, fileInboundInfo) (string, error) getUserUnionID(context.Context, string) (string, error) downloadDentryFile(context.Context, int64, int64, string, string) (string, error) } diff --git a/internal/helpers/connect_stream_connector_coverage_test.go b/internal/helpers/connect_stream_connector_coverage_test.go index 2fdcd37d..a95b3b35 100644 --- a/internal/helpers/connect_stream_connector_coverage_test.go +++ b/internal/helpers/connect_stream_connector_coverage_test.go @@ -84,6 +84,14 @@ func (m *fakeConnectMedia) downloadMessageFile(context.Context, string, string) return m.messagePath, m.messageErr } +func (m *fakeConnectMedia) downloadMessageFileNamed(context.Context, string, string, string) (string, error) { + return m.messagePath, m.messageErr +} + +func (m *fakeConnectMedia) downloadRecoveredChatRecordFile(context.Context, fileInboundInfo) (string, error) { + return m.messagePath, m.messageErr +} + func (m *fakeConnectMedia) getUserUnionID(context.Context, string) (string, error) { return m.unionID, m.unionErr } @@ -242,14 +250,14 @@ func runConnectorScenarioWithCard(t *testing.T, messages []*chatbot.BotCallbackD } func TestRunStreamConnectorBasicMessageEdges(t *testing.T) { - t.Run("blank and missing webhook drop", func(t *testing.T) { + t.Run("blank fallback and missing webhook drop", func(t *testing.T) { fwd := &basicConnectorForwarder{reply: "unused"} blank := connectorMessage("blank", "") noWebhook := connectorMessage("webhook", "hello") noWebhook.SessionWebhook = "" runConnectorScenario(t, []*chatbot.BotCallbackDataModel{blank, noWebhook}, fwd, nil, nil, nil, 0) - if len(fwd.prompts) != 0 { - t.Fatalf("dropped messages forwarded: %#v", fwd.prompts) + if len(fwd.prompts) != 1 || !strings.Contains(fwd.prompts[0], "原始消息 JSON") { + t.Fatalf("fallback messages forwarded: %#v", fwd.prompts) } }) @@ -533,7 +541,7 @@ func TestRunStreamConnectorMediaEdges(t *testing.T) { m.Content = map[string]any{"downloadCode": "code", "fileName": "report.txt"} return m }(), - media: &fakeConnectMedia{messagePath: "/tmp/report.txt"}, want: "用户发来一个文件", + media: &fakeConnectMedia{messagePath: "/tmp/report.txt"}, want: "/tmp/report.txt", }, { name: "client file failure", @@ -543,7 +551,7 @@ func TestRunStreamConnectorMediaEdges(t *testing.T) { m.Content = map[string]any{"downloadCode": "code", "fileName": "report.txt"} return m }(), - media: &fakeConnectMedia{messageErr: errors.New("download")}, want: "文件下载失败", + media: &fakeConnectMedia{messageErr: errors.New("download")}, want: "原始内容下载失败", }, { name: "dentry success", diff --git a/internal/helpers/connect_streaming.go b/internal/helpers/connect_streaming.go index c6f685d3..549f1af7 100644 --- a/internal/helpers/connect_streaming.go +++ b/internal/helpers/connect_streaming.go @@ -22,6 +22,8 @@ import ( "strings" ) +var connectStreamCommandContext = exec.CommandContext + // Incremental forwarding: channels whose CLI can emit stream-json get their // reply streamed into the AI card as it is produced (the hermes/openclaw // experience), instead of one shot at the end. @@ -54,7 +56,7 @@ func (f *execForwarder) forwardStream(ctx context.Context, convID, text string, run := func() (string, string, error) { args := f.commandArgs(f.streamArgv, convID, text) - cmd := exec.CommandContext(ctx, f.streamArgv[0], args...) + cmd := connectStreamCommandContext(ctx, f.streamArgv[0], args...) f.configureCommand(cmd) stdout, err := cmd.StdoutPipe() if err != nil { diff --git a/internal/helpers/connect_streaming_coverage_more_test.go b/internal/helpers/connect_streaming_coverage_more_test.go new file mode 100644 index 00000000..e6363bb7 --- /dev/null +++ b/internal/helpers/connect_streaming_coverage_more_test.go @@ -0,0 +1,108 @@ +package helpers + +import ( + "context" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +func writeStreamingAgent(t *testing.T, body string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "agent") + if err := os.WriteFile(path, []byte("#!/bin/sh\n"+body+"\n"), 0o755); err != nil { + t.Fatal(err) + } + return path +} + +func streamingExecForwarder(bin string) *execForwarder { + return &execForwarder{ + name: "claudecode", + argv: []string{bin}, + streamArgv: []string{bin}, + parser: "cc", + timeout: 5 * time.Second, + } +} + +func TestExecForwarderStreamingRemainingCoverage(t *testing.T) { + // Streaming-disabled forwarders use their ordinary one-shot command. + oneShot := writeStreamingAgent(t, `printf 'one-shot\n'`) + f := streamingExecForwarder(oneShot) + f.streamArgv = nil + if reply, err := f.forwardStream(context.Background(), "", "prompt", func(string) {}); err != nil || reply != "one-shot" { + t.Fatalf("fallback reply=%q err=%v", reply, err) + } + + // StdoutPipe failures are surfaced as invocation failures. + origCommand := connectStreamCommandContext + connectStreamCommandContext = func(ctx context.Context, name string, args ...string) *exec.Cmd { + cmd := exec.CommandContext(ctx, name, args...) + cmd.Stdout = io.Discard + return cmd + } + if _, err := streamingExecForwarder(oneShot).forwardStream(context.Background(), "", "prompt", func(string) {}); err == nil { + t.Fatal("StdoutPipe failure returned nil") + } + connectStreamCommandContext = origCommand + t.Cleanup(func() { connectStreamCommandContext = origCommand }) + + // A missing executable reaches the process-start failure path. + if _, err := streamingExecForwarder(filepath.Join(t.TempDir(), "missing")).forwardStream(context.Background(), "", "prompt", func(string) {}); err == nil { + t.Fatal("start failure returned nil") + } + + // Ignore blank/non-JSON lines, publish deltas, and turn a bare backend + // error into an actionable reply while clearing the stale session. + backend := writeStreamingAgent(t, ` +printf '\nnoise\n' +printf '%s\n' '{"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"partial"}}}' +printf '%s\n' '{"type":"result","result":"API Error: failed"}'`) + sessions := newConvSessions("") + oldID := sessions.id("conv") + f = streamingExecForwarder(backend) + f.sessions = sessions + var deltas []string + reply, err := f.forwardStream(context.Background(), "conv", "prompt", func(s string) { deltas = append(deltas, s) }) + if err != nil || !strings.Contains(reply, "failed") || len(deltas) != 1 || !strings.Contains(deltas[0], "partial") { + t.Fatalf("backend reply=%q deltas=%v err=%v", reply, deltas, err) + } + if sessions.id("conv") == oldID { + t.Fatal("backend error did not reset session") + } + + // A successful command without visible events returns the no-text hint. + empty := writeStreamingAgent(t, `printf 'metadata\n'`) + if reply, err := streamingExecForwarder(empty).forwardStream(context.Background(), "", "prompt", func(string) {}); err != nil || reply != "(本地 agent 无文本输出)" { + t.Fatalf("empty reply=%q err=%v", reply, err) + } + + // A non-session process failure prefers stderr and reports the final error. + failure := writeStreamingAgent(t, `echo 'plain failure' >&2; exit 1`) + if _, err := streamingExecForwarder(failure).forwardStream(context.Background(), "", "prompt", func(string) {}); err == nil || !strings.Contains(err.Error(), "plain failure") { + t.Fatalf("plain failure err=%v", err) + } + + // A failing remembered session is cleared even when it is not retryable. + sessions = newConvSessions("") + oldID = sessions.id("conv") + f = streamingExecForwarder(failure) + f.sessions = sessions + if _, err := f.forwardStream(context.Background(), "conv", "prompt", func(string) {}); err == nil { + t.Fatal("session failure returned nil") + } + if sessions.id("conv") == oldID { + t.Fatal("terminal stream failure did not reset session") + } +} + +func TestParseStreamLineCCInvalidJSON(t *testing.T) { + if delta, final := parseStreamLine("cc", `{not json`); delta != "" || final != "" { + t.Fatalf("invalid cc JSON delta=%q final=%q", delta, final) + } +} diff --git a/internal/helpers/contact.go b/internal/helpers/contact.go index 3a173690..221c10b3 100644 --- a/internal/helpers/contact.go +++ b/internal/helpers/contact.go @@ -600,9 +600,6 @@ contact user profile fields 获取可用字段列表。 {contactDeptListMembersCmd, []string{"ids", "id", "dept-id", "dept-ids"}}, } { for _, name := range s.aliases { - if s.cmd.Flags().Lookup(name) != nil { - continue // 已是主 flag,跳过 - } s.cmd.Flags().String(name, "", "部门 ID 别名(等价于当前命令的主 flag)") _ = s.cmd.Flags().MarkHidden(name) } diff --git a/internal/helpers/contact_coverage_more_test.go b/internal/helpers/contact_coverage_more_test.go new file mode 100644 index 00000000..da484221 --- /dev/null +++ b/internal/helpers/contact_coverage_more_test.go @@ -0,0 +1,47 @@ +package helpers + +import ( + "strings" + "testing" + + "github.com/spf13/cobra" +) + +func TestContactRemainingCompatibilityBranches(t *testing.T) { + cmd := &cobra.Command{Use: "flags"} + cmd.Flags().String("id", "", "") + if got := contactFirstSetFlagName(cmd, "id"); got != "id" { + t.Fatalf("default flag name=%q", got) + } + if got := contactFirstSetFlagName(cmd); got != "" { + t.Fatalf("empty flag name=%q", got) + } + + for _, args := range [][]string{ + {"user", "get", "--ids", "me"}, + {"get", "--ids", "self"}, + } { + installScriptedCaller(t, &scriptedToolCaller{dry: true}) + if err := executeFilterCoverage(t, newContactCommand(), args...); err == nil || !strings.Contains(err.Error(), "真实的 userId") { + t.Fatalf("args=%v placeholder err=%v", args, err) + } + } + + installScriptedCaller(t, &scriptedToolCaller{dry: true}) + if err := executeFilterCoverage(t, newContactCommand(), "get", "--dept", "1"); err != nil { + t.Fatalf("compat dept get: %v", err) + } + + root := newContactCommand() + hint := findCoverageSubcommand(t, root, "department") + if err := hint.RunE(hint, []string{"--help"}); err != nil { + t.Fatalf("hint help: %v", err) + } + if err := hint.RunE(hint, []string{"unexpected"}); err == nil || !strings.Contains(err.Error(), "use: dws contact dept") { + t.Fatalf("hint guidance err=%v", err) + } + + if err := executeFilterCoverage(t, newContactCommand(), "user", "get", "--unknown"); err == nil || !strings.Contains(err.Error(), "See '") { + t.Fatalf("flag help hint err=%v", err) + } +} diff --git a/internal/helpers/coverage_pure_more_test.go b/internal/helpers/coverage_pure_more_test.go index 67de81bd..2f66ae72 100644 --- a/internal/helpers/coverage_pure_more_test.go +++ b/internal/helpers/coverage_pure_more_test.go @@ -75,12 +75,6 @@ func TestPureScalarAndCommandHelpersCoverage(t *testing.T) { for _, value := range []any{json.Number("1"), json.Number("bad"), float64(2), float64(-1), int64(3), 4, "5", "bad", true} { _, _ = int64FromJSONScalar(value) } - if err := printMCPText(`{"ok":true}`); err != nil { - t.Fatal(err) - } - if err := printMCPText("not-json"); err != nil { - t.Fatal(err) - } } func TestChartMailAndSheetPureHelpersCoverage(t *testing.T) { diff --git a/internal/helpers/devapp_command_edges_more_test.go b/internal/helpers/devapp_command_edges_more_test.go new file mode 100644 index 00000000..f1b5027f --- /dev/null +++ b/internal/helpers/devapp_command_edges_more_test.go @@ -0,0 +1,174 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package helpers + +import ( + "bytes" + "errors" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor" + "github.com/spf13/cobra" +) + +func executeDevAppEdge(runner executor.Runner, args ...string) (string, error) { + root := newDevAppTestRoot(runner) + var out bytes.Buffer + root.SetOut(&out) + root.SetErr(&out) + root.SetArgs(append([]string{"dev", "app"}, args...)) + err := root.Execute() + return out.String(), err +} + +func TestDevAppParentHelpCoverage(t *testing.T) { + for _, path := range [][]string{ + {}, {"webapp"}, {"permission"}, {"credentials"}, {"member"}, + {"security"}, {"robot"}, {"version"}, {"event"}, + } { + if _, err := executeDevAppEdge(&captureRunner{}, path...); err != nil { + t.Fatalf("help %v: %v", path, err) + } + } +} + +func TestDevAppValidationBranchesCoverage(t *testing.T) { + cases := []struct { + name string + args []string + }{ + {"event list id", []string{"event", "list"}}, + {"event subscribe guard", []string{"event", "subscribe"}}, + {"event subscribe id", []string{"event", "subscribe", "--dry-run"}}, + {"event subscribe codes", []string{"event", "subscribe", "--dry-run", "--unified-app-id", "app"}}, + {"event unsubscribe guard", []string{"event", "unsubscribe"}}, + {"event unsubscribe id", []string{"event", "unsubscribe", "--dry-run"}}, + {"event unsubscribe codes", []string{"event", "unsubscribe", "--dry-run", "--unified-app-id", "app"}}, + {"get id", []string{"get"}}, + {"create name", []string{"create", "--dry-run"}}, + {"update guard", []string{"update"}}, + {"update id", []string{"update", "--dry-run"}}, + {"update fields", []string{"update", "--dry-run", "--unified-app-id", "app"}}, + {"credentials id", []string{"credentials", "get"}}, + {"disable guard", []string{"disable"}}, + {"enable id", []string{"enable", "--dry-run"}}, + {"delete guard", []string{"delete"}}, + {"delete id", []string{"delete", "--dry-run"}}, + {"delete confirm", []string{"delete", "--yes", "--unified-app-id", "app"}}, + {"webapp get id", []string{"webapp", "get"}}, + {"webapp config guard", []string{"webapp", "config"}}, + {"webapp config id", []string{"webapp", "config", "--dry-run"}}, + {"webapp config fields", []string{"webapp", "config", "--dry-run", "--unified-app-id", "app"}}, + {"permission list id", []string{"permission", "list"}}, + {"permission add guard", []string{"permission", "add"}}, + {"permission add id", []string{"permission", "add", "--dry-run"}}, + {"permission add scopes", []string{"permission", "add", "--dry-run", "--unified-app-id", "app"}}, + {"permission remove guard", []string{"permission", "remove"}}, + {"permission remove id", []string{"permission", "remove", "--dry-run"}}, + {"permission remove scopes", []string{"permission", "remove", "--dry-run", "--unified-app-id", "app"}}, + {"member list id", []string{"member", "list"}}, + {"member add guard", []string{"member", "add"}}, + {"member add id", []string{"member", "add", "--dry-run"}}, + {"security guard", []string{"security", "config"}}, + {"security id", []string{"security", "config", "--dry-run"}}, + {"security fields", []string{"security", "config", "--dry-run", "--unified-app-id", "app"}}, + {"robot submit guard", []string{"robot", "submit"}}, + {"robot submit params", []string{"robot", "submit", "--dry-run"}}, + {"robot result task", []string{"robot", "result"}}, + {"robot get id", []string{"robot", "get"}}, + {"robot config guard", []string{"robot", "config"}}, + {"robot config id", []string{"robot", "config", "--dry-run"}}, + {"robot config fields", []string{"robot", "config", "--dry-run", "--unified-app-id", "app"}}, + {"robot enable guard", []string{"robot", "enable"}}, + {"robot enable id", []string{"robot", "enable", "--dry-run"}}, + {"robot disable guard", []string{"robot", "disable"}}, + {"robot disable id", []string{"robot", "disable", "--dry-run"}}, + {"version create id", []string{"version", "create", "--dry-run"}}, + {"version list id", []string{"version", "list"}}, + {"version get app", []string{"version", "get"}}, + {"version get version", []string{"version", "get", "--unified-app-id", "app"}}, + {"version approval app", []string{"version", "check-approval"}}, + {"version publish guard", []string{"version", "publish"}}, + {"version publish app", []string{"version", "publish", "--dry-run"}}, + {"version status app", []string{"version", "status"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if _, err := executeDevAppEdge(&captureRunner{}, tc.args...); err == nil { + t.Fatalf("Execute(%v) returned nil", tc.args) + } + }) + } +} + +func TestDevAppOptionalMutationBranchesCoverage(t *testing.T) { + success := [][]string{ + {"update", "--dry-run", "--unified-app-id", "app", "--icon-media-id", "media"}, + {"delete", "--dry-run", "--unified-app-id", "app"}, + {"webapp", "config", "--dry-run", "--unified-app-id", "app", "--h5-page-type", "HOME", "--omp-url", "https://omp.invalid"}, + {"robot", "config", "--dry-run", "--unified-app-id", "app", "--disable-ssl-verify", "--i18n-name", `{"zh_CN":"助手"}`}, + } + for _, args := range success { + if _, err := executeDevAppEdge(&captureRunner{}, args...); err != nil { + t.Fatalf("Execute(%v): %v", args, err) + } + } + if _, err := executeDevAppEdge(&captureRunner{}, "robot", "config", "--dry-run", "--unified-app-id", "app", "--mode", "bad"); err == nil { + t.Fatal("invalid robot mode returned nil") + } + if _, err := executeDevAppEdge(&captureRunner{}, "robot", "config", "--dry-run", "--unified-app-id", "app", "--i18n-brief", "{"); err == nil { + t.Fatal("invalid i18n JSON returned nil") + } +} + +func TestDevAppRobotCreateRequiredFieldsCoverage(t *testing.T) { + cmd := newDevAppRobotSubmitCommand(&captureRunner{}) + if _, err := devAppRobotCreateParams(cmd); err == nil { + t.Fatal("missing name returned nil") + } + _ = cmd.Flags().Set("name", "app") + if _, err := devAppRobotCreateParams(cmd); err == nil { + t.Fatal("missing robot name returned nil") + } + _ = cmd.Flags().Set("robot-name", "robot") + if _, err := devAppRobotCreateParams(cmd); err == nil { + t.Fatal("missing desc returned nil") + } +} + +func TestDevAppDeleteFetchErrorCoverage(t *testing.T) { + want := errors.New("fetch app failed") + _, err := executeDevAppEdge(devAppErrorRunner{err: want}, "delete", "--yes", "--unified-app-id", "app", "--confirm-name", "name") + if !errors.Is(err, want) { + t.Fatalf("delete error = %v", err) + } +} + +func TestDevAppPrettyAndAnnotationEdges(t *testing.T) { + cmd := &cobra.Command{Use: "test"} + annotateDevAppTool(cmd, "tool") + if cmd.Annotations["mcp-tool"] != "tool" { + t.Fatalf("annotations = %#v", cmd.Annotations) + } + + // Pretty output takes a separate annotation path after normalization. + runner := &devAppResponseRunner{response: map[string]any{ + "content": map[string]any{"success": true, "result": map[string]any{"id": "app"}}, + }} + out, err := executeDevAppEdge(runner, "list", "--format", "pretty") + if err != nil || !strings.Contains(out, "app") { + t.Fatalf("pretty list err=%v out=%q", err, out) + } +} diff --git a/internal/helpers/devapp_connect.go b/internal/helpers/devapp_connect.go index 3ab95747..832d8394 100644 --- a/internal/helpers/devapp_connect.go +++ b/internal/helpers/devapp_connect.go @@ -44,6 +44,17 @@ var connectChannels = func() map[string]struct{} { return m }() +// These indirections keep the command's process/network boundaries testable. +// Production always uses the real implementations; unit tests replace only the +// final side-effecting step while exercising the full option/validation flow. +var ( + devAppRunStreamConnector = runStreamConnector + devAppLoadConnectKnowledge = loadConnectKnowledgeSource + devAppConnectStdinInteractive = connectStdinInteractive + devAppRunConnectOnboarding = runConnectOnboarding + devAppStartConnectDaemon = startDaemon +) + // resolveConnectChannel resolves the current agent channel using "explicit wins, // then signal fallback". Priority: --channel flag > DWS_AGENT_CHANNEL env var > // each agent's known runtime signal. Returns the channel name and the basis for @@ -284,7 +295,7 @@ func launchConnector(cmd *cobra.Command, runner executor.Runner, channel, client fmt.Fprintf(cmd.ErrOrStderr(), "[connect] 知识库已加载:%d 个片段(%s)\n", len(kb.chunks), opts.KnowledgeDir) } if opts.KnowledgeSource != "" { - if kb, kerr := loadConnectKnowledgeSource(cmd, runner, clientID, opts.KnowledgeSource); kerr != nil { + if kb, kerr := devAppLoadConnectKnowledge(cmd, runner, clientID, opts.KnowledgeSource); kerr != nil { return kerr } else if kb != nil { extras.kb = kb @@ -294,7 +305,7 @@ func launchConnector(cmd *cobra.Command, runner executor.Runner, channel, client // chunks into the same retriever (knowledgeBase is just a chunk slice), so // a role can carry several wiki/doc/dir sources without a flag per source. for _, src := range opts.KnowledgeSources { - kb, kerr := loadConnectKnowledgeSource(cmd, runner, clientID, src) + kb, kerr := devAppLoadConnectKnowledge(cmd, runner, clientID, src) if kerr != nil { return kerr } @@ -365,7 +376,7 @@ func launchConnector(cmd *cobra.Command, runner executor.Runner, channel, client fmt.Fprintf(cmd.ErrOrStderr(), "[connect] 提示:机器人默认在空白临时目录里跑、不带你本地项目的上下文,回答可能不如终端准。要对齐终端:加 --agent-workdir <你的项目目录> 让它读到同样的文件,或 --knowledge-dir/--knowledge-source 挂资料。\n") } fmt.Fprintf(cmd.ErrOrStderr(), "[connect] channel=%s Go 原生 Stream 建联,转发到 %s,回复样式=%s(Ctrl-C 退出)\n", channel, fwd.label(), replyStyle) - return runStreamConnector(cmd.Context(), channel, clientID, clientSecret, fwd, cardCli, extras) + return devAppRunStreamConnector(cmd.Context(), channel, clientID, clientSecret, fwd, cardCli, extras) } // hermes / openclaw run their own official bot provisioning + reply logic @@ -506,12 +517,12 @@ func newDevAppRobotConnectCommand(runner executor.Runner) *cobra.Command { } clientID, clientSecret = id, secret resolvedBy = "unified-app-id:credentials get" - case !commandDryRun(cmd) && connectStdinInteractive(): + case !commandDryRun(cmd) && devAppConnectStdinInteractive(): // No credentials and a real terminal: guide the user through // provisioning a new robot app or picking an existing one, // instead of failing. Scripts/daemons/pipes and dry-run fall // through to the explicit-flag requirement below. - creds, oerr := runConnectOnboarding(runner, cmd, cmd.InOrStdin(), cmd.OutOrStdout()) + creds, oerr := devAppRunConnectOnboarding(runner, cmd, cmd.InOrStdin(), cmd.OutOrStdout()) if oerr != nil { return oerr } @@ -551,7 +562,7 @@ func newDevAppRobotConnectCommand(runner executor.Runner) *cobra.Command { notifyStaffID := devAppStringFlag(cmd, "notify-staff-id") profile, _ := cmd.Root().PersistentFlags().GetString("profile") alwaysOn, _ := cmd.Flags().GetBool("alwayson") - return startDaemon(cmd, daemonDirKey(clientID, unifiedAppID), clientID, unifiedAppID, channel, notifyStaffID, strings.TrimSpace(profile), alwaysOn) + return devAppStartConnectDaemon(cmd, daemonDirKey(clientID, unifiedAppID), clientID, unifiedAppID, channel, notifyStaffID, strings.TrimSpace(profile), alwaysOn) } fmt.Fprintf(cmd.ErrOrStderr(), "[connect] channel=%s(%s)凭证来源=%s\n", channel, detectedBy, resolvedBy) diff --git a/internal/helpers/devapp_connect_coverage_more_test.go b/internal/helpers/devapp_connect_coverage_more_test.go new file mode 100644 index 00000000..4bdbc128 --- /dev/null +++ b/internal/helpers/devapp_connect_coverage_more_test.go @@ -0,0 +1,373 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package helpers + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor" + "github.com/spf13/cobra" +) + +type connectCoverageRunner struct { + response map[string]any + err error +} + +func (r connectCoverageRunner) Run(_ context.Context, inv executor.Invocation) (executor.Result, error) { + return executor.Result{Invocation: inv, Response: r.response}, r.err +} + +func connectCoverageCommand(t *testing.T) (*cobra.Command, *bytes.Buffer) { + t.Helper() + cmd := newDevAppRobotConnectCommand(&captureRunner{}) + buf := &bytes.Buffer{} + cmd.SetOut(buf) + cmd.SetErr(buf) + cmd.SetContext(context.Background()) + return cmd, buf +} + +func writeKnowledgeFile(t *testing.T, dir, name string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, name), []byte("# handbook\ncovered knowledge"), 0o600); err != nil { + t.Fatal(err) + } +} + +func TestBuildConnectPlanQoderAndOpencodeCoverage(t *testing.T) { + for _, ch := range []string{"qoder", "qoderwork", "opencode", "gemini"} { + if got := buildConnectPlan(ch, "id", "robot")["method"]; got == "unknown" { + t.Fatalf("buildConnectPlan(%q) = unknown", ch) + } + } +} + +func TestLaunchConnectorExternalAndGuidanceCoverage(t *testing.T) { + cmd, out := connectCoverageCommand(t) + binDir := t.TempDir() + writeShellExecutable(t, binDir, "connector-ok", "test \"$CID:$SEC:$DWS_AGENT_CHANNEL\" = \"id:secret:hermes\"\n") + writeShellExecutable(t, binDir, "connector-fail", "exit 7\n") + t.Setenv("DWS_CONNECT_CMD", filepath.Join(binDir, "connector-ok")) + if err := launchConnector(cmd, &captureRunner{}, "hermes", "id", "secret", connectAgentOptions{}); err != nil { + t.Fatalf("external connector: %v\n%s", err, out.String()) + } + t.Setenv("DWS_CONNECT_CMD", filepath.Join(binDir, "connector-fail")) + if err := launchConnector(cmd, &captureRunner{}, "hermes", "id", "secret", connectAgentOptions{}); err == nil { + t.Fatal("failing external connector returned nil") + } + + t.Setenv("DWS_CONNECT_CMD", "") + if err := launchConnector(cmd, &captureRunner{}, "hermes", "id", "secret", connectAgentOptions{}); err != nil { + t.Fatalf("official guidance: %v", err) + } + if err := launchConnector(cmd, &captureRunner{}, "unknown", "id", "secret", connectAgentOptions{}); err == nil { + t.Fatal("unknown connector returned nil") + } +} + +func TestLaunchConnectorValidationAndKnowledgeCoverage(t *testing.T) { + t.Setenv("DWS_CONNECT_CMD", "") + t.Setenv("DWS_AGENT_CMD", "sh -c printf\\ ok") + cmd, _ := connectCoverageCommand(t) + + badRole := filepath.Join(t.TempDir(), "missing.yaml") + if err := launchConnector(cmd, &captureRunner{}, "custom", "id", "secret", connectAgentOptions{RoleConfigPath: badRole}); err == nil { + t.Fatal("missing role returned nil") + } + roleDir := t.TempDir() + rolePath := filepath.Join(roleDir, "role.yaml") + if err := os.WriteFile(rolePath, []byte("name: role\nclient_id: other\nowner_user_id: owner\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := launchConnector(cmd, &captureRunner{}, "custom", "id", "secret", connectAgentOptions{RoleConfigPath: rolePath}); err == nil { + t.Fatal("mismatched role returned nil") + } + + t.Setenv("DWS_AGENT_CMD", "") + if err := launchConnector(cmd, &captureRunner{}, "custom", "id", "secret", connectAgentOptions{}); err == nil { + t.Fatal("missing custom agent command returned nil") + } + t.Setenv("DWS_AGENT_CMD", "sh -c printf\\ ok") + if err := launchConnector(cmd, &captureRunner{}, "custom", "id", "secret", connectAgentOptions{KnowledgeDir: filepath.Join(t.TempDir(), "missing")}); err == nil { + t.Fatal("missing knowledge directory returned nil") + } + + knowledge := t.TempDir() + writeKnowledgeFile(t, knowledge, "guide.md") + if err := launchConnector(cmd, &captureRunner{}, "custom", "id", "secret", connectAgentOptions{KnowledgeSource: filepath.Join(knowledge, "missing")}); err == nil { + t.Fatal("missing knowledge source returned nil") + } +} + +func TestLaunchConnectorFullStreamAssemblyCoverage(t *testing.T) { + t.Setenv("DWS_CONNECT_CMD", "") + t.Setenv("DWS_AGENT_CMD", "sh -c printf\\ ok") + t.Setenv("DWS_CONFIG_DIR", t.TempDir()) + knowledgeA := t.TempDir() + knowledgeB := t.TempDir() + writeKnowledgeFile(t, knowledgeA, "a.md") + writeKnowledgeFile(t, knowledgeB, "b.txt") + rolePath := filepath.Join(t.TempDir(), "role.yaml") + role := fmt.Sprintf("name: helper\nclient_id: id\nowner_user_id: owner\nconfirm_policy: auto\npersona: precise\nknowledge_sources:\n - %s\n - %s\nallowed_scopes:\n - todo\n", knowledgeA, knowledgeB) + if err := os.WriteFile(rolePath, []byte(role), 0o600); err != nil { + t.Fatal(err) + } + + origRun := devAppRunStreamConnector + origLoad := devAppLoadConnectKnowledge + t.Cleanup(func() { + devAppRunStreamConnector = origRun + devAppLoadConnectKnowledge = origLoad + }) + var calls int + devAppLoadConnectKnowledge = func(cmd *cobra.Command, runner executor.Runner, clientID, raw string) (*knowledgeBase, error) { + calls++ + switch raw { + case "nil-source": + return nil, nil + case "error-source": + return nil, errors.New("source failed") + default: + return loadConnectKnowledgeSource(cmd, runner, clientID, raw) + } + } + var gotExtras *connectExtras + var gotCard *aiCardClient + devAppRunStreamConnector = func(_ context.Context, channel, clientID, clientSecret string, fwd forwarder, card *aiCardClient, extras *connectExtras) error { + if channel != "custom" || clientID != "id" || clientSecret != "secret" || fwd == nil { + return fmt.Errorf("unexpected stream arguments") + } + gotExtras, gotCard = extras, card + return nil + } + + cmd, out := connectCoverageCommand(t) + opts := connectAgentOptions{ + RoleConfigPath: rolePath, ReplyCard: true, CardTemplate: "card-template", + KnowledgeDir: knowledgeA, KnowledgeSource: knowledgeB, + KnowledgeSources: []string{"nil-source", knowledgeA, knowledgeB}, + AllowedUsers: []string{"u1"}, AllowedGroups: []string{"g1"}, UserRateLimit: 3, + OwnerUserID: "owner", ApprovalCardTemplate: "approval-template", + AuditSheetNode: "node", AuditSheetTab: "tab", RoleScopes: []string{"todo"}, ConfirmPolicy: "auto", + } + if err := launchConnector(cmd, &captureRunner{}, "custom", "id", "secret", opts); err != nil { + t.Fatalf("launchConnector: %v\n%s", err, out.String()) + } + if calls < 5 || gotExtras == nil || gotExtras.kb == nil || gotExtras.gate == nil || gotExtras.approval == nil || gotCard == nil { + t.Fatalf("assembled connector incomplete: calls=%d extras=%+v card=%v", calls, gotExtras, gotCard) + } + if err := launchConnector(cmd, &captureRunner{}, "custom", "id", "secret", connectAgentOptions{ + KnowledgeSources: []string{knowledgeA}, + }); err != nil { + t.Fatalf("first role knowledge source: %v", err) + } + + // No card template selects the text-approval path and also covers the + // card-enabled/plain-template reply style. A cancelled context stops retry. + ctx, cancel := context.WithCancel(context.Background()) + cmd.SetContext(ctx) + if err := launchConnector(cmd, &captureRunner{}, "custom", "id", "secret", connectAgentOptions{ + ReplyCard: true, OwnerUserID: "owner", WorkDir: t.TempDir(), + }); err != nil { + t.Fatal(err) + } + cancel() + + if err := launchConnector(cmd, &captureRunner{}, "custom", "id", "secret", connectAgentOptions{ + KnowledgeSources: []string{"error-source"}, + }); err == nil { + t.Fatal("role knowledge error returned nil") + } +} + +func TestConnectAgentOptionRemainingCoverage(t *testing.T) { + clearChannelEnv(t) + t.Setenv("DWS_REPLY_CARD", "false") + t.Setenv("DWS_AGENT_MODEL", "model") + t.Setenv("DWS_AGENT_WORKDIR", "/work") + t.Setenv("DWS_CARD_TEMPLATE", "public") + t.Setenv("DWS_KNOWLEDGE_DIR", "/knowledge") + t.Setenv("DWS_KNOWLEDGE_SOURCE", "wiki:space") + t.Setenv("DWS_ALLOWED_USERS", "u1,u2") + t.Setenv("DWS_ALLOWED_GROUPS", "g1") + t.Setenv("DWS_USER_RATE_LIMIT", "7") + t.Setenv("DWS_OWNER_USER_ID", "owner") + t.Setenv("DWS_APPROVAL_CARD_TEMPLATE", "approval") + t.Setenv("DWS_ROLE_CONFIG", "role.yaml") + t.Setenv("DWS_AUDIT_SHEET", "node") + t.Setenv("DWS_AUDIT_SHEET_TAB", "tab") + cmd := newDevAppRobotConnectCommand(nil) + if err := cmd.Flags().Set("audit-sheet-tab", ""); err != nil { + t.Fatal(err) + } + opts, err := connectAgentOptionsFromCommand(cmd) + if err != nil { + t.Fatal(err) + } + if opts.Model != "model" || opts.WorkDir != "/work" || opts.ReplyCard || opts.CardTemplate != defaultAICardTemplateID || opts.UserRateLimit != 7 || opts.AuditSheetTab != "tab" { + t.Fatalf("env options = %+v", opts) + } + + t.Setenv("DWS_AUDIT_SHEET_TAB", "") + opts, err = connectAgentOptionsFromCommand(cmd) + if err != nil || opts.AuditSheetTab != "Sheet1" { + t.Fatalf("default audit tab = %q, err=%v", opts.AuditSheetTab, err) + } + t.Setenv("DWS_USER_RATE_LIMIT", "invalid") + if _, err := connectAgentOptionsFromCommand(cmd); err != nil { + t.Fatalf("invalid rate should retain default: %v", err) + } + + cases := []struct { + flag, value, env, envValue string + want bool + wantErr bool + }{ + {env: "DWS_AGENT_PERMISSION_MODE", envValue: "ask", want: false}, + {env: "DWS_AGENT_PERMISSION_MODE", envValue: "bad", wantErr: true}, + {flag: "agent-approval-mode", value: "ask", want: false}, + {env: "DWS_AGENT_APPROVAL_MODE", envValue: "ask", want: false}, + {env: "DWS_AGENT_APPROVAL_MODE", envValue: "bad", wantErr: true}, + } + for i, tc := range cases { + t.Run(fmt.Sprint(i), func(t *testing.T) { + clearChannelEnv(t) + c := newDevAppRobotConnectCommand(nil) + if tc.env != "" { + t.Setenv(tc.env, tc.envValue) + } + if tc.flag != "" { + if err := c.Flags().Set(tc.flag, tc.value); err != nil { + t.Fatal(err) + } + } + got, err := resolveAgentYoloMode(c) + if (err != nil) != tc.wantErr || (!tc.wantErr && got != tc.want) { + t.Fatalf("got (%v,%v), want (%v, err=%v)", got, err, tc.want, tc.wantErr) + } + }) + } +} + +func TestConnectAgentOptionsPayloadRemainingCoverage(t *testing.T) { + cases := []struct { + channel string + memory bool + }{ + {"codex", false}, {"opencode", true}, {"opencode", false}, + {"claudecode", true}, {"claudecode", false}, {"gemini", true}, + } + for _, tc := range cases { + payload := connectAgentOptionsPayload(tc.channel, connectAgentOptions{ + Memory: tc.memory, ReplyCard: true, CardTemplate: "template", Yolo: true, + }) + if payload["replyStyle"] != "ai-card" || payload["yolo"] != "enabled" { + t.Fatalf("payload(%s,%v) = %#v", tc.channel, tc.memory, payload) + } + } +} + +func TestDevAppConnectCommandRemainingCoverage(t *testing.T) { + origInteractive := devAppConnectStdinInteractive + origOnboarding := devAppRunConnectOnboarding + origDaemon := devAppStartConnectDaemon + origStream := devAppRunStreamConnector + t.Cleanup(func() { + devAppConnectStdinInteractive = origInteractive + devAppRunConnectOnboarding = origOnboarding + devAppStartConnectDaemon = origDaemon + devAppRunStreamConnector = origStream + }) + devAppRunStreamConnector = func(context.Context, string, string, string, forwarder, *aiCardClient, *connectExtras) error { + return nil + } + + run := func(runner executor.Runner, args ...string) (string, error) { + root := newDevAppTestRoot(runner) + var out bytes.Buffer + root.SetOut(&out) + root.SetErr(&out) + root.SetIn(strings.NewReader("")) + root.SetArgs(append([]string{"dev", "connect"}, args...)) + return out.String(), root.Execute() + } + + clearChannelEnv(t) + if _, err := run(&captureRunner{}, "--dry-run"); err == nil { + t.Fatal("undetected channel returned nil") + } + if _, err := run(&captureRunner{}, "--channel", "custom", "--agent-cmd", "sh -c printf\\ ok", "--robot-client-id", "id", "--robot-client-secret", "secret"); err != nil { + t.Fatalf("agent-cmd launch: %v", err) + } + if _, err := run(&captureRunner{}, "--channel", "custom", "--robot-client-id", "id", "--robot-client-secret", "secret", "--agent-permission-mode", "bad"); err == nil { + t.Fatal("invalid agent options returned nil") + } + + boom := errors.New("credentials boom") + if _, err := run(connectCoverageRunner{err: boom}, "--channel", "hermes", "--unified-app-id", "app"); !errors.Is(err, boom) { + t.Fatalf("credential error = %v", err) + } + if _, err := run(connectCoverageRunner{response: map[string]any{}}, "--channel", "hermes", "--unified-app-id", "app"); err == nil { + t.Fatal("empty credentials returned nil") + } + response := map[string]any{"result": map[string]any{"clientId": "id", "clientSecret": "secret"}} + if _, err := run(connectCoverageRunner{response: response}, "--channel", "hermes", "--unified-app-id", "app"); err != nil { + t.Fatalf("resolved credentials launch: %v", err) + } + + devAppConnectStdinInteractive = func() bool { return true } + devAppRunConnectOnboarding = func(executor.Runner, *cobra.Command, io.Reader, io.Writer) (connectCreds, error) { + return connectCreds{}, boom + } + if _, err := run(&captureRunner{}, "--channel", "hermes"); !errors.Is(err, boom) { + t.Fatalf("onboarding error = %v", err) + } + devAppRunConnectOnboarding = func(executor.Runner, *cobra.Command, io.Reader, io.Writer) (connectCreds, error) { + return connectCreds{clientID: "id", clientSecret: "secret", source: "onboarding"}, nil + } + if _, err := run(&captureRunner{}, "--channel", "hermes"); err != nil { + t.Fatalf("onboarding success: %v", err) + } + + var daemonCalled bool + devAppStartConnectDaemon = func(_ *cobra.Command, dirKey, clientID, unifiedAppID, channel, notifyStaffID, profile string, alwaysOn bool) error { + daemonCalled = dirKey != "" && clientID == "id" && channel == "hermes" && notifyStaffID == "staff" && alwaysOn + return boom + } + if _, err := run(&captureRunner{}, "--channel", "hermes", "--robot-client-id", "id", "--robot-client-secret", "secret", "--daemon", "--alwayson", "--notify-staff-id", "staff"); !errors.Is(err, boom) || !daemonCalled { + t.Fatalf("daemon result err=%v called=%v", err, daemonCalled) + } + + clearChannelEnv(t) + if _, err := run(&captureRunner{}, "--daemon-supervise"); err == nil || !strings.Contains(err.Error(), "DIRKEY") { + t.Fatalf("supervisor error = %v", err) + } +} + +func TestDevAppFetchCredentialsErrorCoverage(t *testing.T) { + cmd, _ := connectCoverageCommand(t) + want := errors.New("runner failure") + if _, _, err := devAppFetchCredentials(connectCoverageRunner{err: want}, cmd, "app"); !errors.Is(err, want) { + t.Fatalf("devAppFetchCredentials error = %v", err) + } +} diff --git a/internal/helpers/devapp_pretty_coverage_more_test.go b/internal/helpers/devapp_pretty_coverage_more_test.go new file mode 100644 index 00000000..4dec4a0c --- /dev/null +++ b/internal/helpers/devapp_pretty_coverage_more_test.go @@ -0,0 +1,19 @@ +package helpers + +import ( + "encoding/json" + "testing" +) + +func TestDevAppAsIntRemainingTypes(t *testing.T) { + for _, value := range []any{int(1), int64(1), float64(1), json.Number("1"), "1"} { + if got, ok := devAppAsInt(value); !ok || got != 1 { + t.Fatalf("devAppAsInt(%T(%v))=%d,%v", value, value, got, ok) + } + } + for _, value := range []any{json.Number("bad"), "bad", true} { + if got, ok := devAppAsInt(value); ok || got != 0 { + t.Fatalf("invalid devAppAsInt(%T(%v))=%d,%v", value, value, got, ok) + } + } +} diff --git a/internal/helpers/doc_jsonml_pipeline.go b/internal/helpers/doc_jsonml_pipeline.go index 65ecc3a4..003fa61e 100644 --- a/internal/helpers/doc_jsonml_pipeline.go +++ b/internal/helpers/doc_jsonml_pipeline.go @@ -11,6 +11,11 @@ import ( "github.com/spf13/cobra" ) +var ( + repairJSONML = jsonrepair.RepairJSON + diagnoseJSONMLError = doc.DiagnoseJSONError +) + // isInputDangerousUnicode matches the dangerous-unicode set defined by the // server-side RejectControlChars validator (pkg/validate/input.go). func isInputDangerousUnicode(r rune) bool { @@ -93,10 +98,7 @@ func coerceJsonMLBodyShape(raw string) (string, []string, error) { return raw, nil, nil } if _, ok := probe.([]any); ok { - wrapped, err := json.Marshal(map[string]any{"jsonml": probe}) - if err != nil { - return raw, nil, fmt.Errorf("wrap bare jsonml array: %w", err) - } + wrapped, _ := json.Marshal(map[string]any{"jsonml": probe}) return string(wrapped), nil, nil } return raw, nil, nil @@ -137,10 +139,7 @@ func coerceJsonMLNodeShape(raw string) (string, []string, error) { case 0: return "", nil, fmt.Errorf(`--content-format jsonml 输入 {"jsonml":[]}: wrapper 中 jsonml 数组为空`) case 1: - out, err := json.Marshal(arr[0]) - if err != nil { - return raw, nil, fmt.Errorf("unwrap single jsonml node: %w", err) - } + out, _ := json.Marshal(arr[0]) note := `输入为 {"jsonml":[node]} body 形态,已自动解包为单节点以符合 block 命令协议` return string(out), []string{note}, nil default: @@ -170,7 +169,7 @@ func prepareJsonMLBody(cmd *cobra.Command, raw string) (string, error) { if fixJSON { var probe any if err := json.Unmarshal([]byte(raw), &probe); err != nil { - result, repairErr := jsonrepair.RepairJSON(raw) + result, repairErr := repairJSONML(raw) if repairErr != nil { return "", fmt.Errorf("JSON 语法错误且自动修复失败: %w\n原始错误: %v", repairErr, err) } @@ -181,10 +180,7 @@ func prepareJsonMLBody(cmd *cobra.Command, raw string) (string, error) { } // Step 2: Coerce shape — bare array → {"jsonml": [...]} wrapper. - coerced, coerceNotes, err := coerceJsonMLBodyShape(raw) - if err != nil { - return "", err - } + coerced, coerceNotes, _ := coerceJsonMLBodyShape(raw) emitFixNotes(coerceNotes) raw = coerced @@ -193,7 +189,7 @@ func prepareJsonMLBody(cmd *cobra.Command, raw string) (string, error) { if err := json.Unmarshal([]byte(raw), &wrapper); err != nil { if !fixJSON { // Use parser for detailed positional diagnostics - detail := doc.DiagnoseJSONError([]byte(raw)) + detail := diagnoseJSONMLError([]byte(raw)) if detail != "" { return "", fmt.Errorf("JSON 语法错误: %w\n\n%s\n如果输入来自 LLM 生成,可通过 --fix-jsonml 尝试自动修复", err, detail) } @@ -238,10 +234,7 @@ func prepareJsonMLBody(cmd *cobra.Command, raw string) (string, error) { fmt.Fprintf(os.Stderr, "[WARN] %s\n", summary) } - out, err := json.Marshal(bodyArr) - if err != nil { - return "", fmt.Errorf("marshal jsonml: %w", err) - } + out, _ := json.Marshal(bodyArr) cleaned := stripInputUnsafeChars(string(out)) return cleaned, nil } @@ -265,7 +258,7 @@ func prepareJsonMLNode(cmd *cobra.Command, rawElement string) (string, error) { if fixJSON { var probe any if err := json.Unmarshal([]byte(rawElement), &probe); err != nil { - result, repairErr := jsonrepair.RepairJSON(rawElement) + result, repairErr := repairJSONML(rawElement) if repairErr != nil { return "", fmt.Errorf("JSON 语法错误且自动修复失败: %w\n原始错误: %v", repairErr, err) } @@ -288,14 +281,13 @@ func prepareJsonMLNode(cmd *cobra.Command, rawElement string) (string, error) { if err := json.Unmarshal([]byte(rawElement), &node); err != nil { if !fixJSON { // Use parser for detailed positional diagnostics - detail := doc.DiagnoseJSONError([]byte(rawElement)) + detail := diagnoseJSONMLError([]byte(rawElement)) if detail != "" { return "", fmt.Errorf("JSON 语法错误: %w\n\n%s\n如果输入来自 LLM 生成,可通过 --fix-jsonml 尝试自动修复", err, detail) } return "", fmt.Errorf("JSON 语法错误: %w\n输入不是有效的 JSON(可能缺少括号或逗号)。如果输入来自 LLM 生成,可通过 --fix-jsonml 尝试自动修复", err) } - // fixJSON was true but repair+coerce still couldn't produce valid JSON - return "", fmt.Errorf("JSON 修复后仍无法解析: %w", err) + return "", fmt.Errorf("JSON 语法错误: %w", err) } if _, ok := node.([]any); !ok { return "", fmt.Errorf("--content-format jsonml 要求 --element 为 JSON 数组,实际类型: %T", node) @@ -318,10 +310,7 @@ func prepareJsonMLNode(cmd *cobra.Command, rawElement string) (string, error) { fmt.Fprintf(os.Stderr, "[WARN] %s\n", summary) } - out, err := json.Marshal(node) - if err != nil { - return "", fmt.Errorf("marshal jsonml: %w", err) - } + out, _ := json.Marshal(node) return stripInputUnsafeChars(string(out)), nil } diff --git a/internal/helpers/doc_jsonml_pipeline_coverage_more_test.go b/internal/helpers/doc_jsonml_pipeline_coverage_more_test.go new file mode 100644 index 00000000..53a78c03 --- /dev/null +++ b/internal/helpers/doc_jsonml_pipeline_coverage_more_test.go @@ -0,0 +1,63 @@ +package helpers + +import ( + "errors" + "strings" + "testing" +) + +func TestPrepareJSONMLRemainingErrorPaths(t *testing.T) { + strict := jsonMLTestCommand(t, false) + fixing := jsonMLTestCommand(t, true) + + if _, err := prepareJsonMLNode(strict, ""); err == nil { + t.Fatal("empty node returned nil") + } + + origRepair := repairJSONML + repairJSONML = func(string) (string, error) { return "", errors.New("repair failed") } + if _, err := prepareJsonMLBody(fixing, "{"); err == nil || !strings.Contains(err.Error(), "repair failed") { + t.Fatalf("body repair err=%v", err) + } + if _, err := prepareJsonMLNode(fixing, "{"); err == nil || !strings.Contains(err.Error(), "repair failed") { + t.Fatalf("node repair err=%v", err) + } + repairJSONML = origRepair + t.Cleanup(func() { repairJSONML = origRepair }) + repairJSONML = func(string) (string, error) { return "{", nil } + if _, err := prepareJsonMLNode(fixing, "broken"); err == nil || !strings.Contains(err.Error(), "JSON 语法错误") { + t.Fatalf("invalid repaired node err=%v", err) + } + repairJSONML = origRepair + + // Exercise the concise syntax-error diagnostic when the detailed parser has + // no additional location information. + origDiagnose := diagnoseJSONMLError + diagnoseJSONMLError = func([]byte) string { return "" } + if _, err := prepareJsonMLBody(strict, "{"); err == nil || !strings.Contains(err.Error(), "输入不是有效") { + t.Fatalf("body concise diagnostic err=%v", err) + } + if _, err := prepareJsonMLNode(strict, "{"); err == nil || !strings.Contains(err.Error(), "输入不是有效") { + t.Fatalf("node concise diagnostic err=%v", err) + } + diagnoseJSONMLError = func([]byte) string { return "line 1" } + if _, err := prepareJsonMLBody(strict, "{"); err == nil || !strings.Contains(err.Error(), "line 1") { + t.Fatalf("body detailed diagnostic err=%v", err) + } + if _, err := prepareJsonMLNode(strict, "{"); err == nil || !strings.Contains(err.Error(), "line 1") { + t.Fatalf("node detailed diagnostic err=%v", err) + } + diagnoseJSONMLError = origDiagnose + + // Valid JSON with the wrong top-level shape is not a syntax-repair case. + if _, err := prepareJsonMLBody(fixing, `"scalar"`); err == nil || !strings.Contains(err.Error(), "修复后仍无法解析") { + t.Fatalf("fixed scalar body err=%v", err) + } + + if _, err := prepareJsonMLBody(strict, `{"jsonml":["root",{},["p",{"jc":1}]]}`); err == nil || !strings.Contains(err.Error(), "格式校验失败") { + t.Fatalf("body validation err=%v", err) + } + if _, err := prepareJsonMLNode(strict, `["p",{"jc":1}]`); err == nil || !strings.Contains(err.Error(), "格式校验失败") { + t.Fatalf("node validation err=%v", err) + } +} diff --git a/internal/helpers/doc_write_pipeline_coverage_more_test.go b/internal/helpers/doc_write_pipeline_coverage_more_test.go new file mode 100644 index 00000000..9379e9b1 --- /dev/null +++ b/internal/helpers/doc_write_pipeline_coverage_more_test.go @@ -0,0 +1,61 @@ +package helpers + +import ( + "context" + "errors" + "os" + "strings" + "testing" + + "github.com/spf13/cobra" +) + +func docWriteCoverageCommand() *cobra.Command { + cmd := &cobra.Command{Use: "write"} + cmd.Flags().String("content", "", "") + cmd.Flags().String("content-file", "", "") + cmd.Flags().String("content-path", "", "") + cmd.Flags().String("markdown", "", "") + return cmd +} + +func TestDocWritePipelineStrategyRemainingCoverage(t *testing.T) { + oldArgs := os.Args + os.Args = []string{"dws", "doc"} + t.Cleanup(func() { os.Args = oldArgs }) + stdinCmd := docWriteCoverageCommand() + _ = stdinCmd.Flags().Set("markdown", "-") + if got := detectContentSource(stdinCmd); got != sourceStdin { + t.Fatalf("markdown stdin source = %v", got) + } + + longLiteral := strings.Repeat("x", longContentWarningThreshold+1) + installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"nodeId":"node"}`}}}) + if err := docWritePipeline(docWriteCoverageCommand(), "create_document", map[string]any{"markdown": longLiteral}, longLiteral, "create"); err != nil { + t.Fatalf("long literal single write: %v", err) + } + + fallback := strings.Repeat("x", minChunkSize+100) + installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{err: errors.New("timeout")}, {text: `{}`}, {text: `{}`}}}) + if err := docWritePipeline(docWriteCoverageCommand(), "update_document", map[string]any{"nodeId": "node", "mode": "overwrite", "markdown": fallback}, fallback, "update"); err != nil { + t.Fatalf("single timeout fallback: %v", err) + } + + chunked := strings.Repeat("x", initialChunkSize+100) + installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{}`}, {text: `{}`}}}) + if err := docWritePipeline(docWriteCoverageCommand(), "update_document", map[string]any{"nodeId": "node", "markdown": chunked}, chunked, "update"); err != nil { + t.Fatalf("long content chunking: %v", err) + } +} + +func TestChunkedWriteAdaptiveRetryRemainingCoverage(t *testing.T) { + oldArgs := os.Args + os.Args = []string{"dws", "doc"} + t.Cleanup(func() { os.Args = oldArgs }) + markdown := strings.Repeat("x", 24000) + installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{}`}, {err: errors.New("HSFTimeoutException")}, {text: `{}`}}}) + _, written, _, err := chunkedWrite(context.Background(), "update_document", map[string]any{"nodeId": "node"}, markdown, "update", 10000) + if err != nil || written < 3 { + t.Fatalf("adaptive retry written=%d err=%v", written, err) + } +} diff --git a/internal/helpers/drive_coverage_more_test.go b/internal/helpers/drive_coverage_more_test.go new file mode 100644 index 00000000..44dc79ee --- /dev/null +++ b/internal/helpers/drive_coverage_more_test.go @@ -0,0 +1,274 @@ +package helpers + +import ( + "bytes" + "context" + "errors" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/spf13/cobra" +) + +func executeDriveEdge(t *testing.T, caller *scriptedToolCaller, args ...string) error { + t.Helper() + oldDeps := deps + oldArgs := os.Args + InitDeps(caller) + deps.Out.w = io.Discard + deps.Out.errW = io.Discard + t.Cleanup(func() { + deps = oldDeps + os.Args = oldArgs + }) + root := newDriveCommand() + installExampleGlobalFlags(root) + root.SilenceErrors = true + root.SilenceUsage = true + root.SetOut(io.Discard) + root.SetErr(io.Discard) + root.SetArgs(args) + os.Args = append([]string{"dws", "drive"}, args...) + return root.Execute() +} + +func TestParseDriveUploadInfoRemainingCoverage(t *testing.T) { + cases := []struct { + name string + json string + err bool + }{ + {"resource array", `{"result":{"uploadId":"u","resourceUrls":[{"url":"https://upload.invalid","headers":{"X-Test":"yes","skip":1}}]}}`, false}, + {"flat resource and headers", `{"uploadId":"u","resourceUrl":"https://upload.invalid","headers":{"X-Test":"yes","skip":1}}`, false}, + {"upload URL fallback", `{"uploadId":"u","uploadUrl":"https://upload.invalid"}`, false}, + {"non-map first URL", `{"uploadId":"u","resourceUrls":[1]}`, true}, + {"missing upload id", `{"resourceUrl":"https://upload.invalid"}`, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + url, id, headers, err := parseDriveUploadInfo(tc.json) + if (err != nil) != tc.err { + t.Fatalf("parse error=%v, wantErr=%v", err, tc.err) + } + if !tc.err && (url == "" || id == "" || headers == nil) { + t.Fatalf("parse result url=%q id=%q headers=%v", url, id, headers) + } + }) + } +} + +func TestDriveUploadValidationAndDryRunCoverage(t *testing.T) { + file := filepath.Join(t.TempDir(), "fixture.txt") + if err := os.WriteFile(file, []byte("fixture"), 0o600); err != nil { + t.Fatal(err) + } + cases := []struct { + name string + args []string + err bool + }{ + {"missing file", []string{"upload"}, true}, + {"unreadable file", []string{"upload", "--file", file + ".missing"}, true}, + {"directory", []string{"upload", "--file", t.TempDir()}, true}, + {"numeric drive folder", []string{"upload", "--file", file, "--folder", "123"}, true}, + {"drive dry run", []string{"upload", "--file", file, "--file-name", "named.txt", "--dry-run"}, false}, + {"numeric doc folder", []string{"upload", "--file", file, "--workspace", "space", "--folder", "123"}, true}, + {"doc dry run extension", []string{"upload", "--file", file, "--file-name", "named", "--workspace", "space", "--dry-run"}, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := executeDriveEdge(t, &scriptedToolCaller{dry: strings.Contains(strings.Join(tc.args, " "), "--dry-run")}, tc.args...) + if (err != nil) != tc.err { + t.Fatalf("Execute(%v) error=%v, wantErr=%v", tc.args, err, tc.err) + } + }) + } +} + +func TestDriveUploadTransportCoverage(t *testing.T) { + file := filepath.Join(t.TempDir(), "fixture.txt") + if err := os.WriteFile(file, []byte("fixture"), 0o600); err != nil { + t.Fatal(err) + } + boom := errors.New("boom") + t.Run("drive credentials error", func(t *testing.T) { + if err := executeDriveEdge(t, &scriptedToolCaller{steps: []scriptedToolStep{{err: boom}}}, "upload", "--file", file); !errors.Is(err, boom) { + t.Fatalf("error=%v", err) + } + }) + t.Run("drive credentials parse error", func(t *testing.T) { + if err := executeDriveEdge(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{}`}}}, "upload", "--file", file); err == nil { + t.Fatal("parse error returned nil") + } + }) + t.Run("drive put error", func(t *testing.T) { + old := httpPutFile + httpPutFile = func(context.Context, string, map[string]string, string, int64) error { return boom } + t.Cleanup(func() { httpPutFile = old }) + payload := `{"uploadId":"u","resourceUrl":"https://upload.invalid"}` + if err := executeDriveEdge(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: payload}}}, "upload", "--file", file); !errors.Is(err, boom) { + t.Fatalf("error=%v", err) + } + }) + t.Run("drive commit", func(t *testing.T) { + old := httpPutFile + httpPutFile = func(context.Context, string, map[string]string, string, int64) error { return nil } + t.Cleanup(func() { httpPutFile = old }) + payload := `{"uploadId":"u","resourceUrl":"https://upload.invalid"}` + if err := executeDriveEdge(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: payload}, {text: `{}`}}}, "upload", "--file", file, "--space-id", "space", "--folder", "uuid"); err != nil { + t.Fatalf("error=%v", err) + } + }) + + docArgs := []string{"upload", "--file", file, "--workspace", "space", "--folder", "uuid", "--convert"} + t.Run("doc credentials error", func(t *testing.T) { + if err := executeDriveEdge(t, &scriptedToolCaller{steps: []scriptedToolStep{{err: boom}}}, docArgs...); !errors.Is(err, boom) { + t.Fatalf("error=%v", err) + } + }) + t.Run("doc credentials parse error", func(t *testing.T) { + if err := executeDriveEdge(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{}`}}}, docArgs...); err == nil { + t.Fatal("parse error returned nil") + } + }) + t.Run("doc put error", func(t *testing.T) { + old := httpPutFile + httpPutFile = func(context.Context, string, map[string]string, string, int64) error { return boom } + t.Cleanup(func() { httpPutFile = old }) + payload := `{"resourceUrl":"https://upload.invalid","uploadKey":"key"}` + if err := executeDriveEdge(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: payload}}}, docArgs...); !errors.Is(err, boom) { + t.Fatalf("error=%v", err) + } + }) + t.Run("doc commit", func(t *testing.T) { + old := httpPutFile + httpPutFile = func(context.Context, string, map[string]string, string, int64) error { return nil } + t.Cleanup(func() { httpPutFile = old }) + payload := `{"resourceUrl":"https://upload.invalid","uploadKey":"key"}` + if err := executeDriveEdge(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: payload}, {text: `{}`}}}, docArgs...); err != nil { + t.Fatalf("error=%v", err) + } + }) +} + +func TestDriveCommandRemainingEdges(t *testing.T) { + file := filepath.Join(t.TempDir(), "fixture.txt") + _ = os.WriteFile(file, []byte("fixture"), 0o600) + cases := [][]string{ + {"list", "--workspace", "space", "--folder", "123"}, + {"mkdir", "--name", "folder", "--folder", "123"}, + {"upload-info", "--file-name", "x", "--file-size", "1", "--folder", "123"}, + {"commit", "--file-name", "x", "--file-size", "1", "--upload-id", "u", "--folder", "123"}, + {"copy", "--node", "node", "--folder", "123"}, + {"move", "--node", "node", "--folder", "123"}, + } + for _, args := range cases { + if err := executeDriveEdge(t, &scriptedToolCaller{}, args...); err == nil { + t.Fatalf("Execute(%v) returned nil", args) + } + } + if err := executeDriveEdge(t, &scriptedToolCaller{dry: true}, "download", "--node", "node", "--output", file, "--dry-run"); err != nil { + t.Fatalf("dry download: %v", err) + } +} + +func TestDriveDownloadDirectoryCoverage(t *testing.T) { + oldGet := httpGetFile + httpGetFile = func(context.Context, string, map[string]string, string) error { return nil } + t.Cleanup(func() { httpGetFile = oldGet }) + dir := t.TempDir() + for _, payload := range []string{ + `{"resourceUrl":"https://download.invalid/path/from-url.txt","fileName":""}`, + `{"resourceUrl":"https://download.invalid/path/from-url.txt","fileName":"folder/name.txt"}`, + } { + if err := executeDriveEdge(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: payload}}}, "download", "--node", "node", "--output", dir); err != nil { + t.Fatalf("download: %v", err) + } + } +} + +func TestDriveConfirmationCancellationCoverage(t *testing.T) { + oldStdin := os.Stdin + t.Cleanup(func() { os.Stdin = oldStdin }) + for _, args := range [][]string{ + {"delete", "--node", "node"}, + {"publish", "set", "--node", "node"}, + {"publish", "unset", "--node", "node"}, + } { + file, err := os.CreateTemp(t.TempDir(), "stdin") + if err != nil { + t.Fatal(err) + } + if _, err := file.WriteString("no\n"); err != nil { + t.Fatal(err) + } + _, _ = file.Seek(0, 0) + os.Stdin = file + if err := executeDriveEdge(t, &scriptedToolCaller{}, args...); err != nil { + t.Fatalf("cancel %v: %v", args, err) + } + _ = file.Close() + } +} + +func TestDriveInfoDocFallbackCoverage(t *testing.T) { + oldArgs := os.Args + os.Args = []string{"dws", "drive", "info"} + t.Cleanup(func() { os.Args = oldArgs }) + boom := errors.New("boom") + cases := []struct { + name string + steps []scriptedToolStep + err bool + }{ + {"drive error", []scriptedToolStep{{err: boom}}, true}, + {"invalid drive JSON", []scriptedToolStep{{text: `{`}}, false}, + {"no result", []scriptedToolStep{{text: `{}`}}, false}, + {"ordinary file", []scriptedToolStep{{text: `{"result":{"extension":"pdf"}}`}}, false}, + {"doc lookup error", []scriptedToolStep{{text: `{"result":{"message":"钉钉文档","fileId":""}}`}, {err: boom}}, false}, + {"invalid doc JSON", []scriptedToolStep{{text: `{"result":{"extension":"adoc","fileId":"node"}}`}, {text: `{`}}, false}, + {"empty flat doc", []scriptedToolStep{{text: `{"result":{"extension":"axls","fileId":"node"}}`}, {text: `{}`}}, false}, + {"flat doc merge", []scriptedToolStep{{text: `{"result":{"extension":"amind","fileId":"node","path":"/drive","fileSize":3,"type":"doc"}}`}, {text: `{"title":"Doc","path":"existing"}`}}, false}, + {"wrapped doc", []scriptedToolStep{{text: `{"result":{"extension":"adraw","fileId":"node","dentryId":"d"}}`}, {text: `{"result":{"title":"Doc"}}`}}, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + caller := &scriptedToolCaller{steps: tc.steps} + old := deps + InitDeps(caller) + deps.Out.w = &bytes.Buffer{} + deps.Out.errW = io.Discard + t.Cleanup(func() { deps = old }) + err := driveInfoWithDocFallback("fallback-node", map[string]any{"fileId": "fallback-node"}) + if (err != nil) != tc.err { + t.Fatalf("error=%v, wantErr=%v", err, tc.err) + } + }) + } +} + +func TestDriveSmallHelperEdges(t *testing.T) { + for _, ext := range []string{"adoc", "AXLS", "amind", "adraw", "pdf"} { + _ = isDingTalkDocExtension(ext) + } + cmd := &cobra.Command{Use: "upload"} + cmd.Flags().String("file", "", "") + cmd.Flags().String("file-name", "", "") + cmd.Flags().String("name", "", "") + cmd.Flags().String("workspace", "", "") + cmd.Flags().String("workspace-id", "", "") + cmd.Flags().String("folder", "", "") + cmd.Flags().String("parent-id", "", "") + cmd.Flags().String("space-id", "", "") + cmd.Flags().String("mime-type", "", "") + cmd.Flags().Bool("convert", false, "") + if err := cmd.Flags().Set("file", t.TempDir()); err != nil { + t.Fatal(err) + } + if err := runDriveUpload(cmd, nil); err == nil { + t.Fatal("directory upload returned nil") + } +} diff --git a/internal/helpers/helpers.go b/internal/helpers/helpers.go index c56fdf83..d9d4df65 100644 --- a/internal/helpers/helpers.go +++ b/internal/helpers/helpers.go @@ -409,11 +409,6 @@ func getCurrentUserID(ctx context.Context) (string, error) { } } } - if r, ok := flat["result"].(map[string]any); ok { - if uid, ok := r["userId"].(string); ok && uid != "" { - return uid, nil - } - } } } return "", fmt.Errorf("cannot parse userId from get_current_user_profile response") diff --git a/internal/helpers/helpers_coverage_more_test.go b/internal/helpers/helpers_coverage_more_test.go new file mode 100644 index 00000000..b2a25c93 --- /dev/null +++ b/internal/helpers/helpers_coverage_more_test.go @@ -0,0 +1,43 @@ +package helpers + +import ( + "context" + "errors" + "strings" + "testing" +) + +func TestMCPInternalClassificationAndUnescapeRemainingCoverage(t *testing.T) { + caller := &helpersCoreCaller{format: "raw"} + out, _ := installHelpersCoreDeps(t, caller) + + caller.err = errors.New("request failed with PAT_HIGH_RISK_NO_PERMISSION") + if err := callMCPToolInternalOpts("server", "tool", nil, false); err == nil { + t.Fatal("PAT transport error returned nil") + } + caller.err = nil + for _, response := range []string{ + `{"errorCode":"DWS_SERVICE_UNAUTHORIZED"}`, + `{"error":"Missing service_id or access_key"}`, + `{"code":"PAT_NO_PERMISSION"}`, + } { + caller.result = textToolResult(response) + if err := callMCPToolInternalOpts("server", "tool", nil, false); err == nil { + t.Fatalf("classified response %s returned nil", response) + } + } + + out.Reset() + caller.result = textToolResult(`{"url":"https://example.test/?a=1\u0026b=2"}`) + if err := callMCPToolInternalOpts("server", "tool", nil, true); err != nil || !strings.Contains(out.String(), "&") { + t.Fatalf("unescaped raw output=%q err=%v", out.String(), err) + } +} + +func TestCurrentUserMalformedTailFallbackCoverage(t *testing.T) { + caller := &helpersCoreCaller{result: textToolResult(`{"result":[{"orgEmployeeModel":{"userId":"fallback-user"}},{"orgEmployeeModel":"malformed"}]}`)} + installHelpersCoreDeps(t, caller) + if got, err := getCurrentUserID(context.Background()); err != nil || got != "fallback-user" { + t.Fatalf("fallback user=%q err=%v", got, err) + } +} diff --git a/internal/helpers/helpers_final_coverage_test.go b/internal/helpers/helpers_final_coverage_test.go new file mode 100644 index 00000000..34c39341 --- /dev/null +++ b/internal/helpers/helpers_final_coverage_test.go @@ -0,0 +1,170 @@ +package helpers + +import ( + "errors" + "io" + "os" + "path/filepath" + "strings" + "syscall" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor" + "github.com/spf13/cobra" +) + +func TestLastPureAndCommandBranches(t *testing.T) { + if commandBoolFlag(nil, "dry-run") { + t.Fatal("nil command flag is true") + } + SetCmdClassOverride(" ", CmdClassWrite) + formatCmd := &cobra.Command{Use: "format"} + formatCmd.Flags().String("content-format", "json", "") + if err := validateDocFormat(formatCmd, []string{"", "markdown"}, "doc create", "dws doc create"); err == nil { + t.Fatal("json content format returned nil") + } + if sniffJsonMLLike("") { + t.Fatal("empty content looks like JSONML") + } + if err := validateSegment("name", "ab-", 2, 50); err == nil { + t.Fatal("trailing hyphen name returned nil") + } + if got := NormalizeSkillName("Skill --- "); got != "skill" { + t.Fatalf("normalized skill=%q", got) + } + + installScriptedCaller(t, &scriptedToolCaller{dry: true}) + aisearch := newAisearchCommand() + if err := aisearch.RunE(aisearch, nil); err != nil { + t.Fatalf("aisearch group help: %v", err) + } + + gate := newConnectGate(nil, nil, 1) + for i := 0; i < 4097; i++ { + gate.hits[string(rune(0x1000+i))] = nil + } + if ok, reason := gate.allow("sender", "1", "conv"); !ok || reason != "" || len(gate.hits) != 1 { + t.Fatalf("bounded gate ok=%v reason=%q size=%d", ok, reason, len(gate.hits)) + } + + q := newConvQueue() + if process := q.processFor("missing"); process != nil { + t.Fatal("missing queue process is non-nil") + } + + role := &RoleConfig{ConfirmPolicy: ConfirmManual} + merged := applyRoleConfig(connectAgentOptions{}, role) + if merged.ConfirmPolicy != string(ConfirmManual) { + t.Fatalf("confirm policy=%q", merged.ConfirmPolicy) + } + roleDir := t.TempDir() + if err := os.WriteFile(filepath.Join(roleDir, "bad.yaml"), []byte(":\n"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := LoadRoleConfigs(roleDir); err == nil { + t.Fatal("invalid role directory returned nil") + } + + dev := (devHandler{}).Command(executor.EchoRunner{}) + if err := dev.RunE(dev, nil); err != nil { + t.Fatalf("dev help: %v", err) + } + doc := findCoverageSubcommand(t, dev, "doc") + if err := doc.RunE(doc, nil); err != nil { + t.Fatalf("dev doc help: %v", err) + } +} + +func TestLastIOAndConfirmationBranches(t *testing.T) { + origGetrlimit := connectGetrlimit + connectGetrlimit = func(int, *syscall.Rlimit) error { return errors.New("rlimit") } + checkFDLimit() + connectGetrlimit = func(_ int, limit *syscall.Rlimit) error { limit.Cur = 1; return nil } + checkFDLimit() + connectGetrlimit = origGetrlimit + t.Cleanup(func() { connectGetrlimit = origGetrlimit }) + + origOpen, origRead, origRemove := connectLockOpenFile, connectLockReadFile, connectLockRemove + connectLockOpenFile = func(string, int, os.FileMode) (*os.File, error) { return nil, errors.New("open") } + if _, err := acquireConnectLock("client"); err == nil || err.Error() != "open" { + t.Fatalf("non-exist lock err=%v", err) + } + connectLockOpenFile = func(string, int, os.FileMode) (*os.File, error) { return nil, os.ErrExist } + connectLockReadFile = func(string) ([]byte, error) { return nil, errors.New("read") } + connectLockRemove = func(string) error { return errors.New("remove") } + if _, err := acquireConnectLock("client"); err == nil || !strings.Contains(err.Error(), "无法获取") { + t.Fatalf("terminal lock err=%v", err) + } + connectLockOpenFile, connectLockReadFile, connectLockRemove = origOpen, origRead, origRemove + t.Cleanup(func() { connectLockOpenFile, connectLockReadFile, connectLockRemove = origOpen, origRead, origRemove }) + + origReadAll, origReadFile := sheetCSVReadAll, sheetCSVReadFile + sheetCSVReadAll = func(io.Reader) ([]byte, error) { return nil, errors.New("stdin") } + installScriptedCaller(t, &scriptedToolCaller{dry: true}) + if err := executeFilterCoverage(t, newDataCommandRoot(), "csv-put", "--node", "node", "--sheet-id", "sheet", "--start-cell", "A1", "--csv", "-"); err == nil { + t.Fatal("CSV stdin failure returned nil") + } + sheetCSVReadAll = origReadAll + csvFile := filepath.Join(t.TempDir(), "data.csv") + if err := os.WriteFile(csvFile, []byte("a,b"), 0o600); err != nil { + t.Fatal(err) + } + if err := executeFilterCoverage(t, newDataCommandRoot(), "csv-put", "--node", "node", "--sheet-id", "sheet", "--start-cell", "A1", "--csv", "@"+csvFile); err != nil { + t.Fatalf("CSV file input: %v", err) + } + sheetCSVReadAll, sheetCSVReadFile = origReadAll, origReadFile + t.Cleanup(func() { sheetCSVReadAll, sheetCSVReadFile = origReadAll, origReadFile }) + + if err := executeFilterCoverage(t, newDataCommandRoot(), "append", "--node", "node", "--sheet-id", "sheet", "--values", "{"); err == nil { + t.Fatal("invalid append JSON returned nil") + } + floatRoot := &cobra.Command{Use: "sheet"} + floatRoot.AddCommand(newFloatImageCmds()...) + createFloat := findCoverageSubcommand(t, floatRoot, "create-float-image") + for _, name := range []string{"node", "sheet-id", "src", "range"} { + _ = createFloat.Flags().Set(name, "value") + } + createFloat.Flags().Lookup("width").Value = invalidIntFlagValue{} + if err := createFloat.RunE(createFloat, nil); err == nil { + t.Fatal("invalid float image width returned nil") + } + floatRoot = &cobra.Command{Use: "sheet"} + floatRoot.AddCommand(newFloatImageCmds()...) + createFloat = findCoverageSubcommand(t, floatRoot, "create-float-image") + for _, name := range []string{"node", "sheet-id", "src", "range"} { + _ = createFloat.Flags().Set(name, "value") + } + _ = createFloat.Flags().Set("width", "1") + createFloat.Flags().Lookup("height").Value = invalidIntFlagValue{} + if err := createFloat.RunE(createFloat, nil); err == nil { + t.Fatal("invalid float image height returned nil") + } + + root := newOaCommand() + approval := findCoverageSubcommand(t, root, "approval") + revoke := findCoverageSubcommand(t, approval, "revoke") + _ = revoke.Flags().Set("instance-id", "instance") + oldArgs, oldStdin := os.Args, os.Stdin + os.Args = []string{"dws"} + noPath := filepath.Join(t.TempDir(), "no") + if err := os.WriteFile(noPath, []byte("no\n"), 0o600); err != nil { + t.Fatal(err) + } + stdin, err := os.Open(noPath) + if err != nil { + t.Fatal(err) + } + os.Stdin = stdin + if err := revoke.RunE(revoke, nil); err != nil { + t.Fatalf("cancel revoke: %v", err) + } + os.Args, os.Stdin = oldArgs, oldStdin + _ = stdin.Close() + +} + +func newDataCommandRoot() *cobra.Command { + root := &cobra.Command{Use: "sheet"} + root.AddCommand(newDataCmds()...) + return root +} diff --git a/internal/helpers/interfaces.go b/internal/helpers/interfaces.go index 9762c8b5..062b78e7 100644 --- a/internal/helpers/interfaces.go +++ b/internal/helpers/interfaces.go @@ -98,7 +98,7 @@ func validateSegment(label, value string, minLen, maxLen int) error { if !namePattern.MatchString(value) { return fmt.Errorf("%s %q must be kebab-case (a-z0-9-), starting with a letter", label, value) } - if strings.HasPrefix(value, "-") || strings.HasSuffix(value, "-") { + if strings.HasSuffix(value, "-") { return fmt.Errorf("%s %q must not start or end with a hyphen", label, value) } return nil diff --git a/internal/helpers/mail.go b/internal/helpers/mail.go index 9018c198..c72b03ea 100644 --- a/internal/helpers/mail.go +++ b/internal/helpers/mail.go @@ -36,6 +36,12 @@ var mailRuleAllowedOperations = map[string]map[string]bool{ }, } +var ( + mailHTTPClient = func() *http.Client { return &http.Client{Timeout: 5 * time.Minute} } + mailPutAttachment = httpPutMailAttachment + mailGetAttachment = httpGetMailAttachment +) + func parseMailRuleConditions(raw string) ([]any, error) { var conditions []any if err := json.Unmarshal([]byte(raw), &conditions); err != nil { @@ -204,9 +210,6 @@ func newMailCommand() *cobra.Command { return err } sizeVal := flagOrFallback(cmd, "limit", "size", "page-size") - if sizeVal == "" { - sizeVal = "20" - } toolArgs := map[string]any{ "email": mustGetFlag(cmd, "email"), "query": flagOrFallback(cmd, "query", "keyword"), @@ -244,14 +247,8 @@ func newMailCommand() *cobra.Command { return err } folderId := flagOrFallback(cmd, "folder-id", "folder") - if folderId == "" { - folderId = "2" // 默认收件箱 - } query := fmt.Sprintf("folderId:%s", folderId) sizeVal := flagOrFallback(cmd, "limit", "size", "page-size") - if sizeVal == "" { - sizeVal = "20" - } toolArgs := map[string]any{ "email": mustGetFlag(cmd, "email"), "query": query, @@ -2639,7 +2636,7 @@ func runMailDraftWithAttachment(draftTool string, draftArgs map[string]any, mess if err != nil { return "", fmt.Errorf("解析附件 %s 上传信息失败: %w", f.name, err) } - if err := httpPutMailAttachment(ctx, accountType, uploadURL, f.path, f.size); err != nil { + if err := mailPutAttachment(ctx, accountType, uploadURL, f.path, f.size); err != nil { return "", fmt.Errorf("上传附件 %s 失败: %w", f.name, err) } } @@ -2661,7 +2658,7 @@ func runMailDraftWithAttachment(draftTool string, draftArgs map[string]any, mess if err != nil { return "", fmt.Errorf("解析内联附件 %s 上传信息失败: %w", f.name, err) } - if err := httpPutMailAttachment(ctx, accountType, uploadURL, f.path, f.size); err != nil { + if err := mailPutAttachment(ctx, accountType, uploadURL, f.path, f.size); err != nil { return "", fmt.Errorf("上传内联附件 %s 失败: %w", f.name, err) } } @@ -2803,7 +2800,7 @@ func httpPutMailAttachment(ctx context.Context, accountType string, uploadURL st req.ContentLength = fileSize req.Header.Set("Content-Type", "application/octet-stream") - client := &http.Client{Timeout: 5 * time.Minute} + client := mailHTTPClient() resp, err := client.Do(req) if err != nil { return fmt.Errorf("attachment upload failed: %w", err) @@ -2919,7 +2916,7 @@ func httpGetMailAttachment(ctx context.Context, accountType string, downloadURL return fmt.Errorf("failed to create download request: %w", err) } - client := &http.Client{Timeout: 5 * time.Minute} + client := mailHTTPClient() resp, err := client.Do(req) if err != nil { return fmt.Errorf("attachment download failed: %w", err) @@ -2982,7 +2979,7 @@ func runMailAttachmentDownload(cmd *cobra.Command) error { } // Step 3: HTTP GET 下载附件内容并保存到本地 - if err := httpGetMailAttachment(ctx, accountType, downloadURL, destPath); err != nil { + if err := mailGetAttachment(ctx, accountType, downloadURL, destPath); err != nil { return fmt.Errorf("下载附件失败: %w", err) } diff --git a/internal/helpers/mail_command_edges_more_test.go b/internal/helpers/mail_command_edges_more_test.go new file mode 100644 index 00000000..0fd81ff0 --- /dev/null +++ b/internal/helpers/mail_command_edges_more_test.go @@ -0,0 +1,272 @@ +package helpers + +import ( + "context" + "errors" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/spf13/cobra" +) + +func executeMailEdge(t *testing.T, caller *scriptedToolCaller, args ...string) error { + t.Helper() + previous, previousArgs := deps, os.Args + os.Args = append([]string{"dws", "mail"}, os.Args[1:]...) + InitDeps(caller) + deps.Out.w = io.Discard + deps.Out.errW = io.Discard + defer func() { + deps = previous + os.Args = previousArgs + }() + + cmd := newMailCommand() + cmd.SilenceErrors = true + cmd.SilenceUsage = true + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.SetArgs(args) + return cmd.Execute() +} + +func TestMailSearchSendAndThreadEdges(t *testing.T) { + valid := [][]string{ + {"message", "search", "--email", "a@example.com", "--query", "subject:x"}, + {"message", "list", "--email", "a@example.com"}, + {"message", "send", "--from", "a@example.com", "--to", "b@example.com", "--subject", "s", "--content", "body", "--cc", "c@example.com"}, + } + for _, args := range valid { + if err := executeMailEdge(t, &scriptedToolCaller{}, args...); err != nil { + t.Fatalf("execute %v: %v", args, err) + } + } + if err := executeMailEdge(t, &scriptedToolCaller{}, "message", "send", "--from", "a@example.com", "--to", "b@example.com", "--subject", "s"); err == nil { + t.Fatal("missing content returned nil") + } + + many := strings.TrimSuffix(strings.Repeat("id,", 101), ",") + for _, args := range [][]string{ + {"thread", "batch-update", "--email", "a@example.com", "--ids", many, "--action", "markRead"}, + {"thread", "trash", "--email", "a@example.com", "--id", "1"}, + {"thread", "batch-trash", "--email", "a@example.com", "--ids", "1"}, + {"thread", "batch-trash", "--email", "a@example.com", "--ids", many, "--yes"}, + {"message", "batch-update", "--email", "a@example.com", "--ids", "1", "--action", "addTags"}, + {"message", "batch-get", "--email", "a@example.com", "--ids", ","}, + {"message", "batch-get", "--email", "a@example.com", "--ids", strings.TrimSuffix(strings.Repeat("id,", 21), ",")}, + } { + if err := executeMailEdge(t, &scriptedToolCaller{}, args...); err == nil { + t.Fatalf("expected error for %v", args) + } + } + if err := executeMailEdge(t, &scriptedToolCaller{dry: true}, "message", "batch-get", "--email", "a@example.com", "--ids", "1,2"); err != nil { + t.Fatalf("dry batch get: %v", err) + } +} + +func TestMailReplyForwardAndDraftEdges(t *testing.T) { + draftSteps := func() []scriptedToolStep { + return []scriptedToolStep{{text: `{}`}, {text: `{"result":{"message":{"id":"message"}}}`}, {text: `{}`}} + } + for _, args := range [][]string{ + {"message", "reply", "--from", "a@example.com", "--id", "1", "--to", "b@example.com", "--subject", "s", "--content", "body"}, + {"message", "reply-all", "--from", "a@example.com", "--id", "1", "--to", "b@example.com", "--subject", "s", "--content", "body"}, + {"message", "forward", "--from", "a@example.com", "--id", "1", "--to", "b@example.com", "--subject", "s", "--content", "body"}, + } { + if err := executeMailEdge(t, &scriptedToolCaller{steps: draftSteps()}, args...); err != nil { + t.Fatalf("execute %v: %v", args, err) + } + } + + attachment := filepath.Join(t.TempDir(), "attachment.txt") + if err := os.WriteFile(attachment, []byte("data"), 0o600); err != nil { + t.Fatal(err) + } + originalPut := mailPutAttachment + mailPutAttachment = func(context.Context, string, string, string, int64) error { return nil } + defer func() { mailPutAttachment = originalPut }() + upload := `{"uploadUrl":"https://upload.invalid"}` + + if err := executeMailEdge(t, &scriptedToolCaller{}, "draft", "create", "--from", "a@example.com", "--subject", "s"); err != nil { + t.Fatalf("plain create draft: %v", err) + } + createSteps := []scriptedToolStep{{text: `{}`}, {text: `{"messageId":"message"}`}, {text: upload}, {text: `{}`}} + if err := executeMailEdge(t, &scriptedToolCaller{steps: createSteps}, "draft", "create", "--from", "a@example.com", "--subject", "s", "--attachment", attachment); err != nil { + t.Fatalf("attachment create draft: %v", err) + } + if err := executeMailEdge(t, &scriptedToolCaller{}, "draft", "update", "--from", "a@example.com", "--id", "message"); err != nil { + t.Fatalf("plain update draft: %v", err) + } + updateSteps := []scriptedToolStep{{text: `{}`}, {text: `{}`}, {text: upload}, {text: `{}`}} + if err := executeMailEdge(t, &scriptedToolCaller{steps: updateSteps}, "draft", "update", "--from", "a@example.com", "--id", "message", "--attachment", attachment); err != nil { + t.Fatalf("attachment update draft: %v", err) + } + + sendSteps := []scriptedToolStep{{text: `{}`}, {text: `{"messageId":"message"}`}, {text: upload}, {text: `{}`}} + if err := executeMailEdge(t, &scriptedToolCaller{steps: sendSteps}, "message", "send", "--from", "a@example.com", "--to", "b@example.com", "--subject", "s", "--content", "body", "--attachment", attachment); err != nil { + t.Fatalf("attachment send: %v", err) + } +} + +func TestMailRulesRecallAndTemplateEdges(t *testing.T) { + if err := executeMailEdge(t, &scriptedToolCaller{}, "sent-message", "recall", "--email", "a@example.com", "--id", "1", "--subject", "s"); err == nil { + t.Fatal("recall without confirmation returned nil") + } + if err := executeMailEdge(t, &scriptedToolCaller{}, "template", "create", "--email", "a@example.com", "--subject", "s", "--name", "n"); err == nil { + t.Fatal("template missing content returned nil") + } + invalidParameter := `{"success":false,"message":"Invalid parameter"}` + if err := executeMailEdge(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: invalidParameter}}}, "template", "update", "--email", "a@example.com", "--id", "1"); err == nil { + t.Fatal("template invalid parameter returned nil") + } + + condition := `[{"object":"subject","or":[{"and":[{"operation":"include"}]}]}]` + actions := `[{"action":"ActFlagMail2","parameters":["asread"]}]` + for _, args := range [][]string{ + {"rule", "create", "--email", "a@example.com", "--name", "n", "--enabled", "true", "--conditions", condition, "--actions", actions}, + {"rule", "update", "--email", "a@example.com", "--id", "1", "--name", "n", "--enabled", "false", "--conditions", condition, "--actions", actions}, + } { + if err := executeMailEdge(t, &scriptedToolCaller{}, args...); err != nil { + t.Fatalf("rule %v: %v", args, err) + } + } + for _, args := range [][]string{ + {"rule", "create", "--email", "a@example.com", "--name", "n", "--enabled", "true", "--conditions", `bad`, "--actions", actions}, + {"rule", "update", "--email", "a@example.com", "--id", "1", "--name", "n", "--enabled", "true", "--conditions", `bad`, "--actions", actions}, + } { + if err := executeMailEdge(t, &scriptedToolCaller{}, args...); err == nil { + t.Fatalf("bad rule %v returned nil", args) + } + } +} + +func TestMailDraftInlineFailureEdges(t *testing.T) { + previousArgs := os.Args + os.Args = []string{"dws", "mail"} + defer func() { os.Args = previousArgs }() + inline := filepath.Join(t.TempDir(), "image.png") + if err := os.WriteFile(inline, []byte("image"), 0o600); err != nil { + t.Fatal(err) + } + mailboxes := `{"emailAccounts":[{"email":"a@example.com","type":"PERSONAL"}]}` + draft := `{"messageId":"message"}` + upload := `{"uploadUrl":"https://upload.invalid"}` + boom := errors.New("boom") + + for _, steps := range [][]scriptedToolStep{ + {{text: mailboxes}, {err: boom}}, + {{text: mailboxes}, {text: draft}, {err: boom}}, + {{text: mailboxes}, {text: draft}, {text: `{}`}}, + } { + caller := &scriptedToolCaller{steps: steps} + previous := deps + InitDeps(caller) + deps.Out.w, deps.Out.errW = io.Discard, io.Discard + _, _ = runMailDraftWithAttachment("create_draft", map[string]any{"from": "a@example.com"}, "", "body", nil, []string{inline}) + deps = previous + } + + previousPut := mailPutAttachment + mailPutAttachment = func(context.Context, string, string, string, int64) error { return boom } + defer func() { mailPutAttachment = previousPut }() + previous := deps + InitDeps(&scriptedToolCaller{steps: []scriptedToolStep{{text: mailboxes}, {text: draft}, {text: upload}}}) + deps.Out.w, deps.Out.errW = io.Discard, io.Discard + _, _ = runMailDraftWithAttachment("create_draft", map[string]any{"from": "a@example.com"}, "", "body", nil, []string{inline}) + deps = previous + + previous = deps + InitDeps(&scriptedToolCaller{steps: []scriptedToolStep{{text: mailboxes}, {err: boom}}}) + deps.Out.w, deps.Out.errW = io.Discard, io.Discard + _, _ = runMailDraftWithAttachment("ignored", map[string]any{"from": "a@example.com"}, "message", "body", nil, nil) + deps = previous +} + +func TestMailDownloadWorkflowEdges(t *testing.T) { + previousArgs := os.Args + os.Args = []string{"dws", "mail"} + defer func() { os.Args = previousArgs }() + makeCmd := func() *cobra.Command { + cmd := &cobra.Command{Use: "download"} + cmd.Flags().String("email", "a@example.com", "") + cmd.Flags().String("message-id", "message", "") + cmd.Flags().String("attachment-id", "attachment", "") + cmd.Flags().String("name", "file.txt", "") + cmd.Flags().String("output", t.TempDir(), "") + return cmd + } + boom := errors.New("boom") + cases := [][]scriptedToolStep{ + {{err: boom}}, + {{text: `{}`}, {err: boom}}, + {{text: `{}`}, {text: `{}`}}, + } + for _, steps := range cases { + previous := deps + InitDeps(&scriptedToolCaller{steps: steps}) + deps.Out.w, deps.Out.errW = io.Discard, io.Discard + _ = runMailAttachmentDownload(makeCmd()) + deps = previous + } + + previousGet := mailGetAttachment + defer func() { mailGetAttachment = previousGet }() + for _, getErr := range []error{boom, nil} { + mailGetAttachment = func(context.Context, string, string, string) error { return getErr } + previous := deps + InitDeps(&scriptedToolCaller{steps: []scriptedToolStep{{text: `{}`}, {text: `{"downloadUrl":"https://download.invalid"}`}}}) + deps.Out.w, deps.Out.errW = io.Discard, io.Discard + _ = runMailAttachmentDownload(makeCmd()) + deps = previous + } +} + +type mailRoundTripFunc func(*http.Request) (*http.Response, error) + +func (f mailRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) } + +type mailFailingReadCloser struct{} + +func (mailFailingReadCloser) Read([]byte) (int, error) { return 0, errors.New("read") } +func (mailFailingReadCloser) Close() error { return nil } + +func TestMailHTTPAndLimitRemainingEdges(t *testing.T) { + file := filepath.Join(t.TempDir(), "file.txt") + if err := os.WriteFile(file, []byte("data"), 0o600); err != nil { + t.Fatal(err) + } + if err := httpPutMailAttachment(context.Background(), "PERSONAL", "http://[::1", file, 4); err == nil { + t.Fatal("invalid upload URL returned nil") + } + if err := httpGetMailAttachment(context.Background(), "PERSONAL", "http://[::1", filepath.Join(t.TempDir(), "out")); err == nil { + t.Fatal("invalid download URL returned nil") + } + + previousClient := mailHTTPClient + defer func() { mailHTTPClient = previousClient }() + mailHTTPClient = func() *http.Client { + return &http.Client{Transport: mailRoundTripFunc(func(*http.Request) (*http.Response, error) { + return nil, errors.New("round trip") + })} + } + if err := httpPutMailAttachment(context.Background(), "PERSONAL", "https://upload.invalid", file, 4); err == nil { + t.Fatal("upload transport error returned nil") + } + + mailHTTPClient = func() *http.Client { + return &http.Client{Transport: mailRoundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK, Body: mailFailingReadCloser{}}, nil + })} + } + if err := httpGetMailAttachment(context.Background(), "PERSONAL", "https://download.invalid", filepath.Join(t.TempDir(), "out")); err == nil { + t.Fatal("download copy error returned nil") + } + + if _, err := validateMailboxThreadLimit(&cobra.Command{Use: "missing"}); err == nil { + t.Fatal("missing limit flag returned nil") + } +} diff --git a/internal/helpers/minutes.go b/internal/helpers/minutes.go index e9855a6e..0bcc154b 100644 --- a/internal/helpers/minutes.go +++ b/internal/helpers/minutes.go @@ -3,7 +3,6 @@ package helpers import ( "fmt" "strconv" - "strings" "github.com/spf13/cobra" ) @@ -755,11 +754,6 @@ func newMinutesCommand() *cobra.Command { } memberUids := parseCSVValues(mustGetFlag(cmd, "member-uids")) - for _, uid := range memberUids { - if strings.TrimSpace(uid) == "" { - return fmt.Errorf("flag --member-uids contains empty UID") - } - } toolArgs := map[string]any{ "uuids": parseCSVValues(v), @@ -812,11 +806,6 @@ func newMinutesCommand() *cobra.Command { } memberUids := parseCSVValues(mustGetFlag(cmd, "member-uids")) - for _, uid := range memberUids { - if strings.TrimSpace(uid) == "" { - return fmt.Errorf("flag --member-uids contains empty UID") - } - } return callMCPTool("remove_member_permission", map[string]any{ "uuids": parseCSVValues(v), diff --git a/internal/helpers/oa_coverage_more_test.go b/internal/helpers/oa_coverage_more_test.go new file mode 100644 index 00000000..a663ae9f --- /dev/null +++ b/internal/helpers/oa_coverage_more_test.go @@ -0,0 +1,33 @@ +package helpers + +import "testing" + +func TestOARemainingTimeAndRevertBranches(t *testing.T) { + installScriptedCaller(t, &scriptedToolCaller{dry: true}) + for _, args := range [][]string{ + {"approval", "list-pending", "--start", "bad", "--end", "2030-01-01T10:00:00+08:00"}, + {"approval", "list-pending", "--start", "2030-01-01T09:00:00+08:00", "--end", "bad"}, + {"approval", "list-pending", "--start", "2030-01-01T10:00:00+08:00", "--end", "2030-01-01T09:00:00+08:00"}, + {"approval", "list-initiated", "--process-code", "code", "--start", "bad", "--end", "2030-01-01T10:00:00+08:00"}, + {"approval", "list-initiated", "--process-code", "code", "--start", "2030-01-01T09:00:00+08:00", "--end", "bad"}, + {"approval", "list-initiated", "--process-code", "code", "--start", "2030-01-01T10:00:00+08:00", "--end", "2030-01-01T09:00:00+08:00"}, + } { + if err := executeFilterCoverage(t, newOaCommand(), args...); err == nil { + t.Fatalf("args=%v returned nil", args) + } + } + + if err := executeFilterCoverage(t, newOaCommand(), + "approval", "list-pending", + "--start", "2030-01-01T09:00:00+08:00", "--end", "2030-01-01T10:00:00+08:00", + "--page", "2", "--size", "20", "--query", "travel", + ); err != nil { + t.Fatalf("pending options: %v", err) + } + if err := executeFilterCoverage(t, newOaCommand(), + "approval", "revert-task", "--instance-id", "instance", "--task-id", "12", + "--target-activity-id", "activity", "--action", "REVERT_FOR_APPROVAL", "--remark", "retry", + ); err != nil { + t.Fatalf("revert task: %v", err) + } +} diff --git a/internal/helpers/output_coverage_more_test.go b/internal/helpers/output_coverage_more_test.go new file mode 100644 index 00000000..4add0fa7 --- /dev/null +++ b/internal/helpers/output_coverage_more_test.go @@ -0,0 +1,50 @@ +package helpers + +import ( + "bytes" + "context" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" +) + +type outputFilterCaller struct{} + +func (outputFilterCaller) CallTool(context.Context, string, string, map[string]any) (*edition.ToolResult, error) { + return &edition.ToolResult{}, nil +} +func (outputFilterCaller) Format() string { return "" } +func (outputFilterCaller) DryRun() bool { return false } +func (outputFilterCaller) Fields() string { return "name" } +func (outputFilterCaller) JQ() string { return "" } + +func TestFormatterRemainingFilterBranches(t *testing.T) { + origDeps := deps + t.Cleanup(func() { deps = origDeps }) + var out bytes.Buffer + f := &Formatter{w: &out, errW: &out} + + deps = nil + if handled, err := f.applyGlobalFilter(map[string]any{"name": "alice"}); handled || err != nil { + t.Fatalf("nil deps handled=%v err=%v", handled, err) + } + deps = &Deps{Out: f} + if handled, err := f.applyGlobalFilter(map[string]any{"name": "alice"}); handled || err != nil { + t.Fatalf("nil caller handled=%v err=%v", handled, err) + } + + deps = &Deps{Caller: outputFilterCaller{}, Out: f} + if err := f.PrintJSON(map[string]any{"name": "alice", "hidden": true}); err != nil { + t.Fatalf("filtered JSON: %v", err) + } + if err := f.PrintJSONUnescaped(map[string]any{"name": "bob", "hidden": true}); err != nil { + t.Fatalf("filtered unescaped JSON: %v", err) + } + if out.Len() == 0 { + t.Fatal("filtered output is empty") + } + if got := runeWidth("中a"); got != 3 { + t.Fatalf("rune width=%d", got) + } + f.PrintTable([]string{"name"}, nil) +} diff --git a/internal/helpers/polling_coverage_more_test.go b/internal/helpers/polling_coverage_more_test.go index 8f1df99e..aaa04ac2 100644 --- a/internal/helpers/polling_coverage_more_test.go +++ b/internal/helpers/polling_coverage_more_test.go @@ -112,6 +112,9 @@ func TestDocPollingCoverage(t *testing.T) { func TestSheetExportPollingCoverage(t *testing.T) { installImmediateTiming(t) + oldArgs := os.Args + os.Args = []string{"dws", "sheet"} + t.Cleanup(func() { os.Args = oldArgs }) cases := []struct { format string steps []scriptedToolStep diff --git a/internal/helpers/report.go b/internal/helpers/report.go index c6834c10..27924488 100644 --- a/internal/helpers/report.go +++ b/internal/helpers/report.go @@ -31,6 +31,15 @@ const ( reportDispatchAuthFailureHint = "dws auth login" ) +var ( + reportOpenFile = os.Open + reportAbsPath = filepath.Abs + reportGetwd = os.Getwd + reportEvalSymlinks = filepath.EvalSymlinks + reportStat = os.Stat + reportRelPath = filepath.Rel +) + // ────────────────────────────────────────────────────────── // dws report — 钉钉日志 // MCP tools: get_available_report_templates, get_template_details_by_name, @@ -551,16 +560,9 @@ func enrichReportListReadable(ctx context.Context, operation string, parsed any) "command": "dws report entry get --report-id " + entry.reportID + " --format json", }) } - if includeContent { - if _, ok := row["日志内容"]; !ok { - row["日志内容"] = "" - } - } else { + if !includeContent { delete(row, "日志内容") } - if _, ok := row["钉钉链接"]; !ok { - row["钉钉链接"] = "查看详情" - } rows = append(rows, row) } title := reportReadableListTitle(operation) @@ -1387,7 +1389,7 @@ func resolveReportContentsFromFlags(cmd *cobra.Command) (string, error) { } func readReportContentsFile(filePath string) (string, error) { - file, err := os.Open(filePath) + file, err := reportOpenFile(filePath) if err != nil { if os.IsNotExist(err) { return "", &CLIError{ @@ -1451,11 +1453,11 @@ func resolveSafeReportContentsFilePath(filePath string) (string, error) { return "", reportContentsFilePathError(filePath, "parent-directory traversal is not allowed") } - absPath, err := filepath.Abs(cleanPath) + absPath, err := reportAbsPath(cleanPath) if err != nil { return "", reportContentsFilePathError(filePath, err.Error()) } - cwd, err := os.Getwd() + cwd, err := reportGetwd() if err != nil { return "", &CLIError{ Code: CodeFileNotFound, @@ -1464,14 +1466,14 @@ func resolveSafeReportContentsFilePath(filePath string) (string, error) { Cause: err, } } - cwdAbs, err := filepath.Abs(cwd) + cwdAbs, err := reportAbsPath(cwd) if err != nil { return "", reportContentsFilePathError(filePath, err.Error()) } if !pathWithinRoot(cwdAbs, absPath) { return "", reportContentsFilePathError(filePath, "path must stay under the current working directory") } - rootPath, err := filepath.EvalSymlinks(cwdAbs) + rootPath, err := reportEvalSymlinks(cwdAbs) if err != nil { return "", &CLIError{ Code: CodeFileNotFound, @@ -1480,7 +1482,7 @@ func resolveSafeReportContentsFilePath(filePath string) (string, error) { Cause: err, } } - info, err := os.Stat(absPath) + info, err := reportStat(absPath) if err != nil { if os.IsNotExist(err) { return "", &CLIError{ @@ -1501,7 +1503,7 @@ func resolveSafeReportContentsFilePath(filePath string) (string, error) { if info.IsDir() { return "", reportContentsFilePathError(filePath, "must point to a file, not a directory") } - realPath, err := filepath.EvalSymlinks(absPath) + realPath, err := reportEvalSymlinks(absPath) if err != nil { return "", &CLIError{ Code: CodeFileNotFound, @@ -1521,7 +1523,7 @@ func pathEscapesUpward(cleanPath string) bool { } func pathWithinRoot(rootPath, targetPath string) bool { - rel, err := filepath.Rel(rootPath, targetPath) + rel, err := reportRelPath(rootPath, targetPath) if err != nil { return false } diff --git a/internal/helpers/report_coverage_more_test.go b/internal/helpers/report_coverage_more_test.go new file mode 100644 index 00000000..7b52d8bd --- /dev/null +++ b/internal/helpers/report_coverage_more_test.go @@ -0,0 +1,201 @@ +package helpers + +import ( + "context" + "errors" + "io" + "os" + "path/filepath" + "testing" + + "github.com/spf13/cobra" +) + +func reportCreateCoverageCommand(t *testing.T, values map[string]string) *cobra.Command { + t.Helper() + cmd := &cobra.Command{Use: "create"} + addReportCreateFlags(cmd) + for name, value := range values { + if err := cmd.Flags().Set(name, value); err != nil { + t.Fatalf("set %s: %v", name, err) + } + } + return cmd +} + +func reportListCoverageCommand(t *testing.T, sent bool, values map[string]string) *cobra.Command { + t.Helper() + cmd := &cobra.Command{Use: "list"} + if sent { + addReportSentFlags(cmd) + } else { + addReportListFlags(cmd) + } + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + for name, value := range values { + if err := cmd.Flags().Set(name, value); err != nil { + t.Fatalf("set %s: %v", name, err) + } + } + return cmd +} + +func TestReportHandlerRemainingCoverage(t *testing.T) { + caller := &reportTestCaller{dry: true, format: "json"} + installReportTestDeps(t, caller) + validContents := `[{"key":"Done","sort":"0","content":"work","contentType":"markdown","type":"1"}]` + + if err := runReportCreate(reportCreateCoverageCommand(t, map[string]string{ + "template-id": "template", "contents": "[]", + }), nil); err == nil { + t.Fatal("empty contents array returned nil") + } + if err := runReportCreate(reportCreateCoverageCommand(t, map[string]string{ + "template-id": "template", "contents": validContents, "dd-from": "", "to-chat": "true", "to-user-ids": "u1,u2", + }), nil); err != nil { + t.Fatalf("valid report create: %v", err) + } + + start := "2026-01-01T00:00:00+08:00" + end := "2026-01-02T00:00:00+08:00" + if err := runReportList(reportListCoverageCommand(t, false, map[string]string{ + "start": end, "end": start, + }), nil); err == nil { + t.Fatal("reversed inbox range returned nil") + } + if err := runReportList(reportListCoverageCommand(t, false, map[string]string{ + "start": start, "end": end, "limit": "3", "sender-user-ids": "u1,u2", + }), nil); err != nil { + t.Fatalf("inbox filters: %v", err) + } + + if err := runReportSent(reportListCoverageCommand(t, true, map[string]string{ + "start": end, "end": start, + }), nil); err == nil { + t.Fatal("reversed outbox range returned nil") + } + if err := runReportSent(reportListCoverageCommand(t, true, map[string]string{ + "start": start, "end": end, "limit": "4", "modified-start": start, "modified-end": end, "template-name": "daily", + }), nil); err != nil { + t.Fatalf("outbox filters: %v", err) + } +} + +func TestReportReadableRemainingCoverage(t *testing.T) { + caller := &reportTestCaller{format: "json", response: "{"} + installReportTestDeps(t, caller) + if got := reportDetailForListRow(context.Background(), "report", true); got.markdownLink != "查看详情" { + t.Fatalf("invalid detail fallback = %#v", got) + } + if got := reportMarkdownLinkFromResponse(map[string]any{"dingtalkOpenMarkdownLink": " [open](url) "}); got != "[open](url)" { + t.Fatalf("direct markdown link = %q", got) + } + if maps := reportResponseMaps("not an object"); maps != nil { + t.Fatalf("non-object response maps = %#v", maps) + } + if got := reportTimeValueToString("2026-01-02T03:04:05Z"); got == "" || got == "2026-01-02T03:04:05Z" { + t.Fatalf("RFC3339 time = %q", got) + } + if got := reportTimeValueToString("1970-01-01T00:00:00Z"); got == "" || got == "1970-01-01T00:00:00Z" { + t.Fatalf("epoch RFC3339 time = %q", got) + } + if _, err := readReportContentsStdin(failingReader{}); err == nil { + t.Fatal("stdin read error returned nil") + } + if _, err := normalizeReportContentType(true, "1", 0); err == nil { + t.Fatal("non-scalar content type returned nil") + } +} + +func TestReportFilesystemFailureCoverage(t *testing.T) { + originalOpen := reportOpenFile + originalAbs := reportAbsPath + originalGetwd := reportGetwd + originalEval := reportEvalSymlinks + originalStat := reportStat + originalRel := reportRelPath + t.Cleanup(func() { + reportOpenFile = originalOpen + reportAbsPath = originalAbs + reportGetwd = originalGetwd + reportEvalSymlinks = originalEval + reportStat = originalStat + reportRelPath = originalRel + }) + boom := errors.New("filesystem failure") + + reportOpenFile = func(string) (*os.File, error) { return nil, boom } + if _, err := readReportContentsFile("blocked.json"); err == nil { + t.Fatal("generic open error returned nil") + } + reportOpenFile = originalOpen + + reportAbsPath = func(string) (string, error) { return "", boom } + if _, err := resolveSafeReportContentsFilePath("report.json"); err == nil { + t.Fatal("absolute path error returned nil") + } + reportAbsPath = originalAbs + + root := t.TempDir() + reportGetwd = func() (string, error) { return "", boom } + if _, err := resolveSafeReportContentsFilePath(filepath.Join(root, "report.json")); err == nil { + t.Fatal("getwd error returned nil") + } + reportGetwd = func() (string, error) { return root, nil } + + absCalls := 0 + reportAbsPath = func(path string) (string, error) { + absCalls++ + if absCalls == 2 { + return "", boom + } + return originalAbs(path) + } + if _, err := resolveSafeReportContentsFilePath(filepath.Join(root, "report.json")); err == nil { + t.Fatal("cwd absolute path error returned nil") + } + reportAbsPath = originalAbs + + reportEvalSymlinks = func(string) (string, error) { return "", boom } + if _, err := resolveSafeReportContentsFilePath(filepath.Join(root, "report.json")); err == nil { + t.Fatal("root symlink resolution error returned nil") + } + reportEvalSymlinks = originalEval + + reportStat = func(string) (os.FileInfo, error) { return nil, boom } + if _, err := resolveSafeReportContentsFilePath(filepath.Join(root, "report.json")); err == nil { + t.Fatal("generic stat error returned nil") + } + reportStat = originalStat + + directory := filepath.Join(root, "directory") + if err := os.Mkdir(directory, 0o700); err != nil { + t.Fatal(err) + } + if _, err := resolveSafeReportContentsFilePath(directory); err == nil { + t.Fatal("directory path returned nil") + } + + file := filepath.Join(root, "report.json") + if err := os.WriteFile(file, []byte("[]"), 0o600); err != nil { + t.Fatal(err) + } + evalCalls := 0 + reportEvalSymlinks = func(path string) (string, error) { + evalCalls++ + if evalCalls == 2 { + return "", boom + } + return originalEval(path) + } + if _, err := resolveSafeReportContentsFilePath(file); err == nil { + t.Fatal("file symlink resolution error returned nil") + } + reportEvalSymlinks = originalEval + + reportRelPath = func(string, string) (string, error) { return "", boom } + if _, err := resolveSafeReportContentsFilePath(file); err == nil { + t.Fatal("relative path error returned nil") + } +} diff --git a/internal/helpers/sheet_batch_test.go b/internal/helpers/sheet_batch_test.go index 38b0f88b..2c43c856 100644 --- a/internal/helpers/sheet_batch_test.go +++ b/internal/helpers/sheet_batch_test.go @@ -1,10 +1,14 @@ package helpers import ( + "errors" + "io" "os" "path/filepath" "strings" "testing" + + "github.com/spf13/cobra" ) func TestSheetBatchOperationTranslationCoversEveryMapping(t *testing.T) { @@ -74,6 +78,9 @@ func TestSheetBatchValueConversionsAndDefaults(t *testing.T) { if _, ok := BuildFillRangeArgs(nil)["fillType"]; ok { t.Fatal("empty fill type should be omitted") } + if got := BuildFillRangeArgs(map[string]any{"fill-type": "down"})["fillType"]; got != "down" { + t.Fatalf("fill type = %v", got) + } if got := BuildGroupDimensionArgs(nil)["groupState"]; got != "expand" { t.Fatalf("default group state = %v", got) } @@ -119,4 +126,106 @@ func TestResolveCSVContent(t *testing.T) { if got := resolveCsvContent("plain\r\n"); got != strings.ReplaceAll("plain\r\n", "\r", "") { t.Fatalf("plain csv = %q", got) } + + closedRead, closedWrite, err := os.Pipe() + if err != nil { + t.Fatalf("closed pipe: %v", err) + } + _ = closedWrite.Close() + _ = closedRead.Close() + os.Stdin = closedRead + if got := resolveCsvContent("-"); got != "-" { + t.Fatalf("failed stdin read = %q", got) + } +} + +func executeSheetBatchCommand(t *testing.T, caller *scriptedToolCaller, cmd *cobra.Command, args ...string) error { + t.Helper() + oldDeps := deps + oldArgs := os.Args + InitDeps(caller) + deps.Out.w = io.Discard + deps.Out.errW = io.Discard + os.Args = []string{"dws", "sheet"} + t.Cleanup(func() { + deps = oldDeps + os.Args = oldArgs + }) + cmd.SilenceErrors = true + cmd.SilenceUsage = true + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.SetArgs(args) + return cmd.Execute() +} + +func batchUpdateCoverageCommand() *cobra.Command { + cmd := newBatchUpdateCmd() + cmd.Flags().String("node", "", "") + cmd.Flags().String("operations", "", "") + cmd.Flags().Bool("continue-on-error", false, "") + return cmd +} + +func rangeBatchClearCoverageCommand() *cobra.Command { + cmd := newRangeBatchClearCmd() + cmd.Flags().String("node", "", "") + cmd.Flags().String("ranges", "", "") + cmd.Flags().String("type", "", "") + return cmd +} + +func TestSheetBatchUpdateCommandRemainingCoverage(t *testing.T) { + for _, args := range [][]string{ + nil, + {"--node", "node", "--operations", "["}, + } { + if err := executeSheetBatchCommand(t, &scriptedToolCaller{}, batchUpdateCoverageCommand(), args...); err == nil { + t.Fatalf("batch arguments %v returned nil", args) + } + } + invalid := []string{ + "[]", + "[1]", + `[{"toolName":"unknown","input":{}}]`, + } + for _, operations := range invalid { + if err := executeSheetBatchCommand(t, &scriptedToolCaller{}, batchUpdateCoverageCommand(), "--node", "node", "--operations", operations); err == nil { + t.Fatalf("operations %s returned nil", operations) + } + } + + valid := `[{"toolName":"range fill","input":{"sheet-id":"sheet","source-range":"A1","target-range":"A2","fill-type":"down"}}]` + if err := executeSheetBatchCommand(t, &scriptedToolCaller{}, batchUpdateCoverageCommand(), "--node", "node", "--operations", valid); err != nil { + t.Fatalf("batch update success: %v", err) + } + boom := errors.New("batch failed") + if err := executeSheetBatchCommand(t, &scriptedToolCaller{steps: []scriptedToolStep{{err: boom}}}, batchUpdateCoverageCommand(), "--node", "node", "--operations", valid); err == nil { + t.Fatal("strict batch error returned nil") + } + if err := executeSheetBatchCommand(t, &scriptedToolCaller{steps: []scriptedToolStep{{err: boom}}}, batchUpdateCoverageCommand(), "--node", "node", "--operations", valid, "--continue-on-error"); err == nil { + t.Fatal("lenient batch error returned nil") + } +} + +func TestSheetRangeBatchClearCommandRemainingCoverage(t *testing.T) { + for _, args := range [][]string{ + nil, + {"--node", "node", "--ranges", "["}, + } { + if err := executeSheetBatchCommand(t, &scriptedToolCaller{}, rangeBatchClearCoverageCommand(), args...); err == nil { + t.Fatalf("clear arguments %v returned nil", args) + } + } + for _, ranges := range []string{"[]", `["A1:B2"]`, `["!A1:B2"]`, `["Sheet!"]`} { + if err := executeSheetBatchCommand(t, &scriptedToolCaller{}, rangeBatchClearCoverageCommand(), "--node", "node", "--ranges", ranges); err == nil { + t.Fatalf("ranges %s returned nil", ranges) + } + } + if err := executeSheetBatchCommand(t, &scriptedToolCaller{}, rangeBatchClearCoverageCommand(), "--node", "node", "--ranges", `[" Sheet ! A1:B2 "]`); err != nil { + t.Fatalf("default clear type: %v", err) + } + if err := executeSheetBatchCommand(t, &scriptedToolCaller{}, rangeBatchClearCoverageCommand(), "--node", "node", "--ranges", `["Sheet!A1:B2"]`, "--type", "all"); err != nil { + t.Fatalf("explicit clear type: %v", err) + } } diff --git a/internal/helpers/sheet_chart_coverage_more_test.go b/internal/helpers/sheet_chart_coverage_more_test.go new file mode 100644 index 00000000..203bf06d --- /dev/null +++ b/internal/helpers/sheet_chart_coverage_more_test.go @@ -0,0 +1,71 @@ +package helpers + +import ( + "os" + "path/filepath" + "testing" +) + +const validChartPropertiesCoverage = `{"position":{"row":0,"col":"A"},"dimensions":{"width":100,"height":100},"chart":{"type":"line","series":[{"value":["A1:A2"]}]}}` + +func TestSheetChartCommandRemainingCoverage(t *testing.T) { + installScriptedCaller(t, &scriptedToolCaller{dry: true}) + base := []string{"--node", "node", "--sheet-id", "sheet"} + for _, args := range [][]string{ + append([]string{"create"}, base...), + append(append([]string{"create"}, base...), "--properties", "@"+filepath.Join(t.TempDir(), "missing")), + append(append([]string{"create"}, base...), "--properties", `{}`), + append(append([]string{"update"}, base...), "--chart-id", "chart"), + append(append([]string{"update"}, base...), "--chart-id", "chart", "--properties", "@"+filepath.Join(t.TempDir(), "missing")), + append(append([]string{"update"}, base...), "--chart-id", "chart", "--properties", `{}`), + } { + if err := executeFilterCoverage(t, newChartCmd(), args...); err == nil { + t.Fatalf("args=%v returned nil", args) + } + } + + propsFile := filepath.Join(t.TempDir(), "chart.json") + if err := os.WriteFile(propsFile, []byte(validChartPropertiesCoverage), 0o600); err != nil { + t.Fatal(err) + } + for _, name := range []string{"create", "update"} { + args := append([]string{name}, base...) + if name == "update" { + args = append(args, "--chart-id", "chart") + } + args = append(args, "--properties", "@"+propsFile) + if err := executeFilterCoverage(t, newChartCmd(), args...); err != nil { + t.Fatalf("%s from file: %v", name, err) + } + } + + root := newChartCmd() + deleteCmd := findCoverageSubcommand(t, root, "delete") + _ = deleteCmd.Flags().Set("node", "node") + _ = deleteCmd.Flags().Set("sheet-id", "sheet") + _ = deleteCmd.Flags().Set("chart-id", "chart") + oldArgs := os.Args + os.Args = []string{"dws", "--yes"} + if err := deleteCmd.RunE(deleteCmd, nil); err != nil { + t.Fatalf("confirmed delete: %v", err) + } + os.Args = []string{"dws"} + noFile := filepath.Join(t.TempDir(), "no") + if err := os.WriteFile(noFile, []byte("no\n"), 0o600); err != nil { + t.Fatal(err) + } + stdin, err := os.Open(noFile) + if err != nil { + t.Fatal(err) + } + oldStdin := os.Stdin + os.Stdin = stdin + t.Cleanup(func() { + os.Args = oldArgs + os.Stdin = oldStdin + _ = stdin.Close() + }) + if err := deleteCmd.RunE(deleteCmd, nil); err != nil { + t.Fatalf("cancelled delete: %v", err) + } +} diff --git a/internal/helpers/sheet_cond_format_coverage_more_test.go b/internal/helpers/sheet_cond_format_coverage_more_test.go new file mode 100644 index 00000000..084921ed --- /dev/null +++ b/internal/helpers/sheet_cond_format_coverage_more_test.go @@ -0,0 +1,36 @@ +package helpers + +import "testing" + +func TestCondFormatRemainingCoverage(t *testing.T) { + installScriptedCaller(t, &scriptedToolCaller{dry: true}) + base := []string{"create", "--node", "node", "--sheet-id", "sheet"} + for _, extra := range [][]string{ + {"--ranges", "[]", "--condition", `{}`}, + {"--ranges", `["A1"]`, "--condition", `{`}, + {"--ranges", `["A1"]`, "--condition", `{}`, "--cell-style", `{`}, + {"--ranges", `["A1"]`, "--condition", `{}`, "--cell-style", `{}`, "--data-bar-style", `{`}, + } { + if err := executeFilterCoverage(t, newCondFormatCmd(), append(base, extra...)...); err == nil { + t.Fatalf("invalid create arguments %v returned nil", extra) + } + } + if err := executeFilterCoverage(t, newCondFormatCmd(), append(base, + "--ranges", `["A1:B2"]`, + "--condition", `{"numberCondition":{"operator":"greater","value1":"1"}}`, + "--cell-style", `{"bold":true}`, + "--data-bar-style", `{"isGradient":true}`, + )...); err != nil { + t.Fatalf("create all fields: %v", err) + } + + if err := executeFilterCoverage(t, newCondFormatCmd(), + "update", "--node", "node", "--sheet-id", "sheet", "--rule-id", "rule", + "--ranges", `["A1:B2"]`, + "--condition", `{"formulaCondition":{"formula":"=A1>1"}}`, + "--cell-style", `{"bold":true}`, + "--data-bar-style", `{"isGradient":false}`, + ); err != nil { + t.Fatalf("update all fields: %v", err) + } +} diff --git a/internal/helpers/sheet_data.go b/internal/helpers/sheet_data.go index 00714667..13bc679c 100644 --- a/internal/helpers/sheet_data.go +++ b/internal/helpers/sheet_data.go @@ -10,6 +10,11 @@ import ( "github.com/spf13/cobra" ) +var ( + sheetCSVReadAll = io.ReadAll + sheetCSVReadFile = os.ReadFile +) + func newDataCmds() []*cobra.Command { findCmd := &cobra.Command{ Use: "find", @@ -205,13 +210,13 @@ range update 与合并区域冲突时返回 MERGED_CELLS_CONFLICT 的行为。 csvContent := mustGetFlag(cmd, "csv") switch { case csvContent == "-": - data, err := io.ReadAll(os.Stdin) + data, err := sheetCSVReadAll(os.Stdin) if err != nil { return fmt.Errorf("读取 stdin 失败: %w", err) } csvContent = string(data) case strings.HasPrefix(csvContent, "@"): - data, err := os.ReadFile(strings.TrimPrefix(csvContent, "@")) + data, err := sheetCSVReadFile(strings.TrimPrefix(csvContent, "@")) if err != nil { return fmt.Errorf("读取 CSV 文件失败: %w", err) } diff --git a/internal/helpers/sheet_dimension_coverage_more_test.go b/internal/helpers/sheet_dimension_coverage_more_test.go new file mode 100644 index 00000000..720e9c50 --- /dev/null +++ b/internal/helpers/sheet_dimension_coverage_more_test.go @@ -0,0 +1,88 @@ +package helpers + +import ( + "io" + "testing" + + "github.com/spf13/cobra" +) + +func dimensionCoverageCommand(t *testing.T, name string) *cobra.Command { + t.Helper() + for _, cmd := range newDimensionCmds() { + if cmd.Name() == name { + cmd.SilenceErrors = true + cmd.SilenceUsage = true + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + return cmd + } + } + t.Fatalf("dimension command %q not found", name) + return nil +} + +func executeDimensionCoverage(t *testing.T, name string, args ...string) error { + t.Helper() + cmd := dimensionCoverageCommand(t, name) + cmd.SetArgs(args) + return cmd.Execute() +} + +func TestDimensionValidationRemainingCoverage(t *testing.T) { + installScriptedCaller(t, &scriptedToolCaller{dry: true}) + common := []string{"--node", "node", "--sheet-id", "sheet"} + if err := executeDimensionCoverage(t, "insert-dimension", append(common, "--dimension", "ROWS", "--position", "1", "--length", "5001")...); err == nil { + t.Fatal("oversized insert returned nil") + } + for _, tc := range []struct { + dimension string + extra []string + wantErr bool + }{ + {"ROW", []string{"--start-index", "1", "--end-index", "2", "--destination-index", "3"}, false}, + {"COLUMN", []string{"--start-index", "A", "--end-index", "B", "--destination-index", "C"}, false}, + {"ROWS", []string{"--end-index", "2", "--destination-index", "3"}, true}, + {"ROWS", []string{"--start-index", "1", "--destination-index", "3"}, true}, + {"ROWS", []string{"--start-index", "1", "--end-index", "2"}, true}, + } { + args := append(append([]string{}, common...), "--dimension", tc.dimension) + args = append(args, tc.extra...) + err := executeDimensionCoverage(t, "move-dimension", args...) + if (err != nil) != tc.wantErr { + t.Errorf("move %s %v error=%v", tc.dimension, tc.extra, err) + } + } + + add := dimensionCoverageCommand(t, "add-dimension") + _ = add.Flags().Set("dimension", "ROWS") + add.Flags().Lookup("length").Value = invalidIntFlagValue{} + if err := add.RunE(add, nil); err == nil { + t.Fatal("invalid add length returned nil") + } + if err := executeDimensionCoverage(t, "add-dimension", append(common, "--dimension", "ROWS", "--length", "5001")...); err == nil { + t.Fatal("oversized add returned nil") + } + if err := executeDimensionCoverage(t, "delete-dimension", append(common, "--dimension", "ROWS", "--position", "1", "--length", "5001")...); err == nil { + t.Fatal("oversized delete returned nil") + } + if err := executeDimensionCoverage(t, "update-dimension", append(common, "--dimension", "ROWS", "--start-index", "1", "--length", "5001", "--hidden")...); err == nil { + t.Fatal("oversized update returned nil") + } + if err := executeDimensionCoverage(t, "update-dimension", append(common, "--dimension", "ROWS", "--start-index", "1", "--length", "1")...); err == nil { + t.Fatal("update without property returned nil") + } +} + +func TestDropdownValidationRemainingCoverage(t *testing.T) { + installScriptedCaller(t, &scriptedToolCaller{dry: true}) + base := []string{"--node", "node", "--sheet-id", "sheet", "--range", "A1"} + for _, options := range []string{"[]", `[{"value":"a,b"}]`} { + if err := executeDimensionCoverage(t, "set-dropdown", append(base, "--options", options)...); err == nil { + t.Fatalf("options %s returned nil", options) + } + } + if err := executeDimensionCoverage(t, "set-dropdown", append(base, "--options", `[{"value":"a"}]`, "--multi-select")...); err != nil { + t.Fatalf("multi-select dropdown: %v", err) + } +} diff --git a/internal/helpers/sheet_export_coverage_more_test.go b/internal/helpers/sheet_export_coverage_more_test.go new file mode 100644 index 00000000..e42aa496 --- /dev/null +++ b/internal/helpers/sheet_export_coverage_more_test.go @@ -0,0 +1,87 @@ +package helpers + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" +) + +func executeSheetExportCoverage(t *testing.T, caller *scriptedToolCaller, args ...string) error { + t.Helper() + installScriptedCaller(t, caller) + oldArgs := os.Args + os.Args = []string{"dws", "sheet"} + t.Cleanup(func() { os.Args = oldArgs }) + cmd := newExportCmd() + for index := 0; index < len(args); index += 2 { + if err := cmd.Flags().Set(args[index], args[index+1]); err != nil { + t.Fatalf("set %s: %v", args[index], err) + } + } + return runSheetExport(cmd, nil) +} + +func TestSheetExportCommandRemainingCoverage(t *testing.T) { + installImmediateTiming(t) + if err := executeSheetExportCoverage(t, &scriptedToolCaller{}); err == nil { + t.Fatal("missing node returned nil") + } + if err := executeSheetExportCoverage(t, &scriptedToolCaller{dry: true}, "node", "node", "output", "file.xlsx"); err != nil { + t.Fatalf("dry run with output: %v", err) + } + if err := executeSheetExportCoverage(t, &scriptedToolCaller{dry: true}, "node", "node"); err != nil { + t.Fatalf("dry run without output: %v", err) + } + + successSteps := func(url string) []scriptedToolStep { + return []scriptedToolStep{{text: `{"jobId":"job"}`}, {text: `{"status":"SUCCESS","downloadUrl":"` + url + `"}`}} + } + if err := executeSheetExportCoverage(t, &scriptedToolCaller{steps: []scriptedToolStep{{err: errors.New("submit")}}}, "node", "node"); err == nil { + t.Fatal("submit error returned nil") + } + if err := executeSheetExportCoverage(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{`}}}, "node", "node"); err == nil { + t.Fatal("invalid submit response returned nil") + } + if err := executeSheetExportCoverage(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"jobId":"job"}`}, {text: `{`}}}, "node", "node"); err == nil { + t.Fatal("poll parse error returned nil") + } + if err := executeSheetExportCoverage(t, &scriptedToolCaller{format: "table", steps: successSteps("https://example.test/file.xlsx")}, "node", "node"); err != nil { + t.Fatalf("table URL result: %v", err) + } + if err := executeSheetExportCoverage(t, &scriptedToolCaller{format: "json", steps: successSteps("https://example.test/file.xlsx")}, "node", "node"); err != nil { + t.Fatalf("JSON URL result: %v", err) + } + + oldGet := httpGetFile + t.Cleanup(func() { httpGetFile = oldGet }) + var destination string + httpGetFile = func(_ context.Context, _ string, _ map[string]string, output string) error { + destination = output + return nil + } + directory := t.TempDir() + if err := executeSheetExportCoverage(t, &scriptedToolCaller{format: "table", steps: successSteps("https://example.test/")}, "node", "node", "output", directory); err != nil { + t.Fatalf("directory output: %v", err) + } + if filepath.Base(destination) != "sheet-export-job.xlsx" { + t.Fatalf("fallback destination = %q", destination) + } + file := filepath.Join(directory, "explicit.xlsx") + if err := executeSheetExportCoverage(t, &scriptedToolCaller{format: "json", steps: successSteps("https://example.test/file.xlsx")}, "node", "node", "output", file); err != nil { + t.Fatalf("JSON file output: %v", err) + } + + boom := errors.New("download failed") + httpGetFile = func(context.Context, string, map[string]string, string) error { return boom } + if err := executeSheetExportCoverage(t, &scriptedToolCaller{format: "table", steps: successSteps("https://example.test/file.xlsx")}, "node", "node", "output", file); err == nil { + t.Fatal("download error returned nil") + } +} + +func TestSheetExportFilenameDotCoverage(t *testing.T) { + if got := inferSheetExportFilename("https://example.test/."); got != "" { + t.Fatalf("dot filename = %q", got) + } +} diff --git a/internal/helpers/sheet_filter_view_coverage_more_test.go b/internal/helpers/sheet_filter_view_coverage_more_test.go new file mode 100644 index 00000000..dbc4aec6 --- /dev/null +++ b/internal/helpers/sheet_filter_view_coverage_more_test.go @@ -0,0 +1,153 @@ +package helpers + +import ( + "errors" + "io" + "os" + "testing" + + "github.com/spf13/cobra" +) + +type invalidIntFlagValue struct{} + +func (invalidIntFlagValue) Set(string) error { return nil } +func (invalidIntFlagValue) String() string { return "not-an-int" } +func (invalidIntFlagValue) Type() string { return "int" } + +func filterViewHandlerCommand(column bool) *cobra.Command { + cmd := &cobra.Command{Use: "handler"} + cmd.Flags().String("node", "node", "") + cmd.Flags().String("sheet-id", "sheet", "") + cmd.Flags().String("filter-view-id", "one", "") + if column { + cmd.Flags().Int("column", 0, "") + } + return cmd +} + +func executeFilterCoverage(t *testing.T, root *cobra.Command, args ...string) error { + t.Helper() + oldArgs := os.Args + os.Args = []string{"dws", "sheet"} + t.Cleanup(func() { os.Args = oldArgs }) + root.SilenceErrors = true + root.SilenceUsage = true + root.SetOut(io.Discard) + root.SetErr(io.Discard) + root.SetArgs(args) + return root.Execute() +} + +func findCoverageSubcommand(t *testing.T, root *cobra.Command, name string) *cobra.Command { + t.Helper() + for _, child := range root.Commands() { + if child.Name() == name { + return child + } + } + t.Fatalf("subcommand %q not found", name) + return nil +} + +func TestFilterViewFetchAndHandlerRemainingCoverage(t *testing.T) { + oldArgs := os.Args + os.Args = []string{"dws", "sheet"} + t.Cleanup(func() { os.Args = oldArgs }) + + for _, step := range []scriptedToolStep{{err: errors.New("fetch")}, {text: ""}, {text: "{"}} { + installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{step}}) + if _, err := fetchFilterViews("node", "sheet"); err == nil { + t.Fatalf("fetch step %#v returned nil", step) + } + } + installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{}`}}}) + if views, err := fetchFilterViews("node", "sheet"); err != nil || views != nil { + t.Fatalf("missing views = %#v, %v", views, err) + } + payload := `{"result":{"filterViews":[1,{"id":"one","criteria":{"0":{"filterType":"values"}}},{"filterViewId":"two"}]}}` + installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: payload}}}) + views, err := fetchFilterViews("node", "sheet") + if err != nil || len(views) != 2 { + t.Fatalf("views = %#v, %v", views, err) + } + + installScriptedCaller(t, &scriptedToolCaller{dry: true}) + if err := runFilterViewInfo(filterViewHandlerCommand(false), nil); err != nil { + t.Fatal(err) + } + if err := runFilterViewListCriteria(filterViewHandlerCommand(false), nil); err != nil { + t.Fatal(err) + } + if err := runFilterViewGetCriteria(filterViewHandlerCommand(true), nil); err != nil { + t.Fatal(err) + } + + installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: payload}}}) + if err := runFilterViewInfo(filterViewHandlerCommand(false), nil); err != nil { + t.Fatalf("info: %v", err) + } + if err := runFilterViewListCriteria(filterViewHandlerCommand(false), nil); err != nil { + t.Fatalf("list criteria: %v", err) + } + if err := runFilterViewGetCriteria(filterViewHandlerCommand(true), nil); err != nil { + t.Fatalf("get criteria: %v", err) + } + + noCriteria := `{"filterViews":[{"id":"one"}]}` + installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: noCriteria}}}) + if err := runFilterViewListCriteria(filterViewHandlerCommand(false), nil); err != nil { + t.Fatalf("empty criteria list: %v", err) + } + if err := runFilterViewGetCriteria(filterViewHandlerCommand(true), nil); err == nil { + t.Fatal("empty criteria get returned nil") + } + + missingColumn := `{"filterViews":[{"id":"one","criteria":{"1":{}}}]}` + installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: missingColumn}}}) + if err := runFilterViewGetCriteria(filterViewHandlerCommand(true), nil); err == nil { + t.Fatal("missing column criteria returned nil") + } + + negative := filterViewHandlerCommand(true) + _ = negative.Flags().Set("column", "-1") + if err := runFilterViewGetCriteria(negative, nil); err == nil { + t.Fatal("negative column returned nil") + } + invalid := filterViewHandlerCommand(true) + invalid.Flags().Lookup("column").Value = invalidIntFlagValue{} + if err := runFilterViewGetCriteria(invalid, nil); err == nil { + t.Fatal("invalid column flag returned nil") + } +} + +func TestFilterCommandOptionalArgumentRemainingCoverage(t *testing.T) { + installScriptedCaller(t, &scriptedToolCaller{dry: true}) + common := []string{"--node", "node", "--sheet-id", "sheet"} + if err := executeFilterCoverage(t, newFilterCmd(), append([]string{"create"}, append(common, "--range", "A1:B2", "--criteria", "[]")...)...); err != nil { + t.Fatalf("filter create criteria: %v", err) + } + if err := executeFilterCoverage(t, newFilterCmd(), append([]string{"update"}, append(common, "--criteria", "[]")...)...); err != nil { + t.Fatalf("filter update criteria: %v", err) + } + + viewCommon := []string{"--node", "node", "--sheet-id", "sheet"} + if err := executeFilterCoverage(t, newFilterViewCmd(), append([]string{"create"}, append(viewCommon, "--name", "view", "--range", "A1:B2", "--criteria", "[]")...)...); err != nil { + t.Fatalf("view create criteria: %v", err) + } + withID := append(viewCommon, "--filter-view-id", "view") + if err := executeFilterCoverage(t, newFilterViewCmd(), append([]string{"update"}, append(withID, "--criteria", "[]")...)...); err != nil { + t.Fatalf("view update criteria: %v", err) + } + if err := executeFilterCoverage(t, newFilterViewCmd(), append([]string{"update-criteria"}, append(withID, "--column", "1", "--filter-criteria", `{}`)...)...); err != nil { + t.Fatalf("view set criteria: %v", err) + } + + for _, name := range []string{"update-criteria", "delete-criteria"} { + cmd := findCoverageSubcommand(t, newFilterViewCmd(), name) + cmd.Flags().Lookup("column").Value = invalidIntFlagValue{} + if err := cmd.RunE(cmd, nil); err == nil { + t.Fatalf("%s invalid column returned nil", name) + } + } +} diff --git a/internal/helpers/sheet_info_coverage_more_test.go b/internal/helpers/sheet_info_coverage_more_test.go new file mode 100644 index 00000000..94614823 --- /dev/null +++ b/internal/helpers/sheet_info_coverage_more_test.go @@ -0,0 +1,17 @@ +package helpers + +import ( + "os" + "testing" +) + +func TestSheetCellInfoCleanupRemainingCoverage(t *testing.T) { + oldArgs := os.Args + os.Args = []string{"dws", "sheet"} + t.Cleanup(func() { os.Args = oldArgs }) + payload := `{"cells":["not-a-row",[1,{"value":"plain"},{"dataValidation":{"type":null},"hyperlink":{"url":null}},{"dataValidation":{"type":"list"},"hyperlink":{"url":"https://example.test"}}]]}` + installScriptedCaller(t, &scriptedToolCaller{format: "json", steps: []scriptedToolStep{{text: payload}}}) + if err := callMCPToolCellInfos(map[string]any{"nodeId": "node"}); err != nil { + t.Fatalf("cell info cleanup: %v", err) + } +} diff --git a/internal/helpers/sheet_media_coverage_more_test.go b/internal/helpers/sheet_media_coverage_more_test.go new file mode 100644 index 00000000..f62e523a --- /dev/null +++ b/internal/helpers/sheet_media_coverage_more_test.go @@ -0,0 +1,146 @@ +package helpers + +import ( + "context" + "errors" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/spf13/cobra" +) + +func executeSheetMediaEdge(t *testing.T, index int, caller *scriptedToolCaller, args ...string) error { + t.Helper() + old := deps + InitDeps(caller) + deps.Out.w = io.Discard + deps.Out.errW = io.Discard + t.Cleanup(func() { deps = old }) + commands := newMediaCmds() + cmd := commands[index] + cmd.SilenceErrors = true + cmd.SilenceUsage = true + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.SetArgs(args) + return cmd.Execute() +} + +func TestSheetMediaUploadRemainingCoverage(t *testing.T) { + file := filepath.Join(t.TempDir(), "photo.png") + if err := os.WriteFile(file, []byte("image"), 0o600); err != nil { + t.Fatal(err) + } + cases := []struct { + name string + args []string + }{ + {"node", nil}, + {"file", []string{"--node", "node"}}, + {"stat", []string{"--node", "node", "--file", file + ".missing"}}, + {"directory", []string{"--node", "node", "--file", t.TempDir()}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if err := executeSheetMediaEdge(t, 0, &scriptedToolCaller{}, tc.args...); err == nil { + t.Fatal("validation returned nil") + } + }) + } + if err := executeSheetMediaEdge(t, 0, &scriptedToolCaller{dry: true}, "--node", "node", "--file", file, "--name", "renamed"); err != nil { + t.Fatalf("dry run: %v", err) + } + + boom := errors.New("boom") + if err := executeSheetMediaEdge(t, 0, &scriptedToolCaller{steps: []scriptedToolStep{{err: boom}}}, "--node", "node", "--file", file); !errors.Is(err, boom) { + t.Fatalf("tool error=%v", err) + } + if err := executeSheetMediaEdge(t, 0, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{}`}}}, "--node", "node", "--file", file); err == nil { + t.Fatal("credential parse error returned nil") + } + + origPut := httpPutFile + t.Cleanup(func() { httpPutFile = origPut }) + credential := `{"result":{"uploadUrl":"https://upload.invalid","resourceId":"resource","resourceUrl":"https://resource.invalid"}}` + httpPutFile = func(context.Context, string, map[string]string, string, int64) error { return boom } + if err := executeSheetMediaEdge(t, 0, &scriptedToolCaller{steps: []scriptedToolStep{{text: credential}}}, "--node", "node", "--file", file); !errors.Is(err, boom) { + t.Fatalf("put error=%v", err) + } + httpPutFile = func(context.Context, string, map[string]string, string, int64) error { return nil } + for _, format := range []string{"json", "pretty"} { + caller := &scriptedToolCaller{format: format, steps: []scriptedToolStep{{text: credential}}} + if err := executeSheetMediaEdge(t, 0, caller, "--node", "node", "--file", file, "--mime-type", "image/png"); err != nil { + t.Fatalf("format %s: %v", format, err) + } + } +} + +func TestSheetWriteImageRemainingCoverage(t *testing.T) { + file := filepath.Join(t.TempDir(), "photo.png") + if err := os.WriteFile(file, []byte("image"), 0o600); err != nil { + t.Fatal(err) + } + cases := [][]string{ + nil, + {"--node", "node"}, + {"--node", "node", "--sheet-id", "sheet"}, + {"--node", "node", "--sheet-id", "sheet", "--range", "A1"}, + {"--node", "node", "--sheet-id", "sheet", "--range", "A1", "--file", file + ".missing"}, + {"--node", "node", "--sheet-id", "sheet", "--range", "A1", "--file", t.TempDir()}, + } + for _, args := range cases { + if err := executeSheetMediaEdge(t, 1, &scriptedToolCaller{}, args...); err == nil { + t.Fatalf("validation %v returned nil", args) + } + } + base := []string{"--node", "node", "--sheet-id", "sheet", "--range", "A1", "--file", file, "--name", "renamed"} + defaultName := []string{"--node", "node", "--sheet-id", "sheet", "--range", "A1", "--file", file} + if err := executeSheetMediaEdge(t, 1, &scriptedToolCaller{dry: true}, defaultName...); err != nil { + t.Fatalf("default name dry run: %v", err) + } + if err := executeSheetMediaEdge(t, 1, &scriptedToolCaller{dry: true}, base...); err != nil { + t.Fatalf("dry run: %v", err) + } + + boom := errors.New("boom") + if err := executeSheetMediaEdge(t, 1, &scriptedToolCaller{steps: []scriptedToolStep{{err: boom}}}, base...); !errors.Is(err, boom) { + t.Fatalf("tool error=%v", err) + } + if err := executeSheetMediaEdge(t, 1, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{}`}}}, base...); err == nil { + t.Fatal("credential parse error returned nil") + } + + origPut := httpPutFile + t.Cleanup(func() { httpPutFile = origPut }) + credential := `{"result":{"uploadUrl":"https://upload.invalid","resourceId":"resource","resourceUrl":"https://resource.invalid"}}` + httpPutFile = func(context.Context, string, map[string]string, string, int64) error { return boom } + if err := executeSheetMediaEdge(t, 1, &scriptedToolCaller{steps: []scriptedToolStep{{text: credential}}}, base...); !errors.Is(err, boom) { + t.Fatalf("put error=%v", err) + } + httpPutFile = func(context.Context, string, map[string]string, string, int64) error { return nil } + + for _, format := range []string{"json", "pretty"} { + steps := []scriptedToolStep{{text: credential}, {text: `{}`}} + caller := &scriptedToolCaller{format: format, steps: steps} + args := append(append([]string(nil), base...), "--width", "20", "--height", "10") + if err := executeSheetMediaEdge(t, 1, caller, args...); err != nil { + t.Fatalf("format %s: %v", format, err) + } + } + caller := &scriptedToolCaller{format: "pretty", steps: []scriptedToolStep{{text: credential}, {err: boom}}} + if err := executeSheetMediaEdge(t, 1, caller, base...); !errors.Is(err, boom) && (err == nil || !strings.Contains(err.Error(), "boom")) { + t.Fatalf("write error=%v", err) + } +} + +func TestSheetMediaCommandDefinitionsCoverage(t *testing.T) { + for _, cmd := range newMediaCmds() { + if cmd.RunE == nil || cmd.Name() == "" { + t.Fatalf("invalid media command %#v", cmd) + } + } + _ = (&cobra.Command{}).Name() +} diff --git a/internal/helpers/sheet_range_ops_coverage_more_test.go b/internal/helpers/sheet_range_ops_coverage_more_test.go new file mode 100644 index 00000000..bfe179d0 --- /dev/null +++ b/internal/helpers/sheet_range_ops_coverage_more_test.go @@ -0,0 +1,34 @@ +package helpers + +import "testing" + +func TestSheetRangeUpdateAndSortRemainingCoverage(t *testing.T) { + installScriptedCaller(t, &scriptedToolCaller{dry: true}) + base := []string{"update", "--node", "node", "--sheet-id", "sheet", "--range", "A1", "--values"} + for _, raw := range []string{ + `{`, + `[[null]]`, + `[[1]]`, + `[[{"type":"unsupported"}]]`, + } { + if err := executeFilterCoverage(t, newRangeCmd(), append(base, raw)...); err == nil { + t.Fatalf("invalid values %s returned nil", raw) + } + } + for _, raw := range []string{ + `[[{}]]`, + `[[{"type":"text","text":"ok"}]]`, + } { + if err := executeFilterCoverage(t, newRangeCmd(), append(base, raw)...); err != nil { + t.Fatalf("valid values %s: %v", raw, err) + } + } + + sortBase := []string{"sort", "--node", "node", "--sheet-id", "sheet", "--range", "A1:B2", "--sort-keys"} + if err := executeFilterCoverage(t, newRangeCmd(), append(sortBase, "{")...); err == nil { + t.Fatal("invalid sort keys returned nil") + } + if err := executeFilterCoverage(t, newRangeCmd(), append(sortBase, `[{"column":"A","ascending":true}]`, "--has-header")...); err != nil { + t.Fatalf("sort with header: %v", err) + } +} diff --git a/internal/helpers/sheet_style_coverage_more_test.go b/internal/helpers/sheet_style_coverage_more_test.go index 8d61769e..d20b4ac2 100644 --- a/internal/helpers/sheet_style_coverage_more_test.go +++ b/internal/helpers/sheet_style_coverage_more_test.go @@ -78,13 +78,16 @@ func TestSheetBatchSetStyleCoverage(t *testing.T) { {name: "missing-fields-stop", body: `[{"sheetId":""}]`}, {name: "missing-fields-continue", body: `[{"sheetId":""},{"sheetId":"Sheet1","range":"A1","bgColor":"red"}]`, cont: true}, {name: "invalid-range", body: `[{"sheetId":"Sheet1","range":"bad","bgColor":"red"}]`}, + {name: "invalid-range-continue", body: `[{"sheetId":"Sheet1","range":"bad","bgColor":"red"},{"sheetId":"Sheet1","range":"A1","bgColor":"red"}]`, cont: true}, {name: "invalid-style", body: `[{"sheetId":"Sheet1","range":"A1"}]`}, + {name: "invalid-style-continue", body: `[{"sheetId":"Sheet1","range":"A1"},{"sheetId":"Sheet1","range":"A2","bgColor":"red"}]`, cont: true}, {name: "json-success", body: `[{"sheetId":"Sheet1","range":"A1","bgColor":"red"}]`, format: "json", response: `{"ok":true}`}, {name: "json-raw-result", body: `[{"sheetId":"Sheet1","range":"A1","bgColor":"red"}]`, format: "json", response: `{`}, {name: "json-call-error-stop", body: `[{"sheetId":"Sheet1","range":"A1","bgColor":"red"}]`, format: "json", callErr: errors.New("failed")}, {name: "json-call-error-continue", body: `[{"sheetId":"Sheet1","range":"A1","bgColor":"red"},{"sheetId":"Sheet1","range":"A2","fontSize":12}]`, format: "json", callErr: errors.New("failed"), cont: true}, {name: "raw-success", body: `[{"sheetId":"Sheet1","range":"A1","bgColor":"red"}]`, format: "raw", response: `{"ok":true}`}, {name: "raw-call-error", body: `[{"sheetId":"Sheet1","range":"A1","bgColor":"red"}]`, format: "raw", callErr: errors.New("failed"), cont: true}, + {name: "raw-call-error-stop", body: `[{"sheetId":"Sheet1","range":"A1","bgColor":"red"}]`, format: "raw", callErr: errors.New("failed")}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { diff --git a/internal/helpers/small_remaining_coverage_test.go b/internal/helpers/small_remaining_coverage_test.go new file mode 100644 index 00000000..6f6cf477 --- /dev/null +++ b/internal/helpers/small_remaining_coverage_test.go @@ -0,0 +1,49 @@ +package helpers + +import ( + "context" + "errors" + "net/http" + "strings" + "testing" + "time" + + "github.com/spf13/cobra" +) + +func TestSheetAndMinutesSmallRemainingBranches(t *testing.T) { + if err := applyStyleSpec(&styleSpec{FontColorsJSON: "{"}, 1, 1, map[string]any{}); err == nil { + t.Fatal("invalid font colors JSON returned nil") + } + + root := &cobra.Command{Use: "sheet"} + group := &cobra.Command{Use: "range", Run: func(*cobra.Command, []string) {}} + group.AddCommand(&cobra.Command{Use: "read", Run: func(*cobra.Command, []string) {}}) + root.AddCommand(group) + attachUnknownSubcommandGuard(root) + if err := root.RunE(root, []string{"read"}); err == nil || !strings.Contains(err.Error(), "range read") { + t.Fatalf("deep suggestion err=%v", err) + } + + installScriptedCaller(t, &scriptedToolCaller{dry: true}) + if err := executeFilterCoverage(t, newMinutesCommand(), "permission", "add", "--ids", "id", "--member-uids", "uid", "--policy", "3", "--cover=false"); err != nil { + t.Fatalf("explicit false cover: %v", err) + } + if err := executeFilterCoverage(t, newMinutesCommand(), "list", "all", "--start", "2030-01-01T10:00:00+08:00", "--end", "2030-01-01T09:00:00+08:00"); err == nil { + t.Fatal("reversed minutes range returned nil") + } +} + +func TestGeminiForwardRemainingFailures(t *testing.T) { + invalid := &geminiAPIForwarder{baseURL: "%", model: "model", timeout: time.Second} + if _, err := invalid.forward(context.Background(), "", "text"); err == nil { + t.Fatal("invalid Gemini endpoint returned nil") + } + client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return nil, errors.New("transport") + })} + f := &geminiAPIForwarder{baseURL: "https://example.test", model: "model", apiKey: "key", timeout: time.Second, httpClient: client} + if _, err := f.forward(context.Background(), "", "text"); err == nil || !strings.Contains(err.Error(), "transport") { + t.Fatalf("Gemini transport err=%v", err) + } +} diff --git a/internal/helpers/todo.go b/internal/helpers/todo.go index 7031474b..c8b3a69a 100644 --- a/internal/helpers/todo.go +++ b/internal/helpers/todo.go @@ -22,6 +22,8 @@ import ( const todoListPageSizeMax = 20 +var todoFileMD5Hex = fileMD5Hex + func newTodoCommand() *cobra.Command { todoCmd := &cobra.Command{ Use: "todo", @@ -869,7 +871,7 @@ func buildTodoLocalFileMeta(filePath, fileName, md5Value string) (todoLocalFileM } fileType := strings.TrimPrefix(filepath.Ext(fileName), ".") if md5Value == "" { - md5Value, err = fileMD5Hex(filePath) + md5Value, err = todoFileMD5Hex(filePath) if err != nil { return todoLocalFileMeta{}, err } diff --git a/internal/helpers/todo_command_edges_more_test.go b/internal/helpers/todo_command_edges_more_test.go new file mode 100644 index 00000000..2dd45bf1 --- /dev/null +++ b/internal/helpers/todo_command_edges_more_test.go @@ -0,0 +1,250 @@ +package helpers + +import ( + "context" + "errors" + "io" + "os" + "path/filepath" + "testing" +) + +func executeTodoEdge(t *testing.T, caller *scriptedToolCaller, args ...string) error { + t.Helper() + previous := deps + previousArgs := os.Args + os.Args = append([]string{"dws", "todo"}, os.Args[1:]...) + InitDeps(caller) + deps.Out.w = io.Discard + deps.Out.errW = io.Discard + defer func() { + deps = previous + os.Args = previousArgs + }() + + cmd := newTodoCommand() + cmd.SilenceErrors = true + cmd.SilenceUsage = true + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.SetArgs(args) + return cmd.Execute() +} + +func TestTodoCreateAndListCommandEdges(t *testing.T) { + validDate := "2026-03-10T18:00:00+08:00" + errorCases := [][]string{ + {"task", "create", "--title", "x", "--executors", "u", "--remind-at", validDate}, + {"task", "create", "--title", "x", "--executors", "u", "--due", "bad"}, + {"task", "create-sub", "--title", "x", "--executors", "u", "--parent-id", "abc"}, + {"task", "create-sub", "--title", "x", "--executors", "u", "--parent-id", "1", "--remind-at", validDate}, + {"task", "create-sub", "--title", "x", "--executors", "u", "--parent-id", "1", "--due", "bad"}, + {"task", "list", "--role-types", "invalid"}, + {"task", "list", "--plan-finish-date-start", "bad"}, + {"task", "list", "--plan-finish-date-end", "bad"}, + } + for _, args := range errorCases { + if err := executeTodoEdge(t, &scriptedToolCaller{}, args...); err == nil { + t.Fatalf("expected error for %v", args) + } + } + + validCases := [][]string{ + {"task", "create", "--title", "x", "--executors", "u1,u2", "--due", validDate, "--priority", "40", "--recurrence", "daily"}, + {"task", "create", "--subject", "x", "--executors", "u", "--priority", "bad"}, + {"task", "create-sub", "--content", "x", "--executors", "u", "--parent-id", "1", "--due", validDate, "--priority", "40", "--recurrence", "daily"}, + {"task", "create-sub", "--title", "x", "--executors", "u", "--parent-id", "1", "--priority", "bad"}, + {"task", "list", "--size", "bad"}, + {"task", "list", "--status", "true", "--priority", "40,bad", "--role-types", "creator,executor", "--plan-finish-date-start", validDate, "--plan-finish-date-end", validDate}, + } + for _, args := range validCases { + if err := executeTodoEdge(t, &scriptedToolCaller{}, args...); err != nil { + t.Fatalf("execute %v: %v", args, err) + } + } + if err := executeTodoEdge(t, &scriptedToolCaller{dry: true}, "task", "list", "--size", "21"); err != nil { + t.Fatalf("dry auto-page: %v", err) + } +} + +func TestTodoSimpleCommandValidationAndSuccessEdges(t *testing.T) { + errorCases := [][]string{ + {"task", "create"}, + {"task", "create", "--title", "x"}, + {"task", "create-sub"}, + {"task", "create-sub", "--title", "x"}, + {"task", "list-sub"}, + {"task", "update"}, + {"task", "done"}, + {"task", "get"}, + {"task", "delete"}, + {"task", "add-executor"}, + {"task", "remove-executor"}, + {"task", "add-participant"}, + {"task", "remove-participant"}, + {"task", "add-reminder"}, + {"task", "reset-reminder"}, + {"task", "add-attachment"}, + {"task", "list-attachment"}, + {"task", "remove-attachment"}, + {"comment", "add"}, + {"comment", "list"}, + {"comment", "delete"}, + } + for _, args := range errorCases { + if err := executeTodoEdge(t, &scriptedToolCaller{}, args...); err == nil { + t.Fatalf("expected validation error for %v", args) + } + } + + validCases := [][]string{ + {"task", "list-sub", "--task-id", "1"}, + {"task", "done", "--task-id", "1", "--status", "true"}, + {"task", "add-executor", "--task-id", "1", "--executors", "u1,u2"}, + {"task", "remove-executor", "--task-id", "1", "--executors", "u1,u2"}, + {"task", "add-participant", "--task-id", "1", "--participants", "u1,u2"}, + {"task", "remove-participant", "--task-id", "1", "--participants", "u1,u2"}, + {"task", "list-attachment", "--task-id", "1"}, + {"comment", "add", "--task-id", "1", "--content", "hello"}, + {"comment", "list", "--task-id", "1"}, + } + for _, args := range validCases { + if err := executeTodoEdge(t, &scriptedToolCaller{}, args...); err != nil { + t.Fatalf("execute %v: %v", args, err) + } + } +} + +func TestTodoUpdateReminderAndDetailEdges(t *testing.T) { + validDate := "2026-03-10T18:00:00+08:00" + errorCases := [][]string{ + {"task", "update", "--task-id", "1", "--remind-at", validDate}, + {"task", "update", "--task-id", "1", "--due", "bad"}, + {"task", "add-reminder", "--task-id", "1", "--base-time", "customTime", "--reminder-time-stamp", "bad"}, + {"task", "reset-reminder", "--task-id", "1", "--reminder-rules", `[{"baseTime":"customTime","reminderTimeStamp":"bad"}]`}, + } + for _, args := range errorCases { + if err := executeTodoEdge(t, &scriptedToolCaller{}, args...); err == nil { + t.Fatalf("expected error for %v", args) + } + } + validCases := [][]string{ + {"task", "update", "--task-id", "1", "--title", "new", "--due", validDate, "--priority", "40", "--done", "true"}, + {"task", "update", "--task-id", "1", "--priority", "bad"}, + {"task", "add-reminder", "--task-id", "1", "--base-time", "dueTime", "--due-date-offset", "-10"}, + {"task", "add-reminder", "--task-id", "1", "--base-time", "customTime", "--reminder-time-stamp", validDate}, + {"task", "reset-reminder", "--task-id", "1", "--reminder-rules", `not-json`}, + {"task", "reset-reminder", "--task-id", "1", "--reminder-rules", `[1,{"baseTime":"dueTime"},{"baseTime":"customTime","reminderTimeStamp":"` + validDate + `"}]`}, + } + for _, args := range validCases { + if err := executeTodoEdge(t, &scriptedToolCaller{}, args...); err != nil { + t.Fatalf("execute %v: %v", args, err) + } + } + + details := []scriptedToolStep{ + {text: ""}, + {text: "not-json"}, + {text: `{"result":{"todoDetailModel":{"detailUrl":{"appUrl":"https://app.example/a b","pcUrl":"https://pc.example/a b"}}}}`}, + } + for _, step := range details { + if err := executeTodoEdge(t, &scriptedToolCaller{steps: []scriptedToolStep{step}}, "task", "get", "--task-id", "1"); err != nil { + t.Fatalf("detail %q: %v", step.text, err) + } + } + boom := errors.New("boom") + if err := executeTodoEdge(t, &scriptedToolCaller{steps: []scriptedToolStep{{err: boom}}}, "task", "get", "--task-id", "1"); err == nil { + t.Fatal("detail tool error returned nil") + } +} + +func TestTodoAttachmentCommandEdges(t *testing.T) { + file := filepath.Join(t.TempDir(), "attachment.txt") + if err := os.WriteFile(file, []byte("data"), 0o600); err != nil { + t.Fatal(err) + } + if err := executeTodoEdge(t, &scriptedToolCaller{dry: true}, "task", "add-attachment", "--task-id", "1", "--file-path", file); err != nil { + t.Fatalf("dry attachment: %v", err) + } + if err := executeTodoEdge(t, &scriptedToolCaller{}, "task", "add-attachment", "--task-id", "1", "--file-path", file+".missing"); err == nil { + t.Fatal("missing attachment returned nil") + } + + originalPut := httpPutFile + defer func() { httpPutFile = originalPut }() + httpPutFile = func(context.Context, string, map[string]string, string, int64) error { return nil } + credential := `{"resourceUrl":"https://upload.invalid","uploadKey":"key"}` + for _, tc := range []struct { + name string + steps []scriptedToolStep + ok bool + }{ + {"upload-error", []scriptedToolStep{{err: errors.New("upload")}}, false}, + {"commit-parse-error", []scriptedToolStep{{text: credential}, {text: `{}`}}, false}, + {"success", []scriptedToolStep{{text: credential}, {text: `{"dentryId":1,"spaceId":2}`}, {text: `{}`}}, true}, + } { + err := executeTodoEdge(t, &scriptedToolCaller{steps: tc.steps}, "task", "add-attachment", "--task-id", "1", "--file-path", file) + if tc.ok && err != nil { + t.Fatalf("%s: %v", tc.name, err) + } + if !tc.ok && err == nil { + t.Fatalf("%s returned nil", tc.name) + } + } +} + +func TestTodoDeleteCancellationAndConfirmationEdges(t *testing.T) { + originalArgs, originalStdin := os.Args, os.Stdin + defer func() { + os.Args = originalArgs + os.Stdin = originalStdin + }() + + readEnd, writeEnd, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + if _, err := writeEnd.WriteString("no\nno\nno\n"); err != nil { + t.Fatal(err) + } + _ = writeEnd.Close() + os.Stdin = readEnd + os.Args = []string{"dws"} + for _, args := range [][]string{ + {"task", "delete", "--task-id", "1"}, + {"task", "remove-attachment", "--task-id", "1", "--attachment-id", "2"}, + {"comment", "delete", "--task-id", "1", "--comment-id", "2"}, + } { + if err := executeTodoEdge(t, &scriptedToolCaller{}, args...); err != nil { + t.Fatalf("cancel %v: %v", args, err) + } + } + _ = readEnd.Close() + + os.Args = []string{"dws", "--yes"} + for _, args := range [][]string{ + {"task", "delete", "--task-id", "1"}, + {"task", "remove-attachment", "--task-id", "1", "--attachment-id", "2"}, + {"comment", "delete", "--task-id", "1", "--comment-id", "2"}, + } { + if err := executeTodoEdge(t, &scriptedToolCaller{}, args...); err != nil { + t.Fatalf("confirm %v: %v", args, err) + } + } +} + +func TestTodoMD5AndEmptyListEdges(t *testing.T) { + file := filepath.Join(t.TempDir(), "file.txt") + if err := os.WriteFile(file, []byte("data"), 0o600); err != nil { + t.Fatal(err) + } + original := todoFileMD5Hex + todoFileMD5Hex = func(string) (string, error) { return "", errors.New("md5") } + defer func() { todoFileMD5Hex = original }() + if _, err := buildTodoLocalFileMeta(file, "", ""); err == nil { + t.Fatal("md5 error returned nil") + } + if got := parseStringList(", ,"); got != nil { + t.Fatalf("empty list=%v", got) + } +} diff --git a/internal/helpers/wiki_coverage_more_test.go b/internal/helpers/wiki_coverage_more_test.go new file mode 100644 index 00000000..9340c258 --- /dev/null +++ b/internal/helpers/wiki_coverage_more_test.go @@ -0,0 +1,171 @@ +package helpers + +import ( + "io" + "os" + "strings" + "testing" + + "github.com/spf13/cobra" +) + +func newProxyTestRoot(proxy *cobra.Command, target *cobra.Command) *cobra.Command { + root := &cobra.Command{Use: "dws"} + root.AddCommand(proxy) + if target != nil { + root.AddCommand(target) + } + return root +} + +func TestProxySubCommandCoverage(t *testing.T) { + t.Run("missing product", func(t *testing.T) { + proxy := proxySubCmd("proxy", "doc", "read", nil) + newProxyTestRoot(proxy, nil) + if err := proxy.RunE(proxy, nil); err == nil { + t.Fatal("missing product returned nil") + } + }) + t.Run("missing nested path", func(t *testing.T) { + proxy := proxySubCmd("proxy", "doc", "block missing", nil) + doc := &cobra.Command{Use: "doc"} + doc.AddCommand(&cobra.Command{Use: "block"}) + newProxyTestRoot(proxy, doc) + if err := proxy.RunE(proxy, nil); err == nil { + t.Fatal("missing nested path returned nil") + } + }) + t.Run("disable parsing RunE", func(t *testing.T) { + var got []string + proxy := proxySubCmd("proxy", "doc", "block read", map[string]string{"old": "new"}) + doc := &cobra.Command{Use: "doc"} + block := &cobra.Command{Use: "block"} + read := &cobra.Command{Use: "read", DisableFlagParsing: true, RunE: func(_ *cobra.Command, args []string) error { + got = append([]string(nil), args...) + return nil + }} + block.AddCommand(read) + doc.AddCommand(block) + newProxyTestRoot(proxy, doc) + args := []string{"value", "--old=x", "--old", "y", "--other=z"} + if err := proxy.RunE(proxy, args); err != nil { + t.Fatal(err) + } + if strings.Join(got, " ") != "value --new=x --new y --other=z" { + t.Fatalf("renamed args = %v", got) + } + }) + t.Run("disable parsing Run", func(t *testing.T) { + called := false + proxy := proxySubCmd("proxy", "doc", "raw", nil) + doc := &cobra.Command{Use: "doc"} + doc.AddCommand(&cobra.Command{Use: "raw", DisableFlagParsing: true, Run: func(*cobra.Command, []string) { called = true }}) + newProxyTestRoot(proxy, doc) + if err := proxy.RunE(proxy, []string{"x"}); err != nil || !called { + t.Fatalf("error=%v called=%v", err, called) + } + }) + t.Run("parsed RunE", func(t *testing.T) { + called := false + proxy := proxySubCmd("proxy", "doc", "read", nil) + doc := &cobra.Command{Use: "doc"} + read := &cobra.Command{Use: "read", RunE: func(cmd *cobra.Command, args []string) error { + called = cmd.Flags().Lookup("name").Value.String() == "value" && len(args) == 1 + return nil + }} + read.Flags().String("name", "", "") + doc.AddCommand(read) + newProxyTestRoot(proxy, doc) + if err := proxy.RunE(proxy, []string{"--name", "value", "arg"}); err != nil || !called { + t.Fatalf("error=%v called=%v", err, called) + } + }) + t.Run("parsed Run and parse error", func(t *testing.T) { + called := false + proxy := proxySubCmd("proxy", "doc", "read", nil) + doc := &cobra.Command{Use: "doc"} + read := &cobra.Command{Use: "read", Run: func(*cobra.Command, []string) { called = true }} + read.Flags().String("known", "", "") + doc.AddCommand(read) + newProxyTestRoot(proxy, doc) + if err := proxy.RunE(proxy, []string{"--unknown"}); err == nil { + t.Fatal("parse error returned nil") + } + if err := proxy.RunE(proxy, []string{"--known", "x"}); err != nil || !called { + t.Fatalf("error=%v called=%v", err, called) + } + }) + t.Run("target itself and no runner", func(t *testing.T) { + proxy := proxySubCmd("proxy", "doc", "", nil) + doc := &cobra.Command{Use: "doc"} + newProxyTestRoot(proxy, doc) + if err := proxy.RunE(proxy, nil); err == nil { + t.Fatal("runner-less target returned nil") + } + }) +} + +func executeWikiEdge(t *testing.T, args ...string) error { + t.Helper() + oldDeps := deps + oldArgs := os.Args + InitDeps(&scriptedToolCaller{}) + deps.Out.w = io.Discard + deps.Out.errW = io.Discard + t.Cleanup(func() { + deps = oldDeps + os.Args = oldArgs + }) + root := newWikiCommand() + installExampleGlobalFlags(root) + root.SilenceErrors = true + root.SilenceUsage = true + root.SetOut(io.Discard) + root.SetErr(io.Discard) + root.SetArgs(args) + os.Args = append([]string{"dws", "wiki"}, args...) + return root.Execute() +} + +func TestWikiRoutingAndValidationEdges(t *testing.T) { + for _, args := range [][]string{ + {"space", "list", "--type", "orgSpace", "--limit", "3", "--cursor", "next"}, + {"space", "list", "--type", "mySpace", "--limit", "not-a-number"}, + {"space", "search", "--type", "myWikiSpace"}, + } { + if err := executeWikiEdge(t, args...); err != nil { + t.Fatalf("Execute(%v): %v", args, err) + } + } + for _, args := range [][]string{ + {"node", "list", "--workspace", "space", "--folder", "123"}, + {"node", "create", "--workspace", "space", "--name", "name", "--folder", "123"}, + {"node", "copy", "--workspace", "space", "--node", "node", "--folder", "123"}, + {"node", "move", "--workspace", "space", "--node", "node", "--folder", "123"}, + } { + if err := executeWikiEdge(t, args...); err == nil { + t.Fatalf("Execute(%v) returned nil", args) + } + } +} + +func TestWikiDeleteCancellationEdges(t *testing.T) { + oldStdin := os.Stdin + t.Cleanup(func() { os.Stdin = oldStdin }) + for _, args := range [][]string{ + {"space", "delete", "--workspace", "space"}, + {"node", "delete", "--workspace", "space", "--node", "node"}, + } { + stdin, err := os.CreateTemp(t.TempDir(), "stdin") + if err != nil { + t.Fatal(err) + } + _, _ = stdin.WriteString("no\n") + _, _ = stdin.Seek(0, 0) + os.Stdin = stdin + if err := executeWikiEdge(t, args...); err != nil { + t.Fatalf("cancel %v: %v", args, err) + } + _ = stdin.Close() + } +}