Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
446f27d96e | ||
|
|
ef81a1a59c | ||
|
|
d8ed8c2147 | ||
|
|
f3ae03373e |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Fixed
|
||||
---
|
||||
|
||||
- **Document write verification** (#960) — avoids false partial-success results when normalized Markdown, paginated blocks, inline images, or version reverts are confirmed by server readback.
|
||||
@@ -16,6 +16,7 @@ require (
|
||||
github.com/muesli/termenv v0.16.0
|
||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.2-beta.1
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/yuin/goldmark v1.8.5
|
||||
github.com/zalando/go-keyring v0.2.8
|
||||
golang.org/x/crypto v0.49.0
|
||||
golang.org/x/sys v0.42.0
|
||||
|
||||
@@ -105,6 +105,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA=
|
||||
github.com/yuin/goldmark v1.8.5/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
|
||||
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
|
||||
+214
-6
@@ -492,10 +492,11 @@ func defaultHTTPGetFile(ctx context.Context, url string, headers map[string]stri
|
||||
return nil
|
||||
}
|
||||
|
||||
// runMediaInsert implements the three-step flow for inserting an attachment into a document:
|
||||
// runMediaInsert implements the four-step flow for inserting an attachment into a document:
|
||||
// 1. get_doc_attachment_upload_info → obtain uploadUrl + resourceId
|
||||
// 2. HTTP PUT file content to OSS
|
||||
// 3. insert_document_block with attachment element
|
||||
// 4. list_document_blocks → prove the uploaded resource is visible in the document
|
||||
func runMediaInsert(cmd *cobra.Command, _ []string) error {
|
||||
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||||
if err != nil {
|
||||
@@ -551,7 +552,7 @@ func runMediaInsert(cmd *cobra.Command, _ []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
// Step 1: get upload credentials (uploadUrl + resourceId)
|
||||
deps.Out.PrintInfo(fmt.Sprintf("[1/3] 获取附件上传凭证 (%s, %d bytes)...", fileName, fileSize))
|
||||
deps.Out.PrintInfo(fmt.Sprintf("[1/4] 获取附件上传凭证 (%s, %d bytes)...", fileName, fileSize))
|
||||
|
||||
credText, err := callMCPToolReturnText(ctx, "get_doc_attachment_upload_info", map[string]any{
|
||||
"nodeId": nodeID,
|
||||
@@ -569,7 +570,7 @@ func runMediaInsert(cmd *cobra.Command, _ []string) error {
|
||||
}
|
||||
|
||||
// Step 2: HTTP PUT file to OSS
|
||||
deps.Out.PrintInfo("[2/3] 上传文件到 OSS...")
|
||||
deps.Out.PrintInfo("[2/4] 上传文件到 OSS...")
|
||||
|
||||
ossHeaders := map[string]string{
|
||||
"Content-Type": mimeType,
|
||||
@@ -592,7 +593,7 @@ func runMediaInsert(cmd *cobra.Command, _ []string) error {
|
||||
}
|
||||
|
||||
// Step 3: insert block into document
|
||||
deps.Out.PrintInfo("[3/3] 插入块到文档...")
|
||||
deps.Out.PrintInfo("[3/4] 插入块到文档...")
|
||||
|
||||
const maxInlineImageSize = 20 * 1024 * 1024 // 20MB
|
||||
|
||||
@@ -644,7 +645,8 @@ func runMediaInsert(cmd *cobra.Command, _ []string) error {
|
||||
insertArgs["referenceBlockId"] = v
|
||||
}
|
||||
|
||||
if err := callMCPTool("insert_document_block", insertArgs); err != nil {
|
||||
insertText, err := callMCPToolReturnText(ctx, "insert_document_block", insertArgs)
|
||||
if err != nil {
|
||||
return apperrors.NewAPI(
|
||||
"附件已上传,但正文 block 插入结果未知;请先检查媒体列表,不要重复上传或插入",
|
||||
apperrors.WithOperation("doc.media_insert"),
|
||||
@@ -665,6 +667,23 @@ func runMediaInsert(cmd *cobra.Command, _ []string) error {
|
||||
apperrors.WithCause(err),
|
||||
)
|
||||
}
|
||||
insertResult := map[string]any{}
|
||||
if strings.TrimSpace(insertText) != "" {
|
||||
if err := json.Unmarshal([]byte(insertText), &insertResult); err != nil {
|
||||
return docMediaInsertVerificationError(nodeID, resourceID, resourceURL, fileName,
|
||||
fmt.Errorf("解析 insert_document_block 响应失败: %w", err))
|
||||
}
|
||||
}
|
||||
insertedBlockID := insertedDocBlockID(insertResult)
|
||||
|
||||
deps.Out.PrintInfo("[4/4] 回读验证媒体块...")
|
||||
verifiedBlockID, verifyErr := verifyInsertedDocMedia(ctx, nodeID, insertedBlockID, resourceID, resourceURL)
|
||||
if verifyErr != nil {
|
||||
return docMediaInsertVerificationError(nodeID, resourceID, resourceURL, fileName, verifyErr)
|
||||
}
|
||||
if insertedBlockID == "" {
|
||||
insertedBlockID = verifiedBlockID
|
||||
}
|
||||
|
||||
return deps.Out.PrintJSON(map[string]any{
|
||||
"contractVersion": "doc.operation.v1",
|
||||
@@ -674,16 +693,205 @@ func runMediaInsert(cmd *cobra.Command, _ []string) error {
|
||||
"operation": "doc.media_insert",
|
||||
"data": map[string]any{
|
||||
"nodeId": nodeID, "resourceId": resourceID, "resourceUrl": resourceURL,
|
||||
"fileName": fileName, "mimeType": mimeType, "sizeBytes": fileSize, "inserted": true,
|
||||
"blockId": insertedBlockID, "fileName": fileName, "mimeType": mimeType, "sizeBytes": fileSize,
|
||||
"inserted": true, "verified": true,
|
||||
},
|
||||
"steps": []map[string]any{
|
||||
{"name": "resolve_upload", "status": "success"},
|
||||
{"name": "upload_oss", "status": "success"},
|
||||
{"name": "insert_block", "status": "success"},
|
||||
{"name": "verify", "status": "success"},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func docMediaInsertVerificationError(nodeID, resourceID, resourceURL, fileName string, cause error) error {
|
||||
return apperrors.NewAPI(
|
||||
"附件已上传且插块请求已执行,但回读未能证明媒体块落库;不要直接重试上传或插入",
|
||||
apperrors.WithOperation("doc.media_insert"),
|
||||
apperrors.WithReason("doc_media_insert_verification_failed"),
|
||||
apperrors.WithFailureStage("verify"),
|
||||
apperrors.WithExecutionStarted(true),
|
||||
apperrors.WithRetryable(false),
|
||||
apperrors.WithActions("运行 dws doc +media-list 检查 resourceId", "确认媒体不存在后再决定是否重新执行"),
|
||||
apperrors.WithDetails(map[string]any{
|
||||
"contractVersion": "doc.operation.v1", "status": "partial_success", "nodeId": nodeID,
|
||||
"resourceId": resourceID, "resourceUrl": resourceURL, "fileName": fileName, "verified": false,
|
||||
"steps": []map[string]any{
|
||||
{"name": "resolve_upload", "status": "success"},
|
||||
{"name": "upload_oss", "status": "success"},
|
||||
{"name": "insert_block", "status": "success"},
|
||||
{"name": "verify", "status": "failed"},
|
||||
},
|
||||
}),
|
||||
apperrors.WithCause(cause),
|
||||
)
|
||||
}
|
||||
|
||||
func verifyInsertedDocMedia(ctx context.Context, nodeID, blockID, resourceID, resourceURL string) (string, error) {
|
||||
delays := []time.Duration{250 * time.Millisecond, 500 * time.Millisecond, time.Second, 2 * time.Second, 4 * time.Second, 8 * time.Second}
|
||||
var lastErr error
|
||||
for attempt := 0; attempt <= len(delays); attempt++ {
|
||||
blocks, err := readAllDocBlocksForVerification(ctx, nodeID)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
} else {
|
||||
lastErr = nil
|
||||
if found := findVerifiedMediaBlock(blocks, blockID, resourceID, resourceURL); found != "" {
|
||||
return found, nil
|
||||
}
|
||||
}
|
||||
if attempt < len(delays) {
|
||||
helperSleep(delays[attempt])
|
||||
}
|
||||
}
|
||||
if lastErr != nil {
|
||||
return "", fmt.Errorf("媒体资源在有界回读窗口内仍无法读取: %w", lastErr)
|
||||
}
|
||||
return "", fmt.Errorf("媒体资源在有界回读窗口内仍不可见")
|
||||
}
|
||||
|
||||
func readAllDocBlocksForVerification(ctx context.Context, nodeID string) ([]any, error) {
|
||||
const pageSize = 50
|
||||
const maxItems = 5000
|
||||
all := make([]any, 0, pageSize)
|
||||
seen := map[string]bool{}
|
||||
for start := 0; start < maxItems; start += pageSize {
|
||||
text, err := callMCPToolReturnTextOnServer(ctx, "doc", "list_document_blocks", map[string]any{
|
||||
"nodeId": nodeID, "format": "jsonml", "startIndex": start, "endIndex": start + pageSize - 1,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal([]byte(text), &payload); err != nil {
|
||||
return nil, fmt.Errorf("解析 list_document_blocks 回读失败: %w", err)
|
||||
}
|
||||
payload = nestedDocMap(payload)
|
||||
blocks, ok := payload["blocks"].([]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("list_document_blocks 回读缺少 blocks 数组")
|
||||
}
|
||||
encoded, _ := json.Marshal(blocks)
|
||||
key := string(encoded)
|
||||
if key != "[]" && seen[key] {
|
||||
return nil, fmt.Errorf("list_document_blocks 分页停滞")
|
||||
}
|
||||
seen[key] = true
|
||||
all = append(all, blocks...)
|
||||
hasMore, hasMoreKnown := payload["hasMore"].(bool)
|
||||
if hasMoreKnown && !hasMore {
|
||||
return all, nil
|
||||
}
|
||||
if total, ok := docNumberAsInt(payload["totalCount"]); ok && len(all) >= total {
|
||||
return all, nil
|
||||
}
|
||||
if !hasMoreKnown && len(blocks) < pageSize {
|
||||
return all, nil
|
||||
}
|
||||
if len(blocks) == 0 {
|
||||
return nil, fmt.Errorf("list_document_blocks 声明仍有下一页但当前页为空")
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("文档块超过安全回读上限")
|
||||
}
|
||||
|
||||
func nestedDocMap(data map[string]any) map[string]any {
|
||||
for _, key := range []string{"result", "data"} {
|
||||
if nested, ok := data[key].(map[string]any); ok {
|
||||
return nestedDocMap(nested)
|
||||
}
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func nestedDocString(value any, keys ...string) string {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
for _, key := range keys {
|
||||
if text, ok := typed[key].(string); ok && strings.TrimSpace(text) != "" {
|
||||
return strings.TrimSpace(text)
|
||||
}
|
||||
}
|
||||
for _, child := range typed {
|
||||
if text := nestedDocString(child, keys...); text != "" {
|
||||
return text
|
||||
}
|
||||
}
|
||||
case []any:
|
||||
for _, child := range typed {
|
||||
if text := nestedDocString(child, keys...); text != "" {
|
||||
return text
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// insertedDocBlockID only accepts explicit block IDs from the insert result or
|
||||
// known response wrappers. Arbitrary IDs may belong to the document, operator,
|
||||
// or request and must not become a hard constraint for the media readback.
|
||||
func insertedDocBlockID(data map[string]any) string {
|
||||
for _, key := range []string{"blockId", "elementId"} {
|
||||
if text, ok := data[key].(string); ok && strings.TrimSpace(text) != "" {
|
||||
return strings.TrimSpace(text)
|
||||
}
|
||||
}
|
||||
for _, wrapper := range []string{"result", "data", "content"} {
|
||||
if inner, ok := data[wrapper].(map[string]any); ok {
|
||||
if text := insertedDocBlockID(inner); text != "" {
|
||||
return text
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func findVerifiedMediaBlock(blocks []any, blockID, resourceID, resourceURL string) string {
|
||||
for _, value := range blocks {
|
||||
candidateID := nestedDocString(value, "blockId", "id", "uuid")
|
||||
if candidateID == "" || (blockID != "" && candidateID != blockID) {
|
||||
continue
|
||||
}
|
||||
mediaValue := docMediaReadbackValue(value)
|
||||
if resourceID != "" && nestedDocString(mediaValue, "resourceId") == resourceID {
|
||||
return candidateID
|
||||
}
|
||||
if resourceURL != "" && nestedDocString(mediaValue, "resourceUrl", "src") == resourceURL {
|
||||
return candidateID
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func docMediaReadbackValue(value any) any {
|
||||
block, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
return value
|
||||
}
|
||||
encoded, ok := block["jsonml"].(string)
|
||||
if !ok || strings.TrimSpace(encoded) == "" {
|
||||
return value
|
||||
}
|
||||
var decoded any
|
||||
if json.Unmarshal([]byte(encoded), &decoded) != nil {
|
||||
return value
|
||||
}
|
||||
return decoded
|
||||
}
|
||||
|
||||
func docNumberAsInt(value any) (int, bool) {
|
||||
switch typed := value.(type) {
|
||||
case float64:
|
||||
if typed >= 0 && typed == float64(int(typed)) {
|
||||
return int(typed), true
|
||||
}
|
||||
case int:
|
||||
return typed, typed >= 0
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// parseAttachmentUploadInfo extracts uploadUrl, resourceId and resourceUrl from the MCP tool response.
|
||||
func parseAttachmentUploadInfo(text string) (uploadURL, resourceID, resourceURL string, err error) {
|
||||
var data map[string]any
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -13,6 +14,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -74,6 +76,7 @@ func TestCrossPlatformCoverageDocUploadAndMediaErrorEdges(t *testing.T) {
|
||||
t.Cleanup(func() { os.Args = oldArgs })
|
||||
oldPut, oldGet := httpPutFile, httpGetFile
|
||||
t.Cleanup(func() { httpPutFile, httpGetFile = oldPut, oldGet })
|
||||
testseam.Swap(t, &helperSleep, func(time.Duration) {})
|
||||
file := filepath.Join(t.TempDir(), "file.txt")
|
||||
if err := os.WriteFile(file, []byte("content"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -193,6 +196,47 @@ func TestCrossPlatformCoverageDocUploadAndMediaErrorEdges(t *testing.T) {
|
||||
t.Fatal("media insert failure returned nil")
|
||||
}
|
||||
})
|
||||
t.Run("media insert response parse failure", func(t *testing.T) {
|
||||
httpPutFile = func(context.Context, string, map[string]string, string, int64) error { return nil }
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"uploadUrl":"https://upload","resourceId":"resource"}`},
|
||||
{text: `{`},
|
||||
}}
|
||||
if err := mediaCommand(t, caller, file, "text/plain"); err == nil {
|
||||
t.Fatal("invalid media insert response returned nil")
|
||||
}
|
||||
})
|
||||
t.Run("small image verifies src readback when upload also returns resource ID", func(t *testing.T) {
|
||||
httpPutFile = func(context.Context, string, map[string]string, string, int64) error { return nil }
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"uploadUrl":"https://upload","resourceId":"resource","resourceUrl":"https://image"}`},
|
||||
{text: `{"blockId":"media-block"}`},
|
||||
{text: `{"blocks":[{"blockId":"media-block","jsonml":"[\"p\",{\"uuid\":\"media-block\"},[\"img\",{\"src\":\"https://image\"}]]"}],"hasMore":false}`},
|
||||
}}
|
||||
if err := mediaCommand(t, caller, file, "image/png"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if caller.calls != 3 {
|
||||
t.Fatalf("media insert calls = %d, want credential, insert, and one readback", caller.calls)
|
||||
}
|
||||
if caller.args["format"] != "jsonml" {
|
||||
t.Fatalf("media readback format = %#v, want jsonml", caller.args["format"])
|
||||
}
|
||||
})
|
||||
t.Run("unrelated insert response IDs do not constrain media readback", func(t *testing.T) {
|
||||
httpPutFile = func(context.Context, string, map[string]string, string, int64) error { return nil }
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"uploadUrl":"https://upload","resourceId":"resource","resourceUrl":"https://image"}`},
|
||||
{text: `{"id":"document-id","operator":{"id":"operator-id"},"data":{"request":{"id":"request-id"}}}`},
|
||||
{text: `{"blocks":[{"blockId":"media-block","jsonml":"[\"p\",{\"uuid\":\"media-block\"},[\"img\",{\"src\":\"https://image\"}]]"}],"hasMore":false}`},
|
||||
}}
|
||||
if err := mediaCommand(t, caller, file, "image/png"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if caller.calls != 3 {
|
||||
t.Fatalf("media insert calls = %d, want credential, insert, and one readback", caller.calls)
|
||||
}
|
||||
})
|
||||
t.Run("large image becomes attachment", func(t *testing.T) {
|
||||
httpPutFile = func(context.Context, string, map[string]string, string, int64) error { return nil }
|
||||
large := filepath.Join(t.TempDir(), "large.png")
|
||||
@@ -202,7 +246,11 @@ func TestCrossPlatformCoverageDocUploadAndMediaErrorEdges(t *testing.T) {
|
||||
if err := os.Truncate(large, 21*1024*1024); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"uploadUrl":"https://upload","resourceId":"resource","resourceUrl":"https://image"}`}, {text: `{}`}}}
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"uploadUrl":"https://upload","resourceId":"resource","resourceUrl":"https://image"}`},
|
||||
{text: `{}`},
|
||||
{text: `{"blocks":[{"blockId":"media-block","element":{"attachment":{"resourceId":"resource"}}}],"hasMore":false}`},
|
||||
}}
|
||||
if err := mediaCommand(t, caller, large, "image/png"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -240,6 +288,115 @@ func TestCrossPlatformCoverageDefaultDocHTTPTransportEdges(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDocMediaReadbackDefensiveEdges(t *testing.T) {
|
||||
testseam.Swap(t, &helperSleep, func(time.Duration) {})
|
||||
ctx := context.Background()
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
steps []scriptedToolStep
|
||||
}{
|
||||
{"call failure", []scriptedToolStep{{err: errors.New("read")}}},
|
||||
{"invalid json", []scriptedToolStep{{text: `{`}}},
|
||||
{"missing blocks", []scriptedToolStep{{text: `{}`}}},
|
||||
{"stalled page", []scriptedToolStep{{text: `{"blocks":[{"id":"a"}],"hasMore":true}`}, {text: `{"blocks":[{"id":"a"}],"hasMore":true}`}}},
|
||||
{"empty continued page", []scriptedToolStep{{text: `{"blocks":[],"hasMore":true}`}}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: tc.steps})
|
||||
if _, err := readAllDocBlocksForVerification(ctx, "node"); err == nil {
|
||||
t.Fatal("defensive readback returned nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("total count and nested payload", func(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"data":{"blocks":[{"id":"a"}],"totalCount":1}}`}}})
|
||||
blocks, err := readAllDocBlocksForVerification(ctx, "node")
|
||||
if err != nil || len(blocks) != 1 {
|
||||
t.Fatalf("blocks=%#v err=%v", blocks, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown pagination short page", func(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"blocks":[{"id":"a"}]}`}}})
|
||||
if blocks, err := readAllDocBlocksForVerification(ctx, "node"); err != nil || len(blocks) != 1 {
|
||||
t.Fatalf("blocks=%#v err=%v", blocks, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("bounded retry failure", func(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"blocks":[],"hasMore":false}`}}})
|
||||
if _, err := verifyInsertedDocMedia(ctx, "node", "", "missing", ""); err == nil {
|
||||
t.Fatal("missing media unexpectedly verified")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("transient read failure retries", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{err: errors.New("temporary read failure")},
|
||||
{text: `{"blocks":[{"blockId":"media-block","element":{"attachment":{"resourceId":"resource"}}}],"hasMore":false}`},
|
||||
}}
|
||||
installScriptedCaller(t, caller)
|
||||
blockID, err := verifyInsertedDocMedia(ctx, "node", "media-block", "resource", "")
|
||||
if err != nil || blockID != "media-block" || caller.calls != 2 {
|
||||
t.Fatalf("blockID=%q calls=%d err=%v", blockID, caller.calls, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("block identity alone does not verify media", func(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"blocks":[{"blockId":"media-block","element":{"attachment":{"resourceId":"other-resource"}}}],"hasMore":false}`}}})
|
||||
if _, err := verifyInsertedDocMedia(ctx, "node", "media-block", "resource", ""); err == nil {
|
||||
t.Fatal("matching block ID with a different resource unexpectedly verified")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("block read safety limit", func(t *testing.T) {
|
||||
steps := make([]scriptedToolStep, 100)
|
||||
for index := range steps {
|
||||
steps[index] = scriptedToolStep{text: fmt.Sprintf(`{"blocks":[{"id":"block-%d"}],"hasMore":true}`, index)}
|
||||
}
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: steps})
|
||||
if _, err := readAllDocBlocksForVerification(ctx, "node"); err == nil {
|
||||
t.Fatal("oversized block read returned nil")
|
||||
}
|
||||
})
|
||||
|
||||
if got := nestedDocMap(map[string]any{"result": map[string]any{"data": map[string]any{"ok": true}}}); got["ok"] != true {
|
||||
t.Fatalf("nested map=%#v", got)
|
||||
}
|
||||
if nestedDocString(map[string]any{"x": []any{map[string]any{"id": " nested "}}}, "id") != "nested" || nestedDocString(3, "id") != "" {
|
||||
t.Fatal("nested string traversal failed")
|
||||
}
|
||||
if got := insertedDocBlockID(map[string]any{"id": "document", "result": map[string]any{"data": map[string]any{"blockId": " block "}}}); got != "block" {
|
||||
t.Fatalf("trusted inserted block ID = %q, want block", got)
|
||||
}
|
||||
if got := insertedDocBlockID(map[string]any{"id": "document", "operator": map[string]any{"id": "operator"}, "data": map[string]any{"request": map[string]any{"id": "request"}}}); got != "" {
|
||||
t.Fatalf("untrusted inserted block ID = %q, want empty", got)
|
||||
}
|
||||
blocks := []any{map[string]any{"id": "block", "resourceId": "rid"}, map[string]any{"id": "url-block", "resourceUrl": "https://media"}, map[string]any{"id": "src-block", "jsonml": `["p",{},["img",{"src":"https://image"}]]`}}
|
||||
if findVerifiedMediaBlock(blocks, "block", "rid", "") != "block" || findVerifiedMediaBlock(blocks, "", "rid", "") != "block" || findVerifiedMediaBlock(blocks, "", "", "https://media") != "url-block" || findVerifiedMediaBlock(blocks, "src-block", "upload-resource", "https://image") != "src-block" || findVerifiedMediaBlock(blocks, "other-block", "upload-resource", "https://image") != "" || findVerifiedMediaBlock(blocks, "block", "wrong", "") != "" || findVerifiedMediaBlock(blocks, "", "missing", "") != "" {
|
||||
t.Fatal("media block matching failed")
|
||||
}
|
||||
if findVerifiedMediaBlock([]any{map[string]any{"id": "bad-jsonml", "jsonml": `[`}}, "bad-jsonml", "", "https://image") != "" {
|
||||
t.Fatal("invalid JSONML unexpectedly verified")
|
||||
}
|
||||
if got := docMediaReadbackValue("plain"); got != "plain" {
|
||||
t.Fatalf("non-object media readback = %#v, want unchanged value", got)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
value any
|
||||
want bool
|
||||
}{
|
||||
{float64(3), true}, {float64(-1), false}, {1.5, false}, {3, true}, {-1, false}, {"3", false},
|
||||
} {
|
||||
_, ok := docNumberAsInt(tc.value)
|
||||
if ok != tc.want {
|
||||
t.Fatalf("docNumberAsInt(%#v) ok=%v want=%v", tc.value, ok, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDocCreateUpdateAndBlockCommandEdges(t *testing.T) {
|
||||
oldDeps, oldArgs, oldPut, oldGet := deps, os.Args, httpPutFile, httpGetFile
|
||||
t.Cleanup(func() {
|
||||
|
||||
@@ -4,8 +4,10 @@
|
||||
package doc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -18,6 +20,9 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/localio"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/docresolver"
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/extension"
|
||||
"github.com/yuin/goldmark/renderer/html"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -28,6 +33,21 @@ var (
|
||||
docMkdirTemp = os.MkdirTemp
|
||||
docRemoveAll = os.RemoveAll
|
||||
docDownload = localio.Download
|
||||
docVerifySleep = time.Sleep
|
||||
docVerifyDelays = []time.Duration{250 * time.Millisecond, 500 * time.Millisecond, time.Second, 2 * time.Second, 4 * time.Second, 8 * time.Second}
|
||||
docMarkdown = goldmark.New(
|
||||
goldmark.WithExtensions(extension.Table),
|
||||
goldmark.WithRendererOptions(html.WithUnsafe()),
|
||||
)
|
||||
docMarkdownConvert = func(source []byte, writer io.Writer) error {
|
||||
return docMarkdown.Convert(source, writer)
|
||||
}
|
||||
)
|
||||
|
||||
const (
|
||||
docBlockReadPageSize = 50
|
||||
docBlockReadMaxItems = 5000
|
||||
docMarkdownVerifyMax = 2 * 1024 * 1024
|
||||
)
|
||||
|
||||
var Create = shortcut.Shortcut{
|
||||
@@ -133,7 +153,9 @@ var Create = shortcut.Shortcut{
|
||||
verifyTool = "get_document_content"
|
||||
verifyParams["format"] = format
|
||||
}
|
||||
verification, err := rt.CallMCPData(productDoc, verifyTool, verifyParams)
|
||||
verification, err := readDocVerification(rt, verifyTool, verifyParams, func(data map[string]any) bool {
|
||||
return content == "" || verifyUpdatedDocumentContent(data, content, "overwrite", format)
|
||||
})
|
||||
if err != nil {
|
||||
return docVerificationError("doc.create", "verify", nodeID, err, append(steps, map[string]any{"name": "verify", "status": "failed"}))
|
||||
}
|
||||
@@ -422,7 +444,9 @@ var CheckpointUpdate = shortcut.Shortcut{
|
||||
append(steps, map[string]any{"name": "update", "status": "failed"}, map[string]any{"name": "verify", "status": "not_started"}))
|
||||
}
|
||||
steps = append(steps, map[string]any{"name": "update", "status": "success"})
|
||||
verification, err := rt.CallMCPData(productDoc, "get_document_content", map[string]any{"nodeId": rt.Str("node"), "format": "markdown"})
|
||||
verification, err := readDocVerification(rt, "get_document_content", map[string]any{"nodeId": rt.Str("node"), "format": "markdown"}, func(data map[string]any) bool {
|
||||
return verifyUpdatedDocumentContent(data, content, rt.Str("mode"), "markdown")
|
||||
})
|
||||
if err != nil {
|
||||
return checkpointPartialWriteError(rt.Str("node"), checkpoint, "verify", "doc_checkpoint_verification_failed", err,
|
||||
append(steps, map[string]any{"name": "verify", "status": "failed"}))
|
||||
@@ -539,7 +563,7 @@ func executeUpdate(rt *shortcut.RuntimeContext) error {
|
||||
}
|
||||
referenceBlockID := rt.Str("after-block-id")
|
||||
return executeVerifiedDocMutation(rt, "doc.update", "insert_document_block", params, node,
|
||||
"list_document_blocks", map[string]any{"nodeId": node, "format": verificationFormat},
|
||||
"list_document_blocks", map[string]any{"nodeId": node, "format": verificationFormat, "__allBlocks": true},
|
||||
func(result, data map[string]any) bool {
|
||||
return verifyInsertedBlock(result, data, referenceBlockID, content, rt.Str("doc-format"))
|
||||
})
|
||||
@@ -553,14 +577,14 @@ func executeUpdate(rt *shortcut.RuntimeContext) error {
|
||||
params["element"] = map[string]any{"blockType": "paragraph", "paragraph": map[string]any{"text": content}}
|
||||
}
|
||||
return executeVerifiedDocMutation(rt, "doc.update", "update_document_block", params, node,
|
||||
"list_document_blocks", map[string]any{"nodeId": node, "blockId": blockID, "format": verificationFormat},
|
||||
"list_document_blocks", map[string]any{"nodeId": node, "format": verificationFormat, "__allBlocks": true},
|
||||
func(_, data map[string]any) bool {
|
||||
return blockContentEquals(data, blockID, content, rt.Str("doc-format"))
|
||||
})
|
||||
case "block_delete":
|
||||
blockID := rt.Str("block-id")
|
||||
return executeVerifiedDocMutation(rt, "doc.update", "delete_document_block", map[string]any{"nodeId": node, "blockId": blockID}, node,
|
||||
"list_document_blocks", map[string]any{"nodeId": node, "format": "element"},
|
||||
"list_document_blocks", map[string]any{"nodeId": node, "format": "element", "__allBlocks": true},
|
||||
func(_, data map[string]any) bool { return findBlock(data, blockID) == nil })
|
||||
case "str_replace":
|
||||
return executePlainTextReplace(rt, node)
|
||||
@@ -609,7 +633,7 @@ func nestedRevision(value any) (int, bool) {
|
||||
}
|
||||
|
||||
func executePlainTextReplace(rt *shortcut.RuntimeContext, nodeID string) error {
|
||||
data, err := rt.CallMCPData(productDoc, "list_document_blocks", map[string]any{"nodeId": nodeID, "format": "element"})
|
||||
data, err := readAllDocumentBlocks(rt, map[string]any{"nodeId": nodeID, "format": "element"})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -643,12 +667,12 @@ func executePlainTextReplace(rt *shortcut.RuntimeContext, nodeID string) error {
|
||||
blockID := matches[0].blockID
|
||||
return executeVerifiedDocMutation(rt, "doc.update", "update_document_block",
|
||||
map[string]any{"nodeId": nodeID, "blockId": blockID, "element": map[string]any{"blockType": "paragraph", "paragraph": map[string]any{"text": updated}}}, nodeID,
|
||||
"list_document_blocks", map[string]any{"nodeId": nodeID, "blockId": blockID, "format": "element"},
|
||||
"list_document_blocks", map[string]any{"nodeId": nodeID, "format": "element", "__allBlocks": true},
|
||||
func(_, data map[string]any) bool { return blockContentEquals(data, blockID, updated, "markdown") })
|
||||
}
|
||||
|
||||
func executeBlockCopy(rt *shortcut.RuntimeContext, nodeID string) error {
|
||||
data, err := rt.CallMCPData(productDoc, "list_document_blocks", map[string]any{"nodeId": nodeID, "blockId": rt.Str("block-id"), "format": "element"})
|
||||
data, err := readAllDocumentBlocks(rt, map[string]any{"nodeId": nodeID, "format": "element"})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -664,7 +688,7 @@ func executeBlockCopy(rt *shortcut.RuntimeContext, nodeID string) error {
|
||||
referenceBlockID := rt.Str("after-block-id")
|
||||
return executeVerifiedDocMutation(rt, "doc.update", "insert_document_block",
|
||||
map[string]any{"nodeId": nodeID, "referenceBlockId": referenceBlockID, "where": "after", "element": block}, nodeID,
|
||||
"list_document_blocks", map[string]any{"nodeId": nodeID, "format": "element"},
|
||||
"list_document_blocks", map[string]any{"nodeId": nodeID, "format": "element", "__allBlocks": true},
|
||||
func(result, data map[string]any) bool {
|
||||
return verifyInsertedCanonicalBlock(result, data, referenceBlockID, expectedContent, "markdown")
|
||||
})
|
||||
@@ -684,7 +708,9 @@ func executeVerifiedDocMutation(
|
||||
return docUnknownWriteError(operation, tool, nodeID, err)
|
||||
}
|
||||
steps[0]["status"] = "success"
|
||||
verification, err := rt.CallMCPData(productDoc, verifyTool, verifyParams)
|
||||
verification, err := readDocVerification(rt, verifyTool, verifyParams, func(data map[string]any) bool {
|
||||
return verify == nil || verify(result, data)
|
||||
})
|
||||
if err != nil {
|
||||
return docVerificationError(operation, "verify", nodeID, err, append(steps, map[string]any{"name": "verify", "status": "failed"}))
|
||||
}
|
||||
@@ -732,7 +758,9 @@ func executeVerifiedDocContentMutation(rt *shortcut.RuntimeContext, firstParams
|
||||
}
|
||||
steps = append(steps, map[string]any{"name": stepName, "status": "success"})
|
||||
}
|
||||
verification, err := rt.CallMCPData(productDoc, "get_document_content", map[string]any{"nodeId": nodeID, "format": format})
|
||||
verification, err := readDocVerification(rt, "get_document_content", map[string]any{"nodeId": nodeID, "format": format}, func(data map[string]any) bool {
|
||||
return verifyUpdatedDocumentContent(data, content, mode, format)
|
||||
})
|
||||
if err != nil {
|
||||
return docVerificationError("doc.update", "verify", nodeID, err, append(steps, map[string]any{"name": "verify", "status": "failed"}))
|
||||
}
|
||||
@@ -745,6 +773,134 @@ func executeVerifiedDocContentMutation(rt *shortcut.RuntimeContext, firstParams
|
||||
}, steps...))
|
||||
}
|
||||
|
||||
func readDocVerification(rt *shortcut.RuntimeContext, tool string, rawParams map[string]any, verify func(map[string]any) bool) (map[string]any, error) {
|
||||
params := cloneMap(rawParams)
|
||||
allBlocks, _ := params["__allBlocks"].(bool)
|
||||
delete(params, "__allBlocks")
|
||||
var last map[string]any
|
||||
var lastErr error
|
||||
for attempt := 0; attempt <= len(docVerifyDelays); attempt++ {
|
||||
var data map[string]any
|
||||
var err error
|
||||
if allBlocks && tool == "list_document_blocks" {
|
||||
data, err = readAllDocumentBlocks(rt, params)
|
||||
} else {
|
||||
data, err = rt.CallMCPData(productDoc, tool, params)
|
||||
}
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
} else {
|
||||
last = data
|
||||
lastErr = nil
|
||||
if verify == nil || verify(data) {
|
||||
return data, nil
|
||||
}
|
||||
}
|
||||
if attempt < len(docVerifyDelays) {
|
||||
docVerifySleep(docVerifyDelays[attempt])
|
||||
}
|
||||
}
|
||||
if lastErr != nil {
|
||||
return nil, lastErr
|
||||
}
|
||||
return last, nil
|
||||
}
|
||||
|
||||
func readAllDocumentBlocks(rt *shortcut.RuntimeContext, base map[string]any) (map[string]any, error) {
|
||||
all := make([]any, 0, docBlockReadPageSize)
|
||||
seenPages := map[string]bool{}
|
||||
for start := 0; start < docBlockReadMaxItems; start += docBlockReadPageSize {
|
||||
params := cloneMap(base)
|
||||
params["startIndex"] = start
|
||||
params["endIndex"] = start + docBlockReadPageSize - 1
|
||||
page, err := rt.CallMCPData(productDoc, "list_document_blocks", params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
blocks, ok := documentBlockEntries(page)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("list_document_blocks 回读缺少 blocks 数组")
|
||||
}
|
||||
encoded, _ := json.Marshal(blocks)
|
||||
pageKey := string(encoded)
|
||||
if pageKey != "[]" && seenPages[pageKey] {
|
||||
return nil, fmt.Errorf("list_document_blocks 分页停滞,无法证明回读完整")
|
||||
}
|
||||
seenPages[pageKey] = true
|
||||
all = append(all, blocks...)
|
||||
hasMore, known, _ := docPageState(page)
|
||||
if known && !hasMore {
|
||||
return map[string]any{"blocks": all, "hasMore": false, "totalCount": len(all)}, nil
|
||||
}
|
||||
if total, ok := nestedNonNegativeInt(page, "totalCount", "total_count"); ok && len(all) >= total {
|
||||
return map[string]any{"blocks": all, "hasMore": false, "totalCount": total}, nil
|
||||
}
|
||||
if !known && len(blocks) < docBlockReadPageSize {
|
||||
return map[string]any{"blocks": all, "hasMore": false, "totalCount": len(all)}, nil
|
||||
}
|
||||
if len(blocks) == 0 {
|
||||
return nil, fmt.Errorf("list_document_blocks 声明仍有下一页但当前页为空,无法证明回读完整")
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("list_document_blocks 超过 %d 个块,无法在安全上限内完成回读", docBlockReadMaxItems)
|
||||
}
|
||||
|
||||
func documentBlockEntries(value any) ([]any, bool) {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
for _, key := range []string{"blocks", "items"} {
|
||||
if blocks, ok := typed[key].([]any); ok {
|
||||
return blocks, true
|
||||
}
|
||||
}
|
||||
if encoded, ok := typed["jsonml"].(string); ok {
|
||||
var decoded any
|
||||
if json.Unmarshal([]byte(encoded), &decoded) == nil {
|
||||
blocks := orderedJSONMLBlocks(decoded)
|
||||
values := make([]any, len(blocks))
|
||||
for index := range blocks {
|
||||
values[index] = blocks[index]
|
||||
}
|
||||
return values, true
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"result", "data"} {
|
||||
if nested, ok := typed[key]; ok {
|
||||
if blocks, found := documentBlockEntries(nested); found {
|
||||
return blocks, true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func nestedNonNegativeInt(value any, keys ...string) (int, bool) {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
for _, key := range keys {
|
||||
if raw, ok := typed[key]; ok {
|
||||
switch number := raw.(type) {
|
||||
case float64:
|
||||
if number >= 0 && number == float64(int(number)) {
|
||||
return int(number), true
|
||||
}
|
||||
case int:
|
||||
if number >= 0 {
|
||||
return number, true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"result", "data"} {
|
||||
if result, ok := nestedNonNegativeInt(typed[key], keys...); ok {
|
||||
return result, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func splitDocMarkdown(content string, maxRunes int) []string {
|
||||
if maxRunes <= 0 {
|
||||
return []string{content}
|
||||
@@ -796,11 +952,17 @@ func containsText(value any, needle string) bool {
|
||||
}
|
||||
|
||||
func verifyUpdatedDocumentContent(value any, expected, mode, format string) bool {
|
||||
expected = normalizeDocumentContentForVerification(expected, format)
|
||||
expectedRaw := expected
|
||||
expected = normalizeDocumentContentForVerification(expectedRaw, format)
|
||||
for _, candidate := range documentContentCandidates(value, format) {
|
||||
actual := normalizeDocumentContentForVerification(candidate, format)
|
||||
actualRaw := candidate
|
||||
actual := normalizeDocumentContentForVerification(actualRaw, format)
|
||||
if mode == "overwrite" {
|
||||
if actual == expected {
|
||||
if actual == expected || (format == "markdown" && stripReadbackDocumentTitle(actual) == expected) {
|
||||
return true
|
||||
}
|
||||
if format == "markdown" && (markdownSemanticallyEquivalent(actualRaw, expectedRaw) ||
|
||||
markdownSemanticallyEquivalent(stripReadbackDocumentTitle(actualRaw), expectedRaw)) {
|
||||
return true
|
||||
}
|
||||
continue
|
||||
@@ -808,10 +970,48 @@ func verifyUpdatedDocumentContent(value any, expected, mode, format string) bool
|
||||
if actual == expected || strings.HasSuffix(actual, "\n"+expected) {
|
||||
return true
|
||||
}
|
||||
if format == "markdown" && markdownSemanticallyEndsWith(actualRaw, expectedRaw) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func markdownSemanticallyEquivalent(left, right string) bool {
|
||||
leftFingerprint, leftOK := markdownSemanticFingerprint(left)
|
||||
rightFingerprint, rightOK := markdownSemanticFingerprint(right)
|
||||
return leftOK && rightOK && leftFingerprint == rightFingerprint
|
||||
}
|
||||
|
||||
func markdownSemanticallyEndsWith(content, suffix string) bool {
|
||||
contentFingerprint, contentOK := markdownSemanticFingerprint(content)
|
||||
suffixFingerprint, suffixOK := markdownSemanticFingerprint(suffix)
|
||||
return contentOK && suffixOK && strings.HasSuffix(contentFingerprint, suffixFingerprint)
|
||||
}
|
||||
|
||||
func markdownSemanticFingerprint(source string) (string, bool) {
|
||||
if len(source) > docMarkdownVerifyMax {
|
||||
return "", false
|
||||
}
|
||||
var rendered bytes.Buffer
|
||||
if err := docMarkdownConvert([]byte(source), &rendered); err != nil {
|
||||
return "", false
|
||||
}
|
||||
return rendered.String(), true
|
||||
}
|
||||
|
||||
func stripReadbackDocumentTitle(content string) string {
|
||||
lines := strings.Split(content, "\n")
|
||||
if len(lines) == 0 || !strings.HasPrefix(strings.TrimSpace(lines[0]), "# ") {
|
||||
return content
|
||||
}
|
||||
lines = lines[1:]
|
||||
for len(lines) > 0 && strings.TrimSpace(lines[0]) == "" {
|
||||
lines = lines[1:]
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func verifyInsertedBlock(result, data map[string]any, referenceBlockID, expected, format string) bool {
|
||||
return verifyInsertedCanonicalBlock(result, data, referenceBlockID, normalizeDocumentContentForVerification(expected, format), format)
|
||||
}
|
||||
@@ -848,6 +1048,11 @@ func blockContentEquals(data map[string]any, blockID, expected, format string) b
|
||||
}
|
||||
|
||||
func canonicalBlockContent(value any, format string) string {
|
||||
if values, ok := value.(map[string]any); ok {
|
||||
if element, ok := values["element"].(map[string]any); ok {
|
||||
value = element
|
||||
}
|
||||
}
|
||||
if format == "jsonml" {
|
||||
if values, ok := value.(map[string]any); ok {
|
||||
if encoded, ok := values["jsonml"].(string); ok {
|
||||
@@ -871,7 +1076,7 @@ func canonicalBlockContent(value any, format string) string {
|
||||
return
|
||||
}
|
||||
for key, child := range typed {
|
||||
if key == "id" || key == "blockId" || key == "uuid" {
|
||||
if key == "id" || key == "blockId" || key == "uuid" || key == "blockType" {
|
||||
continue
|
||||
}
|
||||
walk(child)
|
||||
@@ -1005,6 +1210,10 @@ func orderedDocumentBlocks(value any) []map[string]any {
|
||||
walk = func(current any) {
|
||||
switch typed := current.(type) {
|
||||
case map[string]any:
|
||||
if element, ok := typed["element"].(map[string]any); ok && blockIdentity(element, "") != "" {
|
||||
blocks = append(blocks, element)
|
||||
return
|
||||
}
|
||||
if blockIdentity(typed, "") != "" {
|
||||
blocks = append(blocks, typed)
|
||||
return
|
||||
@@ -1065,9 +1274,23 @@ func normalizeDocumentContentForVerification(raw, format string) string {
|
||||
func normalizeMarkdownForVerification(raw string) string {
|
||||
raw = strings.ReplaceAll(strings.ReplaceAll(raw, "\r\n", "\n"), "\r", "\n")
|
||||
lines := make([]string, 0, strings.Count(raw, "\n")+1)
|
||||
inFence := false
|
||||
for _, line := range strings.Split(raw, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(trimmed, "```") || strings.HasPrefix(trimmed, "~~~") {
|
||||
inFence = !inFence
|
||||
lines = append(lines, trimmed)
|
||||
continue
|
||||
}
|
||||
if inFence {
|
||||
lines = append(lines, strings.TrimRight(line, " \t"))
|
||||
continue
|
||||
}
|
||||
line = trimmed
|
||||
if line == "" {
|
||||
if len(lines) > 0 && lines[len(lines)-1] != "" {
|
||||
lines = append(lines, "")
|
||||
}
|
||||
continue
|
||||
}
|
||||
if strings.Contains(line, "|") {
|
||||
@@ -1081,6 +1304,9 @@ func normalizeMarkdownForVerification(raw string) string {
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
for len(lines) > 0 && lines[len(lines)-1] == "" {
|
||||
lines = lines[:len(lines)-1]
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
@@ -1089,33 +1315,75 @@ func normalizeJSONMLForVerification(raw string) string {
|
||||
if err := json.Unmarshal([]byte(raw), &value); err != nil {
|
||||
return normalizeMarkdownForVerification(raw)
|
||||
}
|
||||
var tokens []string
|
||||
var walk func(any)
|
||||
walk = func(current any) {
|
||||
var normalize func(any) any
|
||||
normalize = func(current any) any {
|
||||
switch typed := current.(type) {
|
||||
case []any:
|
||||
start := 0
|
||||
if len(typed) > 0 {
|
||||
if tag, ok := typed[0].(string); ok {
|
||||
tokens = append(tokens, "<"+strings.ToLower(strings.TrimSpace(tag))+">")
|
||||
start = 1
|
||||
if len(typed) == 0 {
|
||||
return []any{}
|
||||
}
|
||||
tag, isElement := typed[0].(string)
|
||||
if !isElement {
|
||||
out := make([]any, 0, len(typed))
|
||||
for _, child := range typed {
|
||||
out = append(out, normalize(child))
|
||||
}
|
||||
return out
|
||||
}
|
||||
start := 1
|
||||
attrs := map[string]any{}
|
||||
if len(typed) > 1 {
|
||||
if declared, ok := typed[1].(map[string]any); ok {
|
||||
attrs, _ = normalize(declared).(map[string]any)
|
||||
start = 2
|
||||
}
|
||||
}
|
||||
children := make([]any, 0, len(typed)-start)
|
||||
for _, child := range typed[start:] {
|
||||
walk(child)
|
||||
normalized := normalize(child)
|
||||
if normalized != nil {
|
||||
children = append(children, normalized)
|
||||
}
|
||||
}
|
||||
if strings.EqualFold(tag, "span") && isGeneratedTextSpan(attrs) {
|
||||
if len(children) == 1 {
|
||||
return children[0]
|
||||
}
|
||||
return children
|
||||
}
|
||||
out := []any{strings.ToLower(tag), attrs}
|
||||
out = append(out, children...)
|
||||
return out
|
||||
case map[string]any:
|
||||
// JSONML maps contain element attributes. Server-generated UUIDs and
|
||||
// default attributes do not change the authored document content.
|
||||
return
|
||||
case string:
|
||||
if text := normalizeMarkdownForVerification(typed); text != "" {
|
||||
tokens = append(tokens, text)
|
||||
out := make(map[string]any, len(typed))
|
||||
for key, child := range typed {
|
||||
normalizedKey := strings.ToLower(strings.NewReplacer("_", "", "-", "").Replace(key))
|
||||
if normalizedKey == "uuid" || normalizedKey == "blockid" || normalizedKey == "elementid" || normalizedKey == "index" {
|
||||
continue
|
||||
}
|
||||
out[key] = normalize(child)
|
||||
}
|
||||
return out
|
||||
case string:
|
||||
return strings.ReplaceAll(strings.ReplaceAll(typed, "\r\n", "\n"), "\r", "\n")
|
||||
}
|
||||
return current
|
||||
}
|
||||
walk(value)
|
||||
return strings.Join(tokens, "\n")
|
||||
// normalize only receives values decoded by encoding/json, so the resulting
|
||||
// tree is always JSON-marshalable.
|
||||
encoded, _ := json.Marshal(normalize(value))
|
||||
return string(encoded)
|
||||
}
|
||||
|
||||
func isGeneratedTextSpan(attrs map[string]any) bool {
|
||||
if len(attrs) == 0 {
|
||||
return true
|
||||
}
|
||||
if len(attrs) != 1 {
|
||||
return false
|
||||
}
|
||||
value, ok := attrs["data-type"].(string)
|
||||
return ok && (value == "text" || value == "leaf")
|
||||
}
|
||||
|
||||
func executeExport(rt *shortcut.RuntimeContext) error {
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package doc
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageDocReadbackRetriesStaleContent(t *testing.T) {
|
||||
testseam.Swap(t, &docVerifySleep, func(time.Duration) {})
|
||||
testseam.Swap(t, &docVerifyDelays, []time.Duration{time.Millisecond})
|
||||
caller := &docCoverageCaller{responses: map[string][]map[string]any{
|
||||
"get_document_content": {{"markdown": "old"}, {"markdown": "old\nnew"}},
|
||||
}}
|
||||
if err := runDocCoverage(t, Update, caller, "--node", "n", "--command", "append", "--content", "new", "--yes"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reads := 0
|
||||
for _, call := range caller.history {
|
||||
if call.tool == "get_document_content" {
|
||||
reads++
|
||||
}
|
||||
}
|
||||
if reads != 2 {
|
||||
t.Fatalf("readback calls = %d, want 2; history=%#v", reads, caller.history)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDocDeleteReadbackConsumesEveryPage(t *testing.T) {
|
||||
testseam.Swap(t, &docVerifySleep, func(time.Duration) {})
|
||||
testseam.Swap(t, &docVerifyDelays, []time.Duration{time.Millisecond})
|
||||
firstPage := make([]any, 50)
|
||||
for index := range firstPage {
|
||||
firstPage[index] = map[string]any{"id": fmt.Sprintf("block-%d", index), "text": "body"}
|
||||
}
|
||||
caller := &docCoverageCaller{responses: map[string][]map[string]any{
|
||||
"list_document_blocks": {
|
||||
{"blocks": firstPage, "hasMore": true, "totalCount": 51},
|
||||
{"blocks": []any{map[string]any{"id": "target", "text": "stale"}}, "hasMore": false, "totalCount": 51},
|
||||
{"blocks": firstPage, "hasMore": false, "totalCount": 50},
|
||||
},
|
||||
}}
|
||||
if err := runDocCoverage(t, Update, caller, "--node", "n", "--command", "block_delete", "--block-id", "target", "--yes"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
starts := []int{}
|
||||
for _, call := range caller.history {
|
||||
if call.tool == "list_document_blocks" {
|
||||
starts = append(starts, call.params["startIndex"].(int))
|
||||
}
|
||||
}
|
||||
if fmt.Sprint(starts) != "[0 50 0]" {
|
||||
t.Fatalf("pagination starts = %v, want [0 50 0]", starts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDocReplacePreflightIsGloballyUnique(t *testing.T) {
|
||||
firstPage := make([]any, 50)
|
||||
for index := range firstPage {
|
||||
text := "body"
|
||||
if index == 0 {
|
||||
text = "unique needle"
|
||||
}
|
||||
firstPage[index] = map[string]any{"id": fmt.Sprintf("block-%d", index), "text": text}
|
||||
}
|
||||
caller := &docCoverageCaller{responses: map[string][]map[string]any{
|
||||
"list_document_blocks": {
|
||||
{"blocks": firstPage, "hasMore": true, "totalCount": 51},
|
||||
{"blocks": []any{map[string]any{"id": "block-50", "text": "another needle"}}, "hasMore": false, "totalCount": 51},
|
||||
},
|
||||
}}
|
||||
err := runDocCoverage(t, Update, caller, "--node", "n", "--command", "str_replace", "--old", "needle", "--new", "changed", "--yes")
|
||||
if err == nil {
|
||||
t.Fatal("str_replace accepted a second match on a later page")
|
||||
}
|
||||
for _, call := range caller.history {
|
||||
if call.tool == "update_document_block" {
|
||||
t.Fatalf("ambiguous replace executed a write: %#v", caller.history)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDocVerificationPreservesMeaning(t *testing.T) {
|
||||
expected := "[\"p\",{},\"text\"]"
|
||||
serverExpanded := "[\"p\",{\"uuid\":\"generated\"},[\"span\",{\"data-type\":\"text\"},[\"span\",{\"data-type\":\"leaf\"},\"text\"]]]"
|
||||
if normalizeJSONMLForVerification(expected) != normalizeJSONMLForVerification(serverExpanded) {
|
||||
t.Fatal("generated JSONML text wrappers should not change document meaning")
|
||||
}
|
||||
linkA := "[\"a\",{\"href\":\"https://example.com/a\"},\"text\"]"
|
||||
linkB := "[\"a\",{\"href\":\"https://example.com/b\"},\"text\"]"
|
||||
if normalizeJSONMLForVerification(linkA) == normalizeJSONMLForVerification(linkB) {
|
||||
t.Fatal("semantic JSONML attributes were ignored")
|
||||
}
|
||||
codeA := "~~~go\n return nil\n~~~"
|
||||
codeB := "~~~go\nreturn nil\n~~~"
|
||||
if normalizeMarkdownForVerification(codeA) == normalizeMarkdownForVerification(codeB) {
|
||||
t.Fatal("fenced code indentation was ignored")
|
||||
}
|
||||
if !verifyUpdatedDocumentContent(map[string]any{"markdown": "# Server title\n\nbody"}, "body", "overwrite", "markdown") {
|
||||
t.Fatal("server-generated document title prevented body verification")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMarkdownSemanticRoundTrip(t *testing.T) {
|
||||
input := strings.Join([]string{
|
||||
"sales_data.xlsx",
|
||||
"### 1.",
|
||||
"+10.22%",
|
||||
"| name | value |",
|
||||
"| -------- | -------- |",
|
||||
"| sales_data.xlsx | +10.22% |",
|
||||
}, "\n")
|
||||
server := strings.Join([]string{
|
||||
`sales\_data.xlsx`,
|
||||
`### 1\.`,
|
||||
`\+10.22%`,
|
||||
"|name|value|",
|
||||
"|---|---|",
|
||||
`|sales\_data.xlsx|\+10.22%|`,
|
||||
}, "\n")
|
||||
if !markdownSemanticallyEquivalent(input, server) {
|
||||
t.Fatal("server Markdown escaping and table delimiter normalization changed the semantic fingerprint")
|
||||
}
|
||||
if !verifyUpdatedDocumentContent(map[string]any{"markdown": server}, input, "overwrite", "markdown") {
|
||||
t.Fatal("equivalent server Markdown failed overwrite verification")
|
||||
}
|
||||
if !verifyUpdatedDocumentContent(map[string]any{"markdown": "existing\n\n" + server}, input, "append", "markdown") {
|
||||
t.Fatal("equivalent server Markdown failed append verification")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMarkdownSemanticDifferencesRemainStrict(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
left string
|
||||
right string
|
||||
}{
|
||||
{name: "emphasis", left: `*important*`, right: `\*important\*`},
|
||||
{name: "inline code", left: "`sales_data`", right: "`sales\\_data`"},
|
||||
{name: "fenced code", left: "```\nsales_data\n```", right: "```\nsales\\_data\n```"},
|
||||
{name: "table alignment", left: "|a|\n|---|\n|x|", right: "|a|\n|:---|\n|x|"},
|
||||
{name: "table columns", left: "|a|b|\n|---|---|\n|x|y|", right: "|a|\n|---|\n|x|"},
|
||||
{name: "table content", left: "|a|\n|---|\n|x|", right: "|a|\n|---|\n|y|"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if markdownSemanticallyEquivalent(test.left, test.right) {
|
||||
t.Fatal("meaningful Markdown difference was ignored")
|
||||
}
|
||||
})
|
||||
}
|
||||
oversized := strings.Repeat("x", docMarkdownVerifyMax+1)
|
||||
if _, ok := markdownSemanticFingerprint(oversized); ok {
|
||||
t.Fatal("oversized Markdown entered semantic verification")
|
||||
}
|
||||
testseam.Swap(t, &docMarkdownConvert, func([]byte, io.Writer) error { return errors.New("render") })
|
||||
if _, ok := markdownSemanticFingerprint("body"); ok {
|
||||
t.Fatal("failed Markdown render produced a semantic fingerprint")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDocElementReadbackUsesNestedElement(t *testing.T) {
|
||||
wrapper := map[string]any{
|
||||
"blockType": "paragraph",
|
||||
"element": map[string]any{"id": "inserted", "blockType": "paragraph", "paragraph": map[string]any{"text": "body"}},
|
||||
}
|
||||
if got := canonicalBlockContent(wrapper, "markdown"); got != "body" {
|
||||
t.Fatalf("nested element content = %q, want body", got)
|
||||
}
|
||||
blocks := orderedDocumentBlocks(map[string]any{"blocks": []any{wrapper}})
|
||||
if len(blocks) != 1 || blockIdentity(blocks[0], "") != "inserted" {
|
||||
t.Fatalf("nested element blocks = %#v", blocks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageVersionRevertRequiresTargetEvidence(t *testing.T) {
|
||||
if revertResultMatchesVersion(map[string]any{"ok": true}, 3) || currentDocumentMatchesRestoredVersion(map[string]any{"version": 99}, 3) {
|
||||
t.Fatal("readability or an unrelated current version must not prove a revert")
|
||||
}
|
||||
if !revertResultMatchesVersion(map[string]any{"revertedToVersion": 3}, 3) {
|
||||
t.Fatal("explicit target-version acknowledgement was not accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDocReadbackDefensiveEdges(t *testing.T) {
|
||||
testseam.Swap(t, &docVerifySleep, func(time.Duration) {})
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
responses []map[string]any
|
||||
failAt int
|
||||
}{
|
||||
{"call failure", nil, 1},
|
||||
{"missing blocks", []map[string]any{{"ok": true}}, 0},
|
||||
{"stalled page", []map[string]any{{"blocks": []any{map[string]any{"id": "a"}}, "hasMore": true}, {"blocks": []any{map[string]any{"id": "a"}}, "hasMore": true}}, 0},
|
||||
{"empty continued page", []map[string]any{{"blocks": []any{}, "hasMore": true}}, 0},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
testseam.Swap(t, &docVerifyDelays, []time.Duration{})
|
||||
caller := &docCoverageCaller{failAt: tc.failAt, responses: map[string][]map[string]any{"list_document_blocks": tc.responses}}
|
||||
err := runDocCoverage(t, Update, caller, "--node", "n", "--command", "block_delete", "--block-id", "target", "--yes")
|
||||
if err == nil {
|
||||
t.Fatal("defensive readback unexpectedly succeeded")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("total count terminates pagination", func(t *testing.T) {
|
||||
testseam.Swap(t, &docVerifyDelays, []time.Duration{})
|
||||
caller := &docCoverageCaller{responses: map[string][]map[string]any{"list_document_blocks": {{"blocks": []any{map[string]any{"id": "other"}}, "totalCount": 1}}}}
|
||||
if err := runDocCoverage(t, Update, caller, "--node", "n", "--command", "block_delete", "--block-id", "target", "--yes"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("block read safety limit", func(t *testing.T) {
|
||||
testseam.Swap(t, &docVerifyDelays, []time.Duration{})
|
||||
pages := make([]map[string]any, docBlockReadMaxItems/docBlockReadPageSize)
|
||||
for index := range pages {
|
||||
pages[index] = map[string]any{"blocks": []any{map[string]any{"id": fmt.Sprintf("block-%d", index)}}, "hasMore": true}
|
||||
}
|
||||
caller := &docCoverageCaller{responses: map[string][]map[string]any{"list_document_blocks": pages}}
|
||||
if err := runDocCoverage(t, Update, caller, "--node", "n", "--command", "block_delete", "--block-id", "target", "--yes"); err == nil {
|
||||
t.Fatal("oversized block read returned nil")
|
||||
}
|
||||
})
|
||||
|
||||
if blocks, ok := documentBlockEntries(map[string]any{"jsonml": `["root",{},["p",{"uuid":"a"},"x"]]`}); !ok || len(blocks) == 0 {
|
||||
t.Fatalf("jsonml blocks=%#v ok=%v", blocks, ok)
|
||||
}
|
||||
if _, ok := documentBlockEntries(map[string]any{"jsonml": `{`}); ok {
|
||||
t.Fatal("invalid jsonml produced blocks")
|
||||
}
|
||||
if blocks, ok := documentBlockEntries(map[string]any{"data": map[string]any{"items": []any{"x"}}}); !ok || len(blocks) != 1 {
|
||||
t.Fatalf("nested items=%#v ok=%v", blocks, ok)
|
||||
}
|
||||
if _, ok := documentBlockEntries(nil); ok {
|
||||
t.Fatal("nil produced blocks")
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
value any
|
||||
want bool
|
||||
}{
|
||||
{map[string]any{"totalCount": float64(2)}, true},
|
||||
{map[string]any{"totalCount": float64(-1)}, false},
|
||||
{map[string]any{"totalCount": 2.5}, false},
|
||||
{map[string]any{"data": map[string]any{"total_count": 2}}, true},
|
||||
{map[string]any{"totalCount": -1}, false},
|
||||
{nil, false},
|
||||
} {
|
||||
_, ok := nestedNonNegativeInt(tc.value, "totalCount", "total_count")
|
||||
if ok != tc.want {
|
||||
t.Fatalf("nestedNonNegativeInt(%#v) ok=%v want=%v", tc.value, ok, tc.want)
|
||||
}
|
||||
}
|
||||
|
||||
for _, raw := range []string{
|
||||
`[]`, `[1,["p",{},"x"]]`, `["span",{},"a","b"]`,
|
||||
`["p",{"block_id":"x","custom":true},"x"]`, `true`,
|
||||
} {
|
||||
if normalizeJSONMLForVerification(raw) == "" {
|
||||
t.Fatalf("empty normalized JSONML for %s", raw)
|
||||
}
|
||||
}
|
||||
if !isGeneratedTextSpan(nil) || isGeneratedTextSpan(map[string]any{"a": 1, "b": 2}) || isGeneratedTextSpan(map[string]any{"data-type": 3}) || isGeneratedTextSpan(map[string]any{"data-type": "other"}) {
|
||||
t.Fatal("generated span classification failed")
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
@@ -112,6 +113,7 @@ func runDocCoverageInput(t *testing.T, declaration shortcut.Shortcut, caller *do
|
||||
|
||||
func runDocCoveragePath(t *testing.T, declaration shortcut.Shortcut, caller *docCoverageCaller, input io.Reader, commandPath string, args ...string) error {
|
||||
t.Helper()
|
||||
testseam.Swap(t, &docVerifySleep, func(time.Duration) {})
|
||||
helpers.InitDeps(caller)
|
||||
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||
root.PersistentFlags().Bool("yes", false, "")
|
||||
@@ -491,6 +493,7 @@ func TestCrossPlatformCoverageDocUpdateAliasReachesNestedBranches(t *testing.T)
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDocWritesStopOnUnknownCommitAndRequireVerification(t *testing.T) {
|
||||
testseam.Swap(t, &docVerifyDelays, []time.Duration{})
|
||||
unknown := &docCoverageCaller{failAt: 1, responses: map[string][]map[string]any{}}
|
||||
err := runDocCoverage(t, Update, unknown, "--node", "n", "--command", "append", "--content", "x", "--yes")
|
||||
var typed *apperrors.Error
|
||||
@@ -512,6 +515,7 @@ func TestCrossPlatformCoverageDocWritesStopOnUnknownCommitAndRequireVerification
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDocCreateRejectsSuccessfulMismatchedReadback(t *testing.T) {
|
||||
testseam.Swap(t, &docVerifyDelays, []time.Duration{})
|
||||
caller := &docCoverageCaller{responses: map[string][]map[string]any{
|
||||
"get_document_content": {{"markdown": "truncated"}},
|
||||
}}
|
||||
@@ -577,8 +581,15 @@ func TestCrossPlatformCoverageDocVersionRevertPaginationAndVerification(t *testi
|
||||
"revert_doc_version": {{}},
|
||||
"get_document_info": {{"nodeId": "n", "revision": 99.0}},
|
||||
}}
|
||||
if err := runDocCoverage(t, VersionRevert, caller, "--node", "n", "--version", "3", "--yes"); err != nil {
|
||||
t.Fatal(err)
|
||||
err := runDocCoverage(t, VersionRevert, caller, "--node", "n", "--version", "3", "--yes")
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) || typed.Reason != "doc_history_revert_target_unproven" || typed.FailureStage != "verify" || typed.Details["status"] != "partial_success" {
|
||||
t.Fatalf("unproven revert error = %#v", err)
|
||||
}
|
||||
data, _ := typed.Details["data"].(map[string]any)
|
||||
steps, _ := typed.Details["steps"].([]map[string]any)
|
||||
if data["verified"] != false || len(steps) != 3 || steps[2]["status"] != "failed" {
|
||||
t.Fatalf("unproven revert details = %#v", typed.Details)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -178,15 +178,47 @@ func executeHistoryRevert(rt *shortcut.RuntimeContext) error {
|
||||
map[string]any{"available": false, "reason": "the requested revert completed; verify the current document before any further write"},
|
||||
)
|
||||
}
|
||||
verified := revertResultMatchesVersion(reverted, target) || currentDocumentMatchesRestoredVersion(current, target)
|
||||
if !verified {
|
||||
return docPartialWriteError(
|
||||
"doc.history_revert", "doc_history_revert_target_unproven", "verify",
|
||||
fmt.Sprintf("版本 %d 的回滚请求已执行且文档可读,但响应没有提供目标版本证据;不要直接重试回滚", target),
|
||||
fmt.Errorf("回读缺少目标版本 %d 的明确证据", target),
|
||||
map[string]any{
|
||||
"nodeId": nodeID, "version": target, "reverted": true, "verified": false,
|
||||
"revertResult": reverted, "current": current,
|
||||
},
|
||||
[]map[string]any{
|
||||
{"name": "preflight", "status": "success"},
|
||||
{"name": "revert", "status": "success"},
|
||||
{"name": "verify", "status": "failed"},
|
||||
},
|
||||
map[string]any{"available": false, "reason": "the revert may have completed; inspect version history before any further revert"},
|
||||
)
|
||||
}
|
||||
return rt.Output(docEnvelope("doc.history_revert", map[string]any{
|
||||
"version": target, "revertResult": reverted, "current": current, "verified": true,
|
||||
"verification": "revert_acknowledged_and_document_readable",
|
||||
"verification": "target_version_proven",
|
||||
},
|
||||
map[string]any{"name": "preflight", "status": "success"},
|
||||
map[string]any{"name": "revert", "status": "success"},
|
||||
map[string]any{"name": "verify", "status": "success"}))
|
||||
}
|
||||
|
||||
func revertResultMatchesVersion(value map[string]any, target int) bool {
|
||||
return versionEvidenceMatches(value, target, map[string]bool{
|
||||
"version": true, "targetversion": true, "appliedversion": true,
|
||||
"restoredversion": true, "revertedversion": true, "revertedtoversion": true, "sourceversion": true,
|
||||
})
|
||||
}
|
||||
|
||||
func currentDocumentMatchesRestoredVersion(value map[string]any, target int) bool {
|
||||
return versionEvidenceMatches(value, target, map[string]bool{
|
||||
"restoredfromversion": true, "revertedfromversion": true,
|
||||
"sourceversion": true, "appliedversion": true, "targetversion": true,
|
||||
})
|
||||
}
|
||||
|
||||
func findHistoryVersion(rt *shortcut.RuntimeContext, nodeID string, target int) (bool, error) {
|
||||
const maxPages = 20
|
||||
cursor := ""
|
||||
|
||||
Reference in New Issue
Block a user