diff --git a/internal/app/audit_command.go b/internal/app/audit_command.go index 4f75ab26..10ff49c2 100644 --- a/internal/app/audit_command.go +++ b/internal/app/audit_command.go @@ -13,6 +13,7 @@ import ( "time" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/audit" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" "github.com/spf13/cobra" ) @@ -117,9 +118,29 @@ func newAuditVerifyCommand() *cobra.Command { } } - valid, brokenAt, err := auditVerify(target) - if err != nil { - return fmt.Errorf("校验失败: %w", err) + valid, brokenAt, verifyErr := auditVerify(target) + if output.ResolveFormat(cmd, output.FormatTable) == output.FormatJSON { + if verifyErr != nil && brokenAt == 0 { + return fmt.Errorf("校验失败: %w", verifyErr) + } + payload := map[string]any{ + "valid": valid, + "file": target, + "brokenAt": brokenAt, + } + if verifyErr != nil { + payload["reason"] = verifyErr.Error() + } + if err := output.WriteCommandPayload(cmd, payload, output.FormatTable); err != nil { + return err + } + if !valid { + auditExit(1) + } + return nil + } + if verifyErr != nil { + return fmt.Errorf("校验失败: %w", verifyErr) } if valid { fmt.Printf("✓ %s 哈希链完整(全部通过)\n", filepath.Base(target)) diff --git a/internal/app/audit_output_contract_test.go b/internal/app/audit_output_contract_test.go new file mode 100644 index 00000000..47dc2bca --- /dev/null +++ b/internal/app/audit_output_contract_test.go @@ -0,0 +1,65 @@ +// 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. + +package app + +import ( + "bytes" + "encoding/json" + "errors" + "testing" + + "github.com/spf13/cobra" +) + +func executeAuditVerifyJSON(t *testing.T, verify func(string) (bool, int, error)) (map[string]any, int, error) { + t.Helper() + previousVerify, previousExit := auditVerify, auditExit + t.Cleanup(func() { + auditVerify, auditExit = previousVerify, previousExit + }) + auditVerify = verify + exitCode := 0 + auditExit = func(code int) { exitCode = code } + + root := &cobra.Command{Use: "dws"} + root.SilenceErrors = true + root.SilenceUsage = true + root.PersistentFlags().String("format", "json", "output format") + root.AddCommand(newAuditVerifyCommand()) + var stdout bytes.Buffer + root.SetOut(&stdout) + root.SetArgs([]string{"verify", "--file", "/tmp/audit.jsonl"}) + err := root.Execute() + + var payload map[string]any + if decodeErr := json.Unmarshal(stdout.Bytes(), &payload); decodeErr != nil { + t.Fatalf("audit verify stdout must be one JSON document: %v\n%s", decodeErr, stdout.String()) + } + return payload, exitCode, err +} + +func TestCrossPlatformCoverageAuditVerifyJSONOutputIsSingleDocument(t *testing.T) { + payload, exitCode, err := executeAuditVerifyJSON(t, func(string) (bool, int, error) { + return true, 0, nil + }) + if err != nil || exitCode != 0 { + t.Fatalf("audit verify returned err=%v exit=%d", err, exitCode) + } + if payload["valid"] != true || payload["file"] != "/tmp/audit.jsonl" || payload["brokenAt"] != float64(0) { + t.Fatalf("unexpected audit payload: %#v", payload) + } +} + +func TestCrossPlatformCoverageAuditVerifyBrokenJSONIncludesReasonBeforeExit(t *testing.T) { + payload, exitCode, err := executeAuditVerifyJSON(t, func(string) (bool, int, error) { + return false, 3, errors.New("prev_hash mismatch") + }) + if err != nil || exitCode != 1 { + t.Fatalf("broken audit verify returned err=%v exit=%d", err, exitCode) + } + if payload["valid"] != false || payload["brokenAt"] != float64(3) || payload["reason"] != "prev_hash mismatch" { + t.Fatalf("unexpected broken audit payload: %#v", payload) + } +} diff --git a/internal/app/schema_agent_examples_dry_run_test.go b/internal/app/schema_agent_examples_dry_run_test.go index fa4eca28..b54d9a58 100644 --- a/internal/app/schema_agent_examples_dry_run_test.go +++ b/internal/app/schema_agent_examples_dry_run_test.go @@ -35,6 +35,7 @@ import ( var ( manualAgentExamplePlaceholderPattern = regexp.MustCompile(`<([^>]+)>`) manualAgentExampleDryRunJSONPattern = regexp.MustCompile(`(?i)"dry_run"\s*:\s*true`) + manualAgentExampleDryRunPlanPattern = regexp.MustCompile(`(?i)"preview_kind"\s*:\s*"plan"`) ) // TestManualAgentExamplesContract is the always-on gate. It validates every @@ -391,6 +392,9 @@ func manualAgentExampleDryRunObserved(capture manualAgentExampleCapture) bool { func manualAgentExampleDryRunEvidence(capture manualAgentExampleCapture) (string, bool) { normalized := strings.ToLower(capture.Output) + if manualAgentExampleDryRunJSONPattern.MatchString(capture.Output) && manualAgentExampleDryRunPlanPattern.MatchString(capture.Output) { + return cli.DryRunPreviewPlan, true + } if manualAgentExampleDryRunJSONPattern.MatchString(capture.Output) { return cli.DryRunPreviewRequest, true } @@ -428,6 +432,22 @@ func TestManualAgentExampleDryRunEvidenceAcceptsSharedAndCommandPlans(t *testing } } +func TestCrossPlatformCoverageManualAgentExampleDryRunEvidenceClassifiesStructuredPlan(t *testing.T) { + kind, observed := manualAgentExampleDryRunEvidence(manualAgentExampleCapture{ + Output: `{"dry_run":true,"executed":false,"preview_kind":"plan"}`, + DryRunChecks: 1, + }) + if !observed || kind != cli.DryRunPreviewPlan { + t.Fatalf("structured plan classified as kind=%q observed=%v", kind, observed) + } + if manualAgentExampleDryRunObserved(manualAgentExampleCapture{ + Output: `{"dry_run":false,"executed":false,"preview_kind":"plan"}`, + DryRunChecks: 1, + }) { + t.Fatal("non-dry-run structured plan was accepted as evidence") + } +} + func manualAgentExamplePromptObserved(output string) bool { normalized := strings.ToLower(output) for _, marker := range []string{ diff --git a/internal/helpers/chat.go b/internal/helpers/chat.go index faf97923..49f5588b 100644 --- a/internal/helpers/chat.go +++ b/internal/helpers/chat.go @@ -1346,10 +1346,9 @@ func newChatCommand() *cobra.Command { timeVal := mustGetFlag(cmd, "time") if groupID != "" { toolArgs := map[string]any{ - "openCid": groupID, - "cid": groupID, - "time": timeVal, - "forward": forward, + "openconversation_id": groupID, + "time": timeVal, + "forward": forward, } if v := chatIntFlagOrFallback(cmd, "limit", "size"); v > 0 { toolArgs["limit"] = v diff --git a/internal/helpers/chat_message_search_test.go b/internal/helpers/chat_message_search_test.go index 61b08b08..c0051581 100644 --- a/internal/helpers/chat_message_search_test.go +++ b/internal/helpers/chat_message_search_test.go @@ -161,7 +161,7 @@ func executeChatChangedContract(t *testing.T, caller *chatChangedContractCaller, return cmd.Execute() } -func TestCrossPlatformCoverageChatMessageListUsesObservedGroupKeys(t *testing.T) { +func TestCrossPlatformCoverageChatMessageListUsesMCPMetadataGroupKey(t *testing.T) { caller := &chatChangedContractCaller{} err := executeChatChangedContract(t, caller, "message", "list", "--group", "cid-1", "--time", "2026-07-15 09:00:00", "--limit", "50") @@ -171,7 +171,7 @@ func TestCrossPlatformCoverageChatMessageListUsesObservedGroupKeys(t *testing.T) if len(caller.calls) != 1 || caller.calls[0].toolName != "list_conversation_message_v2" { t.Fatalf("calls = %#v", caller.calls) } - want := map[string]any{"openCid": "cid-1", "cid": "cid-1", "time": "2026-07-15 09:00:00", "forward": true, "limit": 50} + want := map[string]any{"openconversation_id": "cid-1", "time": "2026-07-15 09:00:00", "forward": true, "limit": 50} if !reflect.DeepEqual(caller.calls[0].args, want) { t.Fatalf("tool args = %#v, want %#v", caller.calls[0].args, want) } diff --git a/internal/helpers/helpers.go b/internal/helpers/helpers.go index 9d718822..2d75799d 100644 --- a/internal/helpers/helpers.go +++ b/internal/helpers/helpers.go @@ -249,6 +249,14 @@ func callMCPToolInternalOpts(explicitServerID, toolName string, args map[string] // DryRun 模式:仅预览工具名和参数,不实际调用 MCP Server if deps.Caller.DryRun() { + if deps.Caller.Format() == "json" { + return deps.Out.PrintJSON(map[string]any{ + "dry_run": true, + "executed": false, + "tool": toolName, + "arguments": args, + }) + } bold := color.New(color.FgYellow, color.Bold) bold.Println("[DRY-RUN] Preview only, not executed:") deps.Out.PrintKeyValue("Tool", toolName) diff --git a/internal/helpers/helpers_core_test.go b/internal/helpers/helpers_core_test.go index 8ebb89ee..f96b3dbd 100644 --- a/internal/helpers/helpers_core_test.go +++ b/internal/helpers/helpers_core_test.go @@ -3,9 +3,11 @@ package helpers import ( "bytes" "context" + "encoding/json" "errors" "os" "path/filepath" + "reflect" "strings" "testing" @@ -176,6 +178,30 @@ func TestCrossPlatformCoverageMCPOutputModesAndDevdocFormatting(t *testing.T) { } } +func TestCrossPlatformCoverageMCPDryRunJSONOutputIsSingleDocument(t *testing.T) { + caller := &helpersCoreCaller{format: "json", dry: true} + out, _ := installHelpersCoreDeps(t, caller) + + args := map[string]any{"name": "weekly", "enabled": true} + if err := callMCPToolInternalOpts("server", "create_workflow", args, false); err != nil { + t.Fatalf("dry run returned error: %v", err) + } + if caller.calls != 0 { + t.Fatalf("dry run called tool %d times, want 0", caller.calls) + } + + var payload map[string]any + if err := json.Unmarshal(out.Bytes(), &payload); err != nil { + t.Fatalf("dry-run stdout must be one JSON document: %v\n%s", err, out.String()) + } + if payload["dry_run"] != true || payload["executed"] != false || payload["tool"] != "create_workflow" { + t.Fatalf("unexpected dry-run payload: %#v", payload) + } + if !reflect.DeepEqual(payload["arguments"], map[string]any{"name": "weekly", "enabled": true}) { + t.Fatalf("arguments = %#v", payload["arguments"]) + } +} + func TestCrossPlatformCoverageCurrentUserResponseShapes(t *testing.T) { caller := &helpersCoreCaller{} installHelpersCoreDeps(t, caller) diff --git a/internal/helpers/import_flow.go b/internal/helpers/import_flow.go index cc32258b..8e64d569 100644 --- a/internal/helpers/import_flow.go +++ b/internal/helpers/import_flow.go @@ -202,8 +202,21 @@ func runImportCommand(cmd *cobra.Command, args []string, cfg importFlowConfig) e if err != nil { return err } + jsonMode := deps.Caller.Format() == "json" if deps.Caller.DryRun() { + if jsonMode { + return deps.Out.PrintJSON(map[string]any{ + "dry_run": true, + "executed": false, + "preview_kind": "plan", + "operation": cfg.operation, + "file": file.path, + "name": file.name, + "format": file.extension, + "size": file.size, + }) + } deps.Out.PrintKeyValue("操作", cfg.operation) deps.Out.PrintKeyValue("文件", file.path) deps.Out.PrintKeyValue("名称", file.name) @@ -217,7 +230,9 @@ func runImportCommand(cmd *cobra.Command, args []string, cfg importFlowConfig) e ctx = context.Background() } - deps.Out.PrintInfo("[1/4] 创建导入会话...") + if !jsonMode { + deps.Out.PrintInfo("[1/4] 创建导入会话...") + } sessionArgs := map[string]any{ "fileName": file.name, "suffix": file.extension, @@ -241,18 +256,28 @@ func runImportCommand(cmd *cobra.Command, args []string, cfg importFlowConfig) e sessionID, _ := sessionResult["sessionId"].(string) uploadURL, _ := sessionResult["uploadUrl"].(string) if sessionID == "" || uploadURL == "" { - deps.Out.PrintRaw(sessionText) + if !jsonMode { + deps.Out.PrintRaw(sessionText) + } return fmt.Errorf("创建导入会话成功但缺少 sessionId 或 uploadUrl") } - deps.Out.PrintInfo(fmt.Sprintf(" 会话已创建,sessionId: %s", sessionID)) + if !jsonMode { + deps.Out.PrintInfo(fmt.Sprintf(" 会话已创建,sessionId: %s", sessionID)) + } - deps.Out.PrintInfo("[2/4] 上传文件...") + if !jsonMode { + deps.Out.PrintInfo("[2/4] 上传文件...") + } if err := httpPutFile(ctx, uploadURL, nil, file.path, file.size); err != nil { return fmt.Errorf("文件上传失败 (sessionId=%s): %w", sessionID, err) } - deps.Out.PrintInfo(" 文件上传完成") + if !jsonMode { + deps.Out.PrintInfo(" 文件上传完成") + } - deps.Out.PrintInfo("[3/4] 确认导入,启动格式转换...") + if !jsonMode { + deps.Out.PrintInfo("[3/4] 确认导入,启动格式转换...") + } confirmText, err := cfg.callTool(ctx, "confirm_import", map[string]any{"sessionId": sessionID}) if err != nil { return fmt.Errorf("确认导入失败 (sessionId=%s): %w", sessionID, err) @@ -263,12 +288,18 @@ func runImportCommand(cmd *cobra.Command, args []string, cfg importFlowConfig) e } taskID, _ := confirmResult["taskId"].(string) if taskID == "" { - deps.Out.PrintRaw(confirmText) + if !jsonMode { + deps.Out.PrintRaw(confirmText) + } return fmt.Errorf("确认导入成功但未返回 taskId") } - deps.Out.PrintInfo(fmt.Sprintf(" 转换任务已提交,taskId: %s", taskID)) + if !jsonMode { + deps.Out.PrintInfo(fmt.Sprintf(" 转换任务已提交,taskId: %s", taskID)) + } - deps.Out.PrintInfo("[4/4] 等待格式转换完成...") + if !jsonMode { + deps.Out.PrintInfo("[4/4] 等待格式转换完成...") + } result, err := pollImportTask(ctx, taskID, cfg) if err != nil { var timeoutErr *importPollTimeoutError @@ -276,15 +307,16 @@ func runImportCommand(cmd *cobra.Command, args []string, cfg importFlowConfig) e return err } if cfg.timeoutAsResult { - deps.Out.PrintInfo(timeoutErr.Error()) - _ = deps.Out.PrintJSON(map[string]any{ + if !jsonMode { + deps.Out.PrintInfo(timeoutErr.Error()) + } + return deps.Out.PrintJSON(map[string]any{ "success": false, "timed_out": true, "taskId": taskID, "status": "processing", "next_command": fmt.Sprintf(cfg.nextCommand, taskID), }) - return nil } return fmt.Errorf("%s,请稍后使用 %s 手动查询", timeoutErr.Error(), fmt.Sprintf(cfg.nextCommand, taskID)) } @@ -302,9 +334,10 @@ func runImportCommand(cmd *cobra.Command, args []string, cfg importFlowConfig) e if cfg.includeNodeID { finalResult["nodeId"] = extractNodeIDFromDocURL(documentURL) } - deps.Out.PrintInfo(fmt.Sprintf("导入完成: %s", documentURL)) - _ = deps.Out.PrintJSON(finalResult) - return nil + if !jsonMode { + deps.Out.PrintInfo(fmt.Sprintf("导入完成: %s", documentURL)) + } + return deps.Out.PrintJSON(finalResult) } func runImportGetCommand(cmd *cobra.Command, cfg importFlowConfig) error { @@ -313,6 +346,15 @@ func runImportGetCommand(cmd *cobra.Command, cfg importFlowConfig) error { return fmt.Errorf("flag --task-id is required") } if deps.Caller.DryRun() { + if deps.Caller.Format() == "json" { + return deps.Out.PrintJSON(map[string]any{ + "dry_run": true, + "executed": false, + "preview_kind": "plan", + "operation": cfg.queryOperation, + "taskId": taskID, + }) + } deps.Out.PrintKeyValue("操作", cfg.queryOperation) deps.Out.PrintKeyValue("任务ID", taskID) return nil @@ -339,15 +381,15 @@ func runImportGetCommand(cmd *cobra.Command, cfg importFlowConfig) error { documentURL, _ := result["documentUrl"].(string) result["nodeId"] = extractNodeIDFromDocURL(documentURL) } - _ = deps.Out.PrintJSON(result) - return nil + return deps.Out.PrintJSON(result) } if strings.EqualFold(status, "processing") { - _ = deps.Out.PrintJSON(result) - return nil + return deps.Out.PrintJSON(result) } - _ = deps.Out.PrintJSON(result) + if err := deps.Out.PrintJSON(result); err != nil { + return err + } if message != "" { return fmt.Errorf("导入任务失败 (status=%s): %s", status, message) } @@ -361,7 +403,9 @@ func pollImportTask(ctx context.Context, taskID string, cfg importFlowConfig) (m } for attempt := 1; attempt <= poll.maxPolls; attempt++ { interval := poll.interval(attempt) - deps.Out.PrintInfo(fmt.Sprintf(" 第 %d/%d 次查询,等待 %v ...", attempt, poll.maxPolls, interval)) + if deps.Caller.Format() != "json" { + deps.Out.PrintInfo(fmt.Sprintf(" 第 %d/%d 次查询,等待 %v ...", attempt, poll.maxPolls, interval)) + } if err := poll.wait(ctx, interval); err != nil { return nil, fmt.Errorf("导入轮询被取消 (taskId=%s): %w", taskID, err) } diff --git a/internal/helpers/sheet_import_test.go b/internal/helpers/sheet_import_test.go index fab80a83..ac46b921 100644 --- a/internal/helpers/sheet_import_test.go +++ b/internal/helpers/sheet_import_test.go @@ -34,6 +34,12 @@ type sheetImportCall struct { args map[string]any } +type forcedJSONWriteFailure struct{} + +func (forcedJSONWriteFailure) Write([]byte) (int, error) { + return 0, fmt.Errorf("forced JSON output failure") +} + type sheetImportCaller struct { calls []sheetImportCall responses map[string][]string @@ -130,7 +136,7 @@ func TestSheetImportRejectsInvalidInputBeforeRemoteCall(t *testing.T) { } } -func TestSheetImportRunsSharedDocImportFlow(t *testing.T) { +func TestCrossPlatformCoverageSheetImportRunsSharedDocImportFlow(t *testing.T) { caller := &sheetImportCaller{responses: map[string][]string{ "create_import_session": {`{"sessionId":"session-1","uploadUrl":"https://upload.example.test/object"}`}, "confirm_import": {`{"taskId":"task-1"}`}, @@ -171,12 +177,16 @@ func TestSheetImportRunsSharedDocImportFlow(t *testing.T) { t.Fatalf("session args = %#v, want %#v", caller.calls[0].args, wantSession) } - if !strings.Contains(output, `"nodeId": "node-1"`) || !strings.Contains(output, `"success": true`) { - t.Fatalf("output missing success contract:\n%s", output) + var payload map[string]any + if err := json.Unmarshal([]byte(output), &payload); err != nil { + t.Fatalf("sheet import stdout must be one JSON document: %v\n%s", err, output) + } + if payload["nodeId"] != "node-1" || payload["success"] != true { + t.Fatalf("output missing success contract: %#v", payload) } } -func TestSheetImportCreateLeafMatchesLegacyDryRun(t *testing.T) { +func TestCrossPlatformCoverageSheetImportCreateLeafMatchesLegacyDryRun(t *testing.T) { filePath := writeImportFixture(t, "xlsx") legacy, err := executeSheetImportCommand(t, &sheetImportCaller{dryRun: true}, fastSheetImportConfig(), "--file", filePath, "--workspace", "workspace-1", "--name", "Sales") @@ -191,6 +201,13 @@ func TestSheetImportCreateLeafMatchesLegacyDryRun(t *testing.T) { if leaf != legacy { t.Fatalf("create leaf output differs from legacy entry:\nlegacy:\n%s\nleaf:\n%s", legacy, leaf) } + var payload map[string]any + if err := json.Unmarshal([]byte(legacy), &payload); err != nil { + t.Fatalf("sheet import dry-run stdout must be one JSON document: %v\n%s", err, legacy) + } + if payload["dry_run"] != true || payload["executed"] != false || payload["preview_kind"] != "plan" || payload["operation"] != "导入本地表格文件为在线电子表格" { + t.Fatalf("unexpected dry-run payload: %#v", payload) + } root := newSheetImportCmdWithConfig(fastSheetImportConfig()) create, _, err := root.Find([]string{"create"}) @@ -202,7 +219,7 @@ func TestSheetImportCreateLeafMatchesLegacyDryRun(t *testing.T) { } } -func TestSheetImportTimeoutIsStructuredSuccessExit(t *testing.T) { +func TestCrossPlatformCoverageSheetImportTimeoutIsStructuredSuccessExit(t *testing.T) { cfg := fastSheetImportConfig() cfg.poll.maxPolls = 2 caller := &sheetImportCaller{responses: map[string][]string{ @@ -220,15 +237,12 @@ func TestSheetImportTimeoutIsStructuredSuccessExit(t *testing.T) { if err != nil { t.Fatalf("timeout should exit successfully, got %v", err) } - for _, fragment := range []string{ - `"success": false`, - `"timed_out": true`, - `"status": "processing"`, - `"next_command": "dws sheet import get --task-id task-1"`, - } { - if !strings.Contains(output, fragment) { - t.Fatalf("output missing %s:\n%s", fragment, output) - } + var payload map[string]any + if err := json.Unmarshal([]byte(output), &payload); err != nil { + t.Fatalf("timeout stdout must be one JSON document: %v\n%s", err, output) + } + if payload["success"] != false || payload["timed_out"] != true || payload["status"] != "processing" || payload["next_command"] != "dws sheet import get --task-id task-1" { + t.Fatalf("unexpected timeout payload: %#v", payload) } } @@ -248,8 +262,7 @@ func TestSheetImportGetAddsNodeIDAndUsesDocServer(t *testing.T) { } var result map[string]any - jsonStart := strings.Index(output, "{") - if jsonStart < 0 || json.Unmarshal([]byte(output[jsonStart:]), &result) != nil { + if json.Unmarshal([]byte(output), &result) != nil { t.Fatalf("invalid JSON output:\n%s", output) } if result["nodeId"] != "node-2" { @@ -257,6 +270,58 @@ func TestSheetImportGetAddsNodeIDAndUsesDocServer(t *testing.T) { } } +func TestSheetImportGetPropagatesJSONWriteFailure(t *testing.T) { + tests := []struct { + name string + response string + }{ + {name: "completed", response: `{"status":"completed","documentUrl":"https://alidocs.dingtalk.com/i/nodes/node-2/"}`}, + {name: "processing", response: `{"status":"processing"}`}, + {name: "failed", response: `{"status":"failed","message":"conversion failed"}`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + previousDeps := deps + t.Cleanup(func() { deps = previousDeps }) + + caller := &sheetImportCaller{responses: map[string][]string{ + "query_import_task": {tt.response}, + }} + InitDeps(caller) + deps.Out.w = forcedJSONWriteFailure{} + + root := newSheetImportCmdWithConfig(fastSheetImportConfig()) + root.SilenceErrors = true + root.SilenceUsage = true + root.SetArgs([]string{"get", "--task-id", "task-write-failure"}) + err := root.Execute() + if err == nil || !strings.Contains(err.Error(), "forced JSON output failure") { + t.Fatalf("error = %v, want JSON write failure", err) + } + }) + } +} + +func TestCrossPlatformCoverageSheetImportGetDryRunJSONIsSingleDocument(t *testing.T) { + caller := &sheetImportCaller{dryRun: true} + output, err := executeSheetImportCommand(t, caller, fastSheetImportConfig(), "get", "--task-id", "task-dry-run") + if err != nil { + t.Fatalf("sheet import get dry-run returned error: %v", err) + } + if len(caller.calls) != 0 { + t.Fatalf("dry-run remote calls = %#v, want none", caller.calls) + } + + var payload map[string]any + if err := json.Unmarshal([]byte(output), &payload); err != nil { + t.Fatalf("sheet import get dry-run stdout must be one JSON document: %v\n%s", err, output) + } + if payload["dry_run"] != true || payload["executed"] != false || payload["preview_kind"] != "plan" || payload["taskId"] != "task-dry-run" || payload["operation"] != "查询表格导入任务结果" { + t.Fatalf("unexpected sheet import get dry-run payload: %#v", payload) + } +} + func TestDocImportConfigPreservesExistingContract(t *testing.T) { cfg := docImportFlowConfig() for _, ext := range []string{"docx", "doc", "xlsx", "xls", "md", "txt", "xmind", "mark"} { @@ -269,7 +334,7 @@ func TestDocImportConfigPreservesExistingContract(t *testing.T) { } } -func TestDocImportDryRunStillAcceptsMarkdownWithoutTarget(t *testing.T) { +func TestCrossPlatformCoverageDocImportDryRunStillAcceptsMarkdownWithoutTarget(t *testing.T) { previousDeps := deps previousArgs := os.Args t.Cleanup(func() { @@ -293,8 +358,12 @@ func TestDocImportDryRunStillAcceptsMarkdownWithoutTarget(t *testing.T) { if len(caller.calls) != 0 { t.Fatalf("dry-run made %d remote calls", len(caller.calls)) } - if !strings.Contains(output.String(), "导入本地文件为在线文档") || !strings.Contains(output.String(), "md") { - t.Fatalf("unexpected dry-run output:\n%s", output.String()) + var payload map[string]any + if err := json.Unmarshal(output.Bytes(), &payload); err != nil { + t.Fatalf("doc import dry-run stdout must be one JSON document: %v\n%s", err, output.String()) + } + if payload["operation"] != "导入本地文件为在线文档" || payload["format"] != "md" || payload["dry_run"] != true || payload["preview_kind"] != "plan" { + t.Fatalf("unexpected dry-run output: %#v", payload) } } diff --git a/internal/helpers/sheet_style.go b/internal/helpers/sheet_style.go index 1740ca4a..ef021223 100644 --- a/internal/helpers/sheet_style.go +++ b/internal/helpers/sheet_style.go @@ -462,11 +462,11 @@ func newRangeBatchSetStyleCmd() *cobra.Command { } fmt.Fprintf(os.Stderr, "[%d/%d] update_range sheet=%s range=%s\n", i+1, total, item.SheetID, item.Range) if deps.Caller.DryRun() { - // The batch command normally uses callMCPToolReturnText in - // JSON mode, which intentionally performs a real call. Keep - // the dry-run guard before both output branches so neither - // format can bypass the shared no-network preview path. - _ = callMCPTool("update_range", toolArgs) + // JSON mode emits one aggregate preview below. Human formats + // reuse the shared per-call preview printer. + if !jsonMode { + _ = callMCPTool("update_range", toolArgs) + } if jsonMode { jsonResults = append(jsonResults, map[string]any{ "index": i + 1, @@ -522,12 +522,14 @@ func newRangeBatchSetStyleCmd() *cobra.Command { } fmt.Fprintf(os.Stderr, "batch-set-style 完成:共 %d 条,失败 %d 条\n", total, failed) if jsonMode { - _ = deps.Out.PrintJSON(map[string]any{ + if err := deps.Out.PrintJSON(map[string]any{ "total": total, "failed": failed, "results": jsonResults, "success": failed == 0, - }) + }); err != nil { + return err + } } if failed > 0 && !continueOnErr { return firstErr diff --git a/internal/helpers/sheet_style_test.go b/internal/helpers/sheet_style_test.go index e89c293f..d044e020 100644 --- a/internal/helpers/sheet_style_test.go +++ b/internal/helpers/sheet_style_test.go @@ -7,6 +7,7 @@ package helpers import ( "bytes" "context" + "encoding/json" "os" "path/filepath" "strings" @@ -30,7 +31,7 @@ func (*sheetStyleDryRunCaller) DryRun() bool { return true } func (*sheetStyleDryRunCaller) Fields() string { return "" } func (*sheetStyleDryRunCaller) JQ() string { return "" } -func TestRangeBatchSetStyleDryRunNeverCallsRemote(t *testing.T) { +func TestCrossPlatformCoverageRangeBatchSetStyleDryRunNeverCallsRemote(t *testing.T) { previousDeps := deps t.Cleanup(func() { deps = previousDeps }) @@ -56,14 +57,50 @@ func TestRangeBatchSetStyleDryRunNeverCallsRemote(t *testing.T) { t.Fatalf("remote CallTool count = %d, want 0", caller.calls) } preview := output.String() - for _, want := range []string{"Tool:", "update_range", "Arguments:"} { - if !strings.Contains(preview, want) { - t.Fatalf("dry-run preview missing %q:\n%s", want, preview) + if format == "json" { + var payload map[string]any + if err := json.Unmarshal(output.Bytes(), &payload); err != nil { + t.Fatalf("JSON dry-run stdout must be one document: %v\n%s", err, preview) + } + results, _ := payload["results"].([]any) + if len(results) != 1 { + t.Fatalf("JSON dry-run results = %#v", payload["results"]) + } + entry, _ := results[0].(map[string]any) + if entry["dryRun"] != true || entry["tool"] != "update_range" { + t.Fatalf("JSON dry-run result = %#v", entry) + } + } else { + for _, want := range []string{"Tool:", "update_range", "Arguments:"} { + if !strings.Contains(preview, want) { + t.Fatalf("dry-run preview missing %q:\n%s", want, preview) + } } - } - if format == "json" && !strings.Contains(preview, `"dryRun": true`) { - t.Fatalf("JSON dry-run summary missing typed dryRun evidence:\n%s", preview) } }) } } + +func TestRangeBatchSetStylePropagatesJSONWriteFailure(t *testing.T) { + previousDeps := deps + t.Cleanup(func() { deps = previousDeps }) + + batchPath := filepath.Join(t.TempDir(), "styles.json") + if err := os.WriteFile(batchPath, []byte(`[{"sheetId":"Sheet1","range":"A1:B2","fontWeight":"bold"}]`), 0o600); err != nil { + t.Fatalf("write batch fixture: %v", err) + } + + caller := &sheetStyleDryRunCaller{format: "json"} + InitDeps(caller) + deps.Out.w = forcedJSONWriteFailure{} + + cmd := newRangeBatchSetStyleCmd() + cmd.SetArgs([]string{"--node", "NODE_ID", "--batch", batchPath}) + err := cmd.Execute() + if err == nil || !strings.Contains(err.Error(), "forced JSON output failure") { + t.Fatalf("error = %v, want JSON write failure", err) + } + if caller.calls != 0 { + t.Fatalf("remote CallTool count = %d, want 0", caller.calls) + } +} diff --git a/internal/shortcut/chat/chat_message.go b/internal/shortcut/chat/chat_message.go index bfafea8b..429cd044 100644 --- a/internal/shortcut/chat/chat_message.go +++ b/internal/shortcut/chat/chat_message.go @@ -221,10 +221,9 @@ var MessagesList = shortcut.Shortcut{ Execute: func(rt *shortcut.RuntimeContext) error { group := rt.StrFirst("group", "conversation-id", "id") params := map[string]any{ - "openCid": group, - "cid": group, - "time": rt.Str("time"), - "forward": rt.Bool("forward"), + "openconversation_id": group, + "time": rt.Str("time"), + "forward": rt.Bool("forward"), } if limit := rt.IntFirst("limit", "size"); limit > 0 { params["limit"] = limit diff --git a/internal/shortcut/chat/compatibility_coverage_test.go b/internal/shortcut/chat/compatibility_coverage_test.go index b468719a..19d14050 100644 --- a/internal/shortcut/chat/compatibility_coverage_test.go +++ b/internal/shortcut/chat/compatibility_coverage_test.go @@ -58,6 +58,7 @@ func TestCrossPlatformCoverageCompatibilityAliases(t *testing.T) { wantProduct string wantTool string wantArgs map[string]any + wantAbsent []string }{ { name: "chat search keyword and size", @@ -81,7 +82,8 @@ func TestCrossPlatformCoverageCompatibilityAliases(t *testing.T) { }, wantProduct: "chat", wantTool: "list_conversation_message_v2", - wantArgs: map[string]any{"openCid": "cid-1", "limit": 7}, + wantArgs: map[string]any{"openconversation_id": "cid-1", "limit": 7}, + wantAbsent: []string{"openCid", "cid"}, }, { name: "direct messages size", @@ -119,6 +121,11 @@ func TestCrossPlatformCoverageCompatibilityAliases(t *testing.T) { t.Errorf("%s = %#v, want %#v", key, got, want) } } + for _, key := range tc.wantAbsent { + if _, ok := fake.args[key]; ok { + t.Errorf("unexpected legacy argument %q in %#v", key, fake.args) + } + } }) } } diff --git a/internal/shortcut/smart/chat_messages.go b/internal/shortcut/smart/chat_messages.go index 33fbccf7..e183a063 100644 --- a/internal/shortcut/smart/chat_messages.go +++ b/internal/shortcut/smart/chat_messages.go @@ -25,7 +25,7 @@ import ( // // Steps: // 1. depending on whether --group or --user is given, call either -// list_conversation_message_v2 (group; param openCid) or +// list_conversation_message_v2 (group; param openconversation_id) or // list_individual_chat_message (single chat; param userId) on the chat // server — tool names and param keys copied verbatim from chat.go's // `dws chat message list` call sites; @@ -70,7 +70,7 @@ var ChatMessages = shortcut.Shortcut{ }, Execute: func(rt *shortcut.RuntimeContext) error { // Step 1 — build params and pick the right tool. Param keys - // (openCid/cid / userId / time / forward / limit) match the MCP server + // (openconversation_id / userId / time / forward / limit) match the MCP server // schema for group and direct message listing. var tool string params := map[string]any{} @@ -95,8 +95,7 @@ var ChatMessages = shortcut.Shortcut{ if group := rt.StrFirst("group", "conversation-id", "id"); group != "" { tool = "list_conversation_message_v2" - params["openCid"] = group - params["cid"] = group + params["openconversation_id"] = group } else { tool = "list_individual_chat_message" params["userId"] = rt.Str("user") diff --git a/internal/shortcut/smart/compatibility_coverage_test.go b/internal/shortcut/smart/compatibility_coverage_test.go index 7997bd96..f2106084 100644 --- a/internal/shortcut/smart/compatibility_coverage_test.go +++ b/internal/shortcut/smart/compatibility_coverage_test.go @@ -112,16 +112,18 @@ func TestCrossPlatformCoverageAIMessageTag(t *testing.T) { func TestCrossPlatformCoverageCompatibilityAliases(t *testing.T) { tests := []struct { - name string - argv []string - wantTool string - wantArgs map[string]any + name string + argv []string + wantTool string + wantArgs map[string]any + wantAbsent []string }{ { - name: "chat messages id and size", - argv: []string{"chat", "+chat-messages", "--id", "cid-1", "--size", "9", "--yes"}, - wantTool: "list_conversation_message_v2", - wantArgs: map[string]any{"openCid": "cid-1", "limit": 9}, + name: "chat messages id and size", + argv: []string{"chat", "+chat-messages", "--id", "cid-1", "--size", "9", "--yes"}, + wantTool: "list_conversation_message_v2", + wantArgs: map[string]any{"openconversation_id": "cid-1", "limit": 9}, + wantAbsent: []string{"openCid", "cid"}, }, { name: "search message id and keyword", @@ -149,6 +151,11 @@ func TestCrossPlatformCoverageCompatibilityAliases(t *testing.T) { t.Errorf("%s = %#v, want %#v", key, got, want) } } + for _, key := range tc.wantAbsent { + if _, ok := call.args[key]; ok { + t.Errorf("unexpected legacy argument %q in %#v", key, call.args) + } + } }) } }