Merge remote-tracking branch 'origin/main' into codex/pr664-current
This commit is contained in:
@@ -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{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user