Merge remote-tracking branch 'origin/main' into codex/pr664-current

This commit is contained in:
修雨
2026-07-17 15:52:39 +08:00
15 changed files with 383 additions and 80 deletions
+24 -3
View File
@@ -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))
@@ -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)
}
}
@@ -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{
+3 -4
View File
@@ -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
+2 -2
View File
@@ -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)
}
+8
View File
@@ -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)
+26
View File
@@ -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)
+65 -21
View File
@@ -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)
}
+88 -19
View File
@@ -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)
}
}
+9 -7
View File
@@ -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
+44 -7
View File
@@ -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)
}
}
+3 -4
View File
@@ -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
@@ -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)
}
}
})
}
}
+3 -4
View File
@@ -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")
@@ -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)
}
}
})
}
}