// 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 app import ( "bytes" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cache" "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/market" mockmcp "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/test/mock_mcp" ) func TestPrintExecutionErrorDefaultsToJSON(t *testing.T) { t.Parallel() root := NewRootCommand() var stdout bytes.Buffer var stderr bytes.Buffer err := printExecutionError(root, &stdout, &stderr, apperrors.NewValidation( "bad flag", apperrors.WithHint("Pass the required flag and retry."), )) if err != nil { t.Fatalf("printExecutionError() error = %v", err) } if stderr.Len() != 0 { t.Fatalf("stderr = %q, want empty for JSON error output", stderr.String()) } if !strings.Contains(stdout.String(), "\"category\": \"validation\"") { t.Fatalf("stdout = %q, want JSON error payload", stdout.String()) } } func TestPrintExecutionErrorUsesJSONWhenFormatIsJSON(t *testing.T) { t.Parallel() root := NewRootCommand() if err := root.PersistentFlags().Set("format", "json"); err != nil { t.Fatalf("Set(format) error = %v", err) } var stdout bytes.Buffer var stderr bytes.Buffer err := printExecutionError(root, &stdout, &stderr, apperrors.NewValidation("bad flag")) if err != nil { t.Fatalf("printExecutionError() error = %v", err) } if stderr.Len() != 0 { t.Fatalf("stderr = %q, want empty for JSON error output", stderr.String()) } if !strings.Contains(stdout.String(), "\"category\": \"validation\"") { t.Fatalf("stdout = %q, want JSON error payload", stdout.String()) } } func TestPrintExecutionErrorUsesJSONWhenCommandSetsJSONFlag(t *testing.T) { setupRuntimeCommandTest(t) server := mockmcp.DefaultServer() defer server.Close() t.Setenv(cli.CatalogFixtureEnv, writeDocCatalogFixture(t, server.RemoteURL("/server/doc"), false)) root := NewRootCommand() root.SetArgs([]string{"mcp", "doc", "search_documents", "--json", "{"}) executed, execErr := root.ExecuteC() if execErr == nil { t.Fatal("ExecuteC() error = nil, want validation error") } if executed == nil { t.Fatal("ExecuteC() returned nil command") } var stdout bytes.Buffer var stderr bytes.Buffer err := printExecutionError(executed, &stdout, &stderr, execErr) if err != nil { t.Fatalf("printExecutionError() error = %v", err) } if stderr.Len() != 0 { t.Fatalf("stderr = %q, want empty for JSON error output", stderr.String()) } if !strings.Contains(stdout.String(), "\"category\": \"validation\"") { t.Fatalf("stdout = %q, want JSON error payload", stdout.String()) } } func TestCompletionCommandUsesConfiguredWriter(t *testing.T) { setupRuntimeCommandTest(t) root := NewRootCommand() var out bytes.Buffer root.SetOut(&out) root.SetErr(&out) root.SetArgs([]string{"completion", "bash"}) if err := root.Execute(); err != nil { t.Fatalf("Execute() error = %v", err) } if !strings.Contains(out.String(), "bash completion for dws") { t.Fatalf("output = %q, want completion script in configured writer", out.String()) } } func TestUnknownSubcommandShowsHelp(t *testing.T) { t.Parallel() root := NewRootCommand() var out bytes.Buffer root.SetOut(&out) root.SetErr(&out) root.SetArgs([]string{"cache", "nonexistent-cmd"}) executed, err := root.ExecuteC() if err == nil { t.Fatal("ExecuteC() error = nil, want unknown command error") } if !isUnknownCommandError(err) { t.Fatalf("isUnknownCommandError() = false for error: %v", err) } // Simulate what Execute() does: redirect output to stderr and print help if executed == nil { executed = root } executed.SetOut(&out) _ = executed.Help() combined := out.String() // Help text should include the parent command's usage if !strings.Contains(combined, "cache") { t.Fatalf("output should contain parent command name 'cache', got:\n%s", combined) } // Help text should list available subcommands if !strings.Contains(combined, "Available Commands") { t.Fatalf("output should contain 'Available Commands', got:\n%s", combined) } if !strings.Contains(combined, "refresh") { t.Fatalf("output should list 'refresh' subcommand, got:\n%s", combined) } } func TestVersionCommandDoesNotRequirePINOrLogin(t *testing.T) { t.Setenv("DWS_CONFIG_DIR", t.TempDir()) root := NewRootCommand() var out bytes.Buffer root.SetOut(&out) root.SetErr(&out) root.SetArgs([]string{"version"}) if err := root.Execute(); err != nil { t.Fatalf("Execute(version) error = %v", err) } if !strings.Contains(out.String(), "\"version\"") { t.Fatalf("version output missing version key:\n%s", out.String()) } } func TestVersionCommandUsesCachedRegistryWithoutBlockingAgedDiscovery(t *testing.T) { t.Setenv("DWS_CONFIG_DIR", t.TempDir()) t.Setenv(cli.CatalogFixtureEnv, "") cacheDir := t.TempDir() t.Setenv(cli.CacheDirEnv, cacheDir) store := cache.NewStore(cacheDir) if err := store.SaveRegistry("default/default", cache.RegistrySnapshot{ SavedAt: time.Now().UTC().Add(-2 * time.Hour), Servers: []market.ServerDescriptor{minimalCLIServer("cached", "https://mcp.dingtalk.com/cached/v1")}, }); err != nil { t.Fatalf("SaveRegistry() error = %v", err) } srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { time.Sleep(300 * time.Millisecond) _ = json.NewEncoder(w).Encode(marketListResponse("network-server")) })) defer srv.Close() SetDiscoveryBaseURL(srv.URL) t.Cleanup(func() { SetDiscoveryBaseURL("") }) root := NewRootCommand() var out bytes.Buffer root.SetOut(&out) root.SetErr(&out) root.SetArgs([]string{"version"}) start := time.Now() if err := root.Execute(); err != nil { t.Fatalf("Execute(version) error = %v", err) } if elapsed := time.Since(start); elapsed >= 200*time.Millisecond { t.Fatalf("Execute(version) took %v, want cached startup under 200ms", elapsed) } if !strings.Contains(out.String(), "\"version\"") { t.Fatalf("version output missing version key:\n%s", out.String()) } } func TestRootHelpDoesNotRequirePINOrLogin(t *testing.T) { t.Setenv("DWS_CONFIG_DIR", t.TempDir()) t.Setenv(cli.CatalogFixtureEnv, "") t.Setenv(cli.CacheDirEnv, t.TempDir()) response := map[string]any{ "metadata": map[string]any{"count": 1, "nextCursor": ""}, "servers": []any{ discoveryServerEntry("aiapp", "AI应用管理", nil, map[string]any{ "create_ai_app": map[string]any{ "cliName": "create", "flags": map[string]any{}, }, }), }, } srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(response) })) defer srv.Close() SetDiscoveryBaseURL(srv.URL) t.Cleanup(func() { SetDiscoveryBaseURL("") }) root := NewRootCommand() var out bytes.Buffer root.SetOut(&out) root.SetErr(&out) root.SetArgs([]string{"--help"}) if err := root.Execute(); err != nil { t.Fatalf("Execute(--help) error = %v", err) } if !strings.Contains(out.String(), "Discovered MCP Services:") { t.Fatalf("root help output missing MCP summary:\n%s", out.String()) } } func TestRootShortHelpDoesNotRequirePINOrLogin(t *testing.T) { t.Setenv("DWS_CONFIG_DIR", t.TempDir()) t.Setenv(cli.CatalogFixtureEnv, "") t.Setenv(cli.CacheDirEnv, t.TempDir()) response := map[string]any{ "metadata": map[string]any{"count": 1, "nextCursor": ""}, "servers": []any{ discoveryServerEntry("devdoc", "开放平台文档搜索", map[string]any{ "article": map[string]any{"description": "文档文章"}, }, map[string]any{ "search_article": map[string]any{ "cliName": "search", "group": "article", "flags": map[string]any{}, }, }), }, } srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(response) })) defer srv.Close() SetDiscoveryBaseURL(srv.URL) t.Cleanup(func() { SetDiscoveryBaseURL("") }) root := NewRootCommand() var out bytes.Buffer root.SetOut(&out) root.SetErr(&out) root.SetArgs([]string{"-h"}) if err := root.Execute(); err != nil { t.Fatalf("Execute(-h) error = %v", err) } if !strings.Contains(out.String(), "Discovered MCP Services:") { t.Fatalf("root short help output missing MCP summary:\n%s", out.String()) } } func TestNestedShortHelpDoesNotRequirePINOrLogin(t *testing.T) { t.Setenv("DWS_CONFIG_DIR", t.TempDir()) t.Setenv(cli.CatalogFixtureEnv, "") t.Setenv(cli.CacheDirEnv, t.TempDir()) response := map[string]any{ "metadata": map[string]any{"count": 1, "nextCursor": ""}, "servers": []any{ discoveryServerEntry("devdoc", "开放平台文档搜索", map[string]any{ "article": map[string]any{"description": "文档文章"}, }, map[string]any{ "search_article": map[string]any{ "cliName": "search", "group": "article", "flags": map[string]any{ "keyword": map[string]any{"alias": "keyword"}, }, }, }), }, } srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(response) })) defer srv.Close() SetDiscoveryBaseURL(srv.URL) t.Cleanup(func() { SetDiscoveryBaseURL("") }) root := NewRootCommand() var out bytes.Buffer root.SetOut(&out) root.SetErr(&out) root.SetArgs([]string{"devdoc", "article", "search", "-h"}) if err := root.Execute(); err != nil { t.Fatalf("Execute(devdoc article search -h) error = %v", err) } if !strings.Contains(out.String(), "devdoc/search") { t.Fatalf("nested short help output missing command title:\n%s", out.String()) } }