diff --git a/.changes/wiki-shortcut-alignment.md b/.changes/wiki-shortcut-alignment.md
index 86551e85..44a9e6db 100644
--- a/.changes/wiki-shortcut-alignment.md
+++ b/.changes/wiki-shortcut-alignment.md
@@ -4,5 +4,6 @@ category: Added
- **Wiki Shortcut workflows** — publishes 20 reviewed space, member, node, and
activity shortcuts with strict collection validation, cursor handling,
- write read-backs, task-oriented routing, and documented backend
+ write-terminal evidence, safe read-backs where the backend supports them,
+ task-oriented routing, and documented backend
boundaries.
diff --git a/docs/shortcut-public-catalog.json b/docs/shortcut-public-catalog.json
index b74e3973..7d447fc7 100644
--- a/docs/shortcut-public-catalog.json
+++ b/docs/shortcut-public-catalog.json
@@ -1,5 +1,5 @@
{
- "generated_at": "2026-08-14T11:59:55.882207",
+ "generated_at": "2026-08-14T13:50:36.324505",
"count": 418,
"results": [
{
@@ -3684,8 +3684,8 @@
"command": "+member-add",
"risk": "write",
"status": "reviewed_available",
- "disposition": "primary_smart",
- "semantic_delta": "批量授予 Wiki 容器角色,严格校验写终态并读取成员列表验证响应链。",
+ "disposition": "semantic_adapter",
+ "semantic_delta": "支持 1-30 个 userId 与四类角色;严格要求写接口终态,并明确后端无法提供精确成员读回。",
"availability": "available"
},
{
@@ -3704,8 +3704,8 @@
"command": "+member-remove",
"risk": "write",
"status": "reviewed_available",
- "disposition": "primary_smart",
- "semantic_delta": "批量移除容器访问,严格校验写终态并读取成员列表验证响应链。",
+ "disposition": "semantic_adapter",
+ "semantic_delta": "支持批量 userId 移除;严格要求写接口终态,并明确后端无法提供精确成员读回。",
"availability": "available"
},
{
@@ -3714,8 +3714,8 @@
"command": "+member-update",
"risk": "write",
"status": "reviewed_available",
- "disposition": "primary_smart",
- "semantic_delta": "补充成员角色更新,并执行终态与列表读回校验。",
+ "disposition": "semantic_adapter",
+ "semantic_delta": "补充成员角色更新;严格要求写接口终态,并明确后端无法提供精确成员读回。",
"availability": "available"
},
{
diff --git a/docs/wiki-shortcut-business-review.html b/docs/wiki-shortcut-business-review.html
index 59a36df8..ce3437cc 100644
--- a/docs/wiki-shortcut-business-review.html
+++ b/docs/wiki-shortcut-business-review.html
@@ -19,7 +19,7 @@
DWS Wiki Shortcut 全景评审
以 13 项成熟 Wiki 用户任务为基线,重新审视 DWS 的空间、成员、节点与动态能力。本次不是按命令名凑数:每个入口都要求真实业务证据,缺失数组、畸形响应、空确认或读回不一致一律失败。
- 评审日期 2026-08-14独立 worktree / 独立分支真实组织数据 E2E报告已去标识化
+ 评审日期 2026-08-14独立 worktree / 独立分支真实组织数据 E2E 28/28报告已去标识化
@@ -41,9 +41,9 @@
+space-list | wiki +space-list | 完整对齐 | 严格空集合、游标续传、自动翻页、停滞检测;支持组织/我的知识库。 |
+space-create | wiki +space-create | 超过 | 公开真实 32 字符名称上限;创建后按 workspaceId 读回。 |
+delete-space | wiki +delete-space | 超过 | 预读目标、高风险确认、只接受 success=true;兼容 +space-delete。 |
-
+member-add | wiki +member-add | 任务对齐 | 支持 1–30 个 userId 与四种角色,读回名单和角色;不支持 email/open_id 等身份类型。 |
+
+member-add | wiki +member-add | 任务对齐 | 支持 1–30 个 userId 与四种角色;以写接口终态作为成功证据,不把最多 50 条的名单误作精确读回。 |
+member-list | wiki +member-list | 任务对齐 | 严格成员数组、角色过滤、真实上限 50;后端无游标,不能提供诚实的 page-all。 |
-
+member-remove | wiki +member-remove | 任务对齐 | 批量 userId,移除后读回确认成员已消失;身份类型边界同上。 |
+
+member-remove | wiki +member-remove | 任务对齐 | 支持批量 userId;只接受写接口明确终态,并公开无法进行精确成员读回的边界。 |
+node-list | wiki +node-list | 完整对齐 | 正确跨域路由 doc/list_nodes,严格空目录、分页与自动翻页。 |
+node-get | wiki +node-get | 任务对齐 | 支持 DingTalk 节点 ID/在线文档 URL 并返回文档域元数据;不接受跨平台专用的 token/type 组合。 |
+node-create | wiki +node-create | 任务对齐 | 支持 adoc/axls/able/appt/adraw/amind/folder 并读回;无 origin/shortcut 等价接口。 |
@@ -58,7 +58,7 @@
DWS 可挖掘的 7 个额外场景
定位与创建链
+space-search:严格关键词搜索。+space-get:空间详情与 workspaceId 证据。+resolve-space:唯一命中直出 ID,多命中拒绝猜测。+wiki-new-doc:空间名解析 → 创建 → 文档读回。
-
治理与巡检链
+member-update:角色变更后读回。+node-search:库内关键词/扩展名搜索,严格零命中。+feed-list:知识库动态时间线与服务端 exclude-file 过滤。
+
治理与巡检链
+member-update:角色变更终态与不可精确读回声明。+node-search:库内关键词/扩展名搜索,严格零命中。+feed-list:知识库动态时间线与服务端 exclude-file 过滤。
@@ -69,14 +69,15 @@
| 列表投影找不到数组或遇到坏元素时返回空 slice。 | 把内部错误、字段漂移误报为“没有数据”。 | 失败关闭 只有响应中真实存在的 [] 才是合法空集合。 |
| 节点列表 Shortcut 调错 Wiki MCP 服务。 | 真实后端 success=false,Mock/静态检查看不出。 | 跨域路由 明确调用 doc/list_nodes,并纳入真实 E2E。 |
| 成员帮助宣称最大 200。 | 真实接口超过 50 直接参数错误。 | 真实上限 Shortcut 与原子 Help 均改为 50,并在本地提前拒绝。 |
+
| 成员写操作从最多 50 条、不可分页的名单推断成员存在或缺失。 | 目标在截断部分时会误报写失败,或把未验证的移除报告为已读回。 | 终态证据 只接受写接口 success=true,并在结果中明确 readbackAvailable=false。 |
| 知识库名称帮助宣称最大 100。 | 真实接口超过 32 失败。 | 真实上限 Help 与 Shortcut 校验统一为 32。 |
-
| 复制/移动/创建只把无异常视为成功。 | 空确认、未知远端效果或移动未到目标仍可能被接受。 | 读回证明 检查 success、业务 ID、workspace/folder/role 等最终状态。 |
+
| 复制/移动/创建只把无异常视为成功。 | 空确认、未知远端效果或移动未到目标仍可能被接受。 | 读回证明 在后端具备精确查询能力时检查 success、业务 ID、workspace/folder 等最终状态。 |
真实数据 E2E 证据矩阵
- 测试使用一次性空知识库、临时在线文档与一名同组织内部测试成员;所有对象在 finally 清理。报告不保存对象 ID、成员身份、组织信息、URL、trace 或原始响应。
+ 28 项业务断言全部通过。测试使用一次性空知识库、临时在线文档与一名同组织内部测试成员;所有对象在 finally 清理。报告不保存对象 ID、成员身份、组织信息、URL、trace 或原始响应。
| 分组 | Shortcut | 实际业务断言 | 状态 |
| 空间 | +space-list | 真实 count、hasMore、nextCursor;自动翻页返回两页结果。 | PASS |
@@ -86,9 +87,9 @@
| 空间 | +space-create | success=true、workspaceId 非空、详情读回一致。 | PASS |
| 空间 | +delete-space | 目标预读、确认、success=true;兼容别名执行 finally 清理。 | PASS |
| 成员 | +member-list | 真实 owner 条目与显式 members 数组,limit=50。 | PASS |
- | 成员 | +member-add | 真实内部成员添加后在名单中出现且角色为 READER。 | PASS |
- | 成员 | +member-update | 同一成员读回角色变为 EDITOR。 | PASS |
- | 成员 | +member-remove | 移除后名单中不再存在该 userId。 | PASS |
+ | 成员 | +member-add | 命令只报告写终态;一次性小规模空间另行确认名单完整且角色为 READER。 | PASS |
+ | 成员 | +member-update | 命令只报告写终态;一次性小规模空间另行确认角色变为 EDITOR。 | PASS |
+ | 成员 | +member-remove | 命令只报告写终态;一次性小规模空间另行确认完整名单中不存在该 userId。 | PASS |
| 节点 | +node-list | 空库返回真实 nodes:[];有数据时验证游标与自动翻页。 | PASS |
| 节点 | +node-get | 读回 nodeId 与请求一致。 | PASS |
| 节点 | +node-search | 等待索引后按标题命中真实 nodeId。 | PASS |
@@ -105,7 +106,7 @@
成功判定与发布门
- 运行时证据层
- 传输/MCP 调用成功。
- 响应契约存在且类型正确。
- 写操作必须有
success=true 与业务 ID。 - 创建/复制/移动/成员变更必须读回最终状态。
- 集合只有显式数组才允许为空。
交付门
- 20/20 语义目录与注册面精确覆盖。
- Contract、Safety、Result、统一输出完整。
- 生成漂移、Schema、确认真值、全量 Go 测试。
- 独立真实数据 E2E 与 finally 清理。
- diff PII/密钥/本地绝对路径扫描。
+ 运行时证据层
- 传输/MCP 调用成功。
- 响应契约存在且类型正确。
- 写操作必须有
success=true;创建类操作还必须有业务 ID。 - 后端具备精确查询时必须读回;不具备时明确发布不可读回,而非从截断集合推断。
- 集合只有显式数组才允许为空。
交付门
- 20/20 语义目录与注册面精确覆盖。
- Contract、Safety、Result、统一输出完整。
- 生成漂移、Schema、确认真值、全量 Go 测试。
- 独立真实数据 E2E 与 finally 清理。
- diff PII/密钥/本地绝对路径扫描。
diff --git a/internal/shortcut/semantic_catalog_wiki.json b/internal/shortcut/semantic_catalog_wiki.json
index 17a3e4a1..70481b90 100644
--- a/internal/shortcut/semantic_catalog_wiki.json
+++ b/internal/shortcut/semantic_catalog_wiki.json
@@ -10,10 +10,10 @@
"+space-create": {"disposition":"primary_smart","semantic_delta":"创建后要求 workspaceId 并通过空间详情读回验证真实落库。","risk":"write","public":true,"reviewed":true},
"+delete-space": {"disposition":"semantic_adapter","semantic_delta":"删除前读取影响目标,经高风险确认后只接受 success=true 终态。","risk":"high-risk-write","public":true,"reviewed":true},
- "+member-add": {"disposition":"primary_smart","semantic_delta":"批量授予 Wiki 容器角色,严格校验写终态并读取成员列表验证响应链。","risk":"write","public":true,"reviewed":true},
- "+member-update": {"disposition":"primary_smart","semantic_delta":"补充成员角色更新,并执行终态与列表读回校验。","risk":"write","public":true,"reviewed":true},
+ "+member-add": {"disposition":"semantic_adapter","semantic_delta":"支持 1-30 个 userId 与四类角色;严格要求写接口终态,并明确后端无法提供精确成员读回。","risk":"write","public":true,"reviewed":true},
+ "+member-update": {"disposition":"semantic_adapter","semantic_delta":"补充成员角色更新;严格要求写接口终态,并明确后端无法提供精确成员读回。","risk":"write","public":true,"reviewed":true},
"+member-list": {"disposition":"semantic_adapter","semantic_delta":"严格验证成员数组并公开真实单次上限 50;后端无游标时不伪造 page-all。","risk":"read","public":true,"reviewed":true},
- "+member-remove": {"disposition":"primary_smart","semantic_delta":"批量移除容器访问,严格校验写终态并读取成员列表验证响应链。","risk":"write","public":true,"reviewed":true},
+ "+member-remove": {"disposition":"semantic_adapter","semantic_delta":"支持批量 userId 移除;严格要求写接口终态,并明确后端无法提供精确成员读回。","risk":"write","public":true,"reviewed":true},
"+node-list": {"disposition":"semantic_adapter","semantic_delta":"严格区分显式空目录与假空成功,稳定投影节点并保留 nextCursor/hasMore。","risk":"read","public":true,"reviewed":true},
"+node-get": {"disposition":"schema_leaf","semantic_delta":"统一节点 ID 或在线文档 URL 的元数据读取,补充文档域属性视角。","risk":"read","public":true,"reviewed":true},
diff --git a/internal/shortcut/smart/mail_space_todo_projection_test.go b/internal/shortcut/smart/mail_space_todo_projection_test.go
index e5af1037..80869469 100644
--- a/internal/shortcut/smart/mail_space_todo_projection_test.go
+++ b/internal/shortcut/smart/mail_space_todo_projection_test.go
@@ -23,6 +23,7 @@ import (
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
@@ -103,6 +104,8 @@ func runShortcut(t *testing.T, fake *stubMailboxCaller, argv ...string) string {
root.PersistentFlags().Bool("yes", false, "")
root.PersistentFlags().Bool("dry-run", false, "")
root.PersistentFlags().String("format", "json", "")
+ ctx, _ := output.WithResultStore(context.Background())
+ root.SetContext(ctx)
root.AddCommand(shortcut.Commands()...)
var buf bytes.Buffer
root.SetOut(&buf)
@@ -121,6 +124,8 @@ func runShortcutErr(t *testing.T, fake *stubMailboxCaller, argv ...string) error
root.PersistentFlags().Bool("yes", false, "")
root.PersistentFlags().Bool("dry-run", false, "")
root.PersistentFlags().String("format", "json", "")
+ ctx, _ := output.WithResultStore(context.Background())
+ root.SetContext(ctx)
root.AddCommand(shortcut.Commands()...)
root.SetOut(io.Discard)
root.SetErr(io.Discard)
diff --git a/internal/shortcut/smart/wiki_workflow_coverage_test.go b/internal/shortcut/smart/wiki_workflow_coverage_test.go
new file mode 100644
index 00000000..06812531
--- /dev/null
+++ b/internal/shortcut/smart/wiki_workflow_coverage_test.go
@@ -0,0 +1,187 @@
+// Copyright 2026 Alibaba Group
+// Licensed under the Apache License, Version 2.0
+
+package smart
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestCrossPlatformCoverageResolveSpaceStrictContracts(t *testing.T) {
+ invalid := []map[string]any{
+ nil,
+ {"success": false},
+ {"success": "yes"},
+ {"result": "bad"},
+ {"wikiSpaces": "bad"},
+ {"wikiSpaces": []any{"bad"}},
+ {"wikiSpaces": []any{map[string]any{"workspaceId": "w"}}},
+ {"wikiSpaces": []any{map[string]any{"name": "Docs"}}},
+ {"success": true},
+ }
+ for index, data := range invalid {
+ if _, err := resolveSpaceItemsStrict(data); err == nil {
+ t.Fatalf("invalid response %d succeeded: %#v", index, data)
+ }
+ }
+
+ for _, data := range []map[string]any{
+ {"wikiSpaces": []any{}},
+ {"spaces": []any{map[string]any{"spaceId": "s", "spaceName": "Docs"}}},
+ {"result": map[string]any{"items": []any{map[string]any{"id": "i", "title": "Plan"}}}},
+ {"data": map[string]any{"records": []any{map[string]any{"workspaceId": "w", "name": "Roadmap"}}}},
+ } {
+ if _, err := resolveSpaceItemsStrict(data); err != nil {
+ t.Fatalf("valid response rejected: %#v: %v", data, err)
+ }
+ }
+
+ for _, tc := range []struct {
+ data map[string]any
+ id string
+ name string
+ }{
+ {map[string]any{"workspaceId": "w", "name": "N"}, "w", "N"},
+ {map[string]any{"spaceId": "s", "spaceName": "S"}, "s", "S"},
+ {map[string]any{"space_id": "legacy", "title": "T"}, "legacy", "T"},
+ {map[string]any{"id": "i"}, "i", ""},
+ {map[string]any{}, "", ""},
+ } {
+ if got := resolveSpaceID(tc.data); got != tc.id {
+ t.Fatalf("resolveSpaceID(%#v)=%q want %q", tc.data, got, tc.id)
+ }
+ if got := resolveSpaceName(tc.data); got != tc.name {
+ t.Fatalf("resolveSpaceName(%#v)=%q want %q", tc.data, got, tc.name)
+ }
+ }
+}
+
+func TestCrossPlatformCoverageResolveSpaceExecution(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ fake *stubMailboxCaller
+ ok bool
+ }{
+ {"transport error", &stubMailboxCaller{errTool: "search_wikiSpaces"}, false},
+ {"malformed result", &stubMailboxCaller{byTool: map[string]string{"search_wikiSpaces": `{"success":true}`}}, false},
+ {"zero result", &stubMailboxCaller{byTool: map[string]string{"search_wikiSpaces": `{"wikiSpaces":[]}`}}, false},
+ {"one result", &stubMailboxCaller{byTool: map[string]string{"search_wikiSpaces": `{"wikiSpaces":[{"workspaceId":"w","name":"Docs"}]}`}}, true},
+ {"multiple results", &stubMailboxCaller{byTool: map[string]string{"search_wikiSpaces": `{"wikiSpaces":[{"workspaceId":"w1","name":"Docs"},{"workspaceId":"w2","name":"Docs 2"}]}`}}, true},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ err := runShortcutErr(t, tc.fake, "wiki", "+resolve-space", "--name", "Docs", "--format", "json")
+ if (err == nil) != tc.ok {
+ t.Fatalf("success=%v want %v; err=%v", err == nil, tc.ok, err)
+ }
+ })
+ }
+}
+
+func TestCrossPlatformCoverageWikiNewDocParsing(t *testing.T) {
+ invalid := []map[string]any{
+ nil,
+ {"success": false},
+ {"success": "yes"},
+ {"result": map[string]any{"wikiSpaces": "bad"}},
+ {"wikiSpaces": []any{"bad"}},
+ {"wikiSpaces": []any{map[string]any{"workspaceId": "w"}}},
+ {"success": true},
+ }
+ for index, data := range invalid {
+ if _, err := wikiNewDocExtractSpaces(data); err == nil {
+ t.Fatalf("invalid response %d succeeded: %#v", index, data)
+ }
+ }
+
+ for _, data := range []map[string]any{
+ {"wikiSpaces": []any{}},
+ {"list": []any{map[string]any{"workspaceId": "w", "name": "Docs"}}},
+ {"data": map[string]any{"spaces": []any{map[string]any{"spaceId": "s", "spaceName": "Plan"}}}},
+ {"result": map[string]any{"result": []any{map[string]any{"id": "i", "title": "Roadmap"}}}},
+ } {
+ if _, err := wikiNewDocExtractSpaces(data); err != nil {
+ t.Fatalf("valid response rejected: %#v: %v", data, err)
+ }
+ }
+
+ if got := wikiNewDocFirstString(map[string]any{"nodeId": " n "}, "nodeId"); got != "n" {
+ t.Fatalf("direct node id=%q", got)
+ }
+ if got := wikiNewDocFirstString(map[string]any{"result": map[string]any{"fileId": " f "}}, "nodeId", "fileId"); got != "f" {
+ t.Fatalf("nested node id=%q", got)
+ }
+ if got := wikiNewDocFirstString(map[string]any{"data": map[string]any{"id": " i "}}, "id"); got != "i" {
+ t.Fatalf("data node id=%q", got)
+ }
+ if got := wikiNewDocFirstString(map[string]any{"id": 1}, "id"); got != "" {
+ t.Fatalf("invalid node id=%q", got)
+ }
+ labels := wikiNewDocLabels([]wikiSpaceCandidate{{id: "w1", name: "Docs"}, {id: "w2", name: "Plan"}})
+ if strings.Join(labels, ",") != "Docs(w1),Plan(w2)" {
+ t.Fatalf("labels=%v", labels)
+ }
+}
+
+func TestCrossPlatformCoverageWikiNewDocResolution(t *testing.T) {
+ cases := []struct {
+ name string
+ data map[string]any
+ spaceName string
+ want string
+ ok bool
+ }{
+ {"parser error", nil, "Docs", "", false},
+ {"zero", map[string]any{"wikiSpaces": []any{}}, "Docs", "", false},
+ {"unique exact", map[string]any{"wikiSpaces": []any{map[string]any{"workspaceId": "w1", "name": " docs "}, map[string]any{"workspaceId": "w2", "name": "Plan"}}}, "Docs", "w1", true},
+ {"unique fallback", map[string]any{"wikiSpaces": []any{map[string]any{"workspaceId": "w1", "name": "Docs Team"}}}, "Docs", "w1", true},
+ {"multiple", map[string]any{"wikiSpaces": []any{map[string]any{"workspaceId": "w1", "name": "Docs 1"}, map[string]any{"workspaceId": "w2", "name": "Docs 2"}}}, "Docs", "", false},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ got, err := wikiNewDocResolveSpaceID(tc.data, tc.spaceName)
+ if (err == nil) != tc.ok || got != tc.want {
+ t.Fatalf("got=%q err=%v want=%q ok=%v", got, err, tc.want, tc.ok)
+ }
+ })
+ }
+}
+
+func TestCrossPlatformCoverageWikiNewDocExecution(t *testing.T) {
+ if err := runShortcutErr(t, &stubMailboxCaller{}, "wiki", "+wiki-new-doc", "--space", " ", "--title", "Doc"); err == nil {
+ t.Fatal("blank space succeeded")
+ }
+ if err := runShortcutErr(t, &stubMailboxCaller{}, "wiki", "+wiki-new-doc", "--space", "Docs", "--title", " "); err == nil {
+ t.Fatal("blank title succeeded")
+ }
+
+ validSearch := `{"wikiSpaces":[{"workspaceId":"w","name":"Docs"}]}`
+ cases := []struct {
+ name string
+ fake *stubMailboxCaller
+ args []string
+ ok bool
+ }{
+ {"search error", &stubMailboxCaller{errTool: "search_wikiSpaces"}, nil, false},
+ {"resolve error", &stubMailboxCaller{byTool: map[string]string{"search_wikiSpaces": `{"wikiSpaces":[]}`}}, nil, false},
+ {"dry run", &stubMailboxCaller{byTool: map[string]string{"search_wikiSpaces": validSearch}}, []string{"--dry-run"}, true},
+ {"create error", &stubMailboxCaller{byTool: map[string]string{"search_wikiSpaces": validSearch}, errTool: "create_file"}, nil, false},
+ {"missing terminal success", &stubMailboxCaller{byTool: map[string]string{"search_wikiSpaces": validSearch, "create_file": `{"result":{"fileId":"n"}}`}}, nil, false},
+ {"missing created id", &stubMailboxCaller{byTool: map[string]string{"search_wikiSpaces": validSearch, "create_file": `{"success":true}`}}, nil, false},
+ {"readback error", &stubMailboxCaller{byTool: map[string]string{"search_wikiSpaces": validSearch, "create_file": `{"success":true,"fileId":"n"}`}, errTool: "get_document_info"}, nil, false},
+ {"readback failed", &stubMailboxCaller{byTool: map[string]string{"search_wikiSpaces": validSearch, "create_file": `{"success":true,"fileId":"n"}`, "get_document_info": `{"success":false}`}}, nil, false},
+ {"readback malformed success", &stubMailboxCaller{byTool: map[string]string{"search_wikiSpaces": validSearch, "create_file": `{"success":true,"fileId":"n"}`, "get_document_info": `{"success":"yes"}`}}, nil, false},
+ {"readback id mismatch", &stubMailboxCaller{byTool: map[string]string{"search_wikiSpaces": validSearch, "create_file": `{"success":true,"fileId":"n"}`, "get_document_info": `{"success":true,"nodeId":"other"}`}}, nil, false},
+ {"verified", &stubMailboxCaller{byTool: map[string]string{"search_wikiSpaces": validSearch, "create_file": `{"success":true,"data":{"fileId":"n"}}`, "get_document_info": `{"success":true,"result":{"nodeId":"n"}}`}}, nil, true},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ args := []string{"wiki", "+wiki-new-doc", "--space", "Docs", "--title", "Doc", "--format", "json"}
+ args = append(args, tc.args...)
+ err := runShortcutErr(t, tc.fake, args...)
+ if (err == nil) != tc.ok {
+ t.Fatalf("success=%v want %v; err=%v", err == nil, tc.ok, err)
+ }
+ })
+ }
+}
diff --git a/internal/shortcut/wiki/common.go b/internal/shortcut/wiki/common.go
index 1479315b..708aa21a 100644
--- a/internal/shortcut/wiki/common.go
+++ b/internal/shortcut/wiki/common.go
@@ -6,6 +6,7 @@ package wiki
import (
"encoding/json"
"fmt"
+ "strconv"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
@@ -177,6 +178,31 @@ func nestedWikiString(data map[string]any, keys ...string) string {
return ""
}
+func wikiStringInt(rt *shortcut.RuntimeContext, name string, fallback, min, max int) (int, error) {
+ raw := rt.Str(name)
+ if raw == "" {
+ return fallback, nil
+ }
+ value, err := strconv.Atoi(raw)
+ if err != nil || value < min || value > max {
+ return 0, fmt.Errorf("--%s 必须是 %d-%d 之间的整数", name, min, max)
+ }
+ return value, nil
+}
+
+func wikiStringSliceFirst(rt *shortcut.RuntimeContext, primary string, aliases ...string) []string {
+ if rt.Changed(primary) {
+ return rt.StrSlice(primary)
+ }
+ for _, alias := range aliases {
+ if rt.Changed(alias) {
+ values, _ := rt.Command().Flags().GetStringSlice(alias)
+ return values
+ }
+ }
+ return rt.StrSlice(primary)
+}
+
func wikiResponseError(operation, reason, message string) error {
return apperrors.NewAPI(message, apperrors.WithOperation(operation), apperrors.WithOrigin("mcp"), apperrors.WithFailureStage("response_validation"), apperrors.WithRetryable(false), apperrors.WithReason(reason))
}
@@ -218,14 +244,14 @@ type wikiPageFetcher func(cursor string, pageSize int) (map[string]any, error)
func collectWikiPages(rt *shortcut.RuntimeContext, operation string, pageSize int, keys []string, fetch wikiPageFetcher) ([]any, map[string]any, error) {
if !rt.Bool("page-all") {
- data, err := fetch(rt.Str("cursor"), pageSize)
+ data, err := fetch(rt.StrFirst("cursor", "page-token"), pageSize)
if err != nil {
return nil, nil, err
}
return requireWikiCollection(data, operation, keys...)
}
all := make([]any, 0)
- cursor := rt.Str("cursor")
+ cursor := rt.StrFirst("cursor", "page-token")
lastPage := map[string]any{}
for pageNumber := 1; pageNumber <= rt.Int("page-limit"); pageNumber++ {
requestSize := shortcut.AutoPageRequestSize(rt, pageSize, len(all))
diff --git a/internal/shortcut/wiki/wiki.go b/internal/shortcut/wiki/wiki.go
index 28012ba8..5bb417d3 100644
--- a/internal/shortcut/wiki/wiki.go
+++ b/internal/shortcut/wiki/wiki.go
@@ -30,9 +30,13 @@ func writeShortcut(command, description, intent string, risk shortcut.Risk, safe
var SpaceList = readShortcut("+space-list", "严格分页列出知识库", "浏览有权访问的组织或个人知识库,并保留服务端分页证据;只有显式 wikiSpaces:[] 才是空结果。", "spaces", []shortcut.Flag{
{Name: "type", Type: shortcut.FlagString, Default: "orgWikiSpace", Desc: "知识库类型", Enum: []string{"orgWikiSpace", "myWikiSpace"}},
- {Name: "limit", Type: shortcut.FlagInt, Default: "20", Desc: "每页数量 1-50"}, {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标", Aliases: []string{"page-token"}, AliasesVisible: true},
+ {Name: "limit", Type: shortcut.FlagString, Desc: "每页数量 1-50(默认 20)"}, {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标", Aliases: []string{"page-token"}, AliasesVisible: true},
}, []contract.ParamDecl{{Name: "type", Property: "wikiSpaceType"}, {Name: "limit", Property: "pageSize"}, {Name: "cursor", Property: "pageToken"}}, func(rt *shortcut.RuntimeContext) error {
- items, page, err := collectWikiPages(rt, "wiki/list_wikiSpaces", rt.Int("limit"), []string{"wikiSpaces", "spaces"}, func(cursor string, size int) (map[string]any, error) {
+ pageSize, err := wikiStringInt(rt, "limit", 20, 1, 50)
+ if err != nil {
+ return err
+ }
+ items, page, err := collectWikiPages(rt, "wiki/list_wikiSpaces", pageSize, []string{"wikiSpaces", "spaces"}, func(cursor string, size int) (map[string]any, error) {
params := map[string]any{"wikiSpaceType": rt.Str("type"), "pageSize": size}
if cursor != "" {
params["pageToken"] = cursor
@@ -49,9 +53,13 @@ var SpaceList = readShortcut("+space-list", "严格分页列出知识库", "浏
})
var SpaceSearch = readShortcut("+space-search", "严格搜索知识库", "按名称关键词定位知识库;严格验证搜索数组,避免内部异常被误报为零命中。", "spaces", []shortcut.Flag{
- {Name: "query", Type: shortcut.FlagString, Required: true, Desc: "搜索关键词"}, {Name: "limit", Type: shortcut.FlagInt, Default: "10", Desc: "返回数量 1-20"},
+ {Name: "query", Type: shortcut.FlagString, Required: true, Desc: "搜索关键词"}, {Name: "limit", Type: shortcut.FlagString, Desc: "返回数量 1-20(默认 10)"},
}, []contract.ParamDecl{{Name: "query", Property: "query"}, {Name: "limit", Property: "pageSize"}}, func(rt *shortcut.RuntimeContext) error {
- data, err := rt.CallMCPData("wiki", "search_wikiSpaces", map[string]any{"keyword": rt.Str("query"), "pageSize": rt.Int("limit")})
+ pageSize, err := wikiStringInt(rt, "limit", 10, 1, 20)
+ if err != nil {
+ return err
+ }
+ data, err := rt.CallMCPData("wiki", "search_wikiSpaces", map[string]any{"keyword": rt.Str("query"), "pageSize": pageSize})
if err != nil {
return err
}
@@ -154,7 +162,7 @@ func memberWrite(command, tool, description, intent string, withRole bool) short
params = append(params, contract.ParamDecl{Name: "role", Property: "roleId"})
}
return writeShortcut(command, description, intent, shortcut.RiskWrite, wikiWriteSafety(false), memberFlags(withRole), params, func(rt *shortcut.RuntimeContext) error {
- users := rt.StrSlice("users")
+ users := wikiStringSliceFirst(rt, "users", "user")
if len(users) == 0 || len(users) > 30 {
return fmt.Errorf("--users 必须包含 1-30 个 userId")
}
@@ -173,41 +181,21 @@ func memberWrite(command, tool, description, intent string, withRole bool) short
if err != nil {
return err
}
- listed, err := rt.CallMCPData("wiki", "list_member", map[string]any{"workspaceId": rt.Str("workspace"), "maxResults": 50})
- if err != nil {
- return err
- }
- items, _, err := requireWikiCollection(listed, "wiki/list_member", "members")
- if err != nil {
- return err
- }
- found := make(map[string]string)
- for _, item := range items {
- member := item.(map[string]any)
- id := firstWikiString(member, "id", "userId")
- if id != "" {
- found[id] = strings.ToUpper(firstWikiString(member, "role", "roleId"))
- }
- }
- for _, user := range users {
- role, present := found[user]
- if tool == "remove_member" && present {
- return wikiResponseError("wiki/"+tool, "readback_member_still_present", "成员移除后仍出现在成员列表中")
- }
- if tool != "remove_member" && !present {
- return wikiResponseError("wiki/"+tool, "readback_member_missing", "成员写入后未出现在成员列表中")
- }
- if withRole && role != strings.ToUpper(rt.Str("role")) {
- return wikiResponseError("wiki/"+tool, "readback_role_mismatch", "成员写入后读回角色与请求不一致")
- }
- }
- return rt.Output(map[string]any{"success": true, "workspaceId": rt.Str("workspace"), "userCount": len(users), "operation": tool, "verifiedBy": "member_list_readback"})
+ return rt.Output(map[string]any{
+ "success": true, "workspaceId": rt.Str("workspace"), "userCount": len(users), "operation": tool,
+ "verifiedBy": "write_terminal_success",
+ "verification": map[string]any{
+ "status": "terminal_response_only",
+ "readbackAvailable": false,
+ "reason": "member_list_is_capped_and_has_no_cursor",
+ },
+ })
})
}
-var MemberAdd = memberWrite("+member-add", "add_member", "添加知识库成员", "向知识库授予一个或多个用户容器级角色,并用成员列表读回验证响应契约。", true)
-var MemberUpdate = memberWrite("+member-update", "update_member", "更新知识库成员角色", "调整已有成员的知识库容器级角色,并用成员列表读回验证响应契约。", true)
-var MemberRemove = memberWrite("+member-remove", "remove_member", "移除知识库成员", "移除一个或多个用户的知识库容器级访问,并用成员列表读回验证响应契约。", false)
+var MemberAdd = memberWrite("+member-add", "add_member", "添加知识库成员", "向知识库授予一个或多个用户容器级角色;仅以写接口 success=true 作为终态证据,并明确成员列表无法完成精确读回。", true)
+var MemberUpdate = memberWrite("+member-update", "update_member", "更新知识库成员角色", "调整已有成员的知识库容器级角色;仅以写接口 success=true 作为终态证据,并明确成员列表无法完成精确读回。", true)
+var MemberRemove = memberWrite("+member-remove", "remove_member", "移除知识库成员", "移除一个或多个用户的知识库容器级访问;仅以写接口 success=true 作为终态证据,并明确成员列表无法完成精确读回。", false)
var MemberList = readShortcut("+member-list", "严格列出知识库成员", "列出知识库成员及角色;后端不提供可续游标且单次真实上限为 50,不伪造 page-all。", "members", []shortcut.Flag{{Name: "workspace", Type: shortcut.FlagString, Required: true, Desc: "知识库 ID 或 URL"}, {Name: "limit", Type: shortcut.FlagInt, Default: "30", Desc: "返回上限 1-50"}, {Name: "filter-role", Type: shortcut.FlagStringSlice, Desc: "角色过滤"}}, []contract.ParamDecl{{Name: "workspace", Property: "workspaceId"}, {Name: "limit", Property: "maxResults"}, {Name: "filter-role", Property: "filterRoleIds"}}, func(rt *shortcut.RuntimeContext) error {
if rt.Int("limit") < 1 || rt.Int("limit") > 50 {
diff --git a/internal/shortcut/wiki/workflow_coverage_test.go b/internal/shortcut/wiki/workflow_coverage_test.go
new file mode 100644
index 00000000..a1d2a8b0
--- /dev/null
+++ b/internal/shortcut/wiki/workflow_coverage_test.go
@@ -0,0 +1,638 @@
+// Copyright 2026 Alibaba Group
+// Licensed under the Apache License, Version 2.0
+
+package wiki
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "strings"
+ "testing"
+
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
+ "github.com/spf13/cobra"
+)
+
+type wikiCoverageCall struct {
+ product string
+ tool string
+ args map[string]any
+}
+
+type wikiCoverageCaller struct {
+ responses map[string][]string
+ errors map[string][]error
+ indexes map[string]int
+ calls []wikiCoverageCall
+ dryRun bool
+}
+
+func (c *wikiCoverageCaller) CallTool(_ context.Context, product, tool string, args map[string]any) (*edition.ToolResult, error) {
+ key := product + "/" + tool
+ c.calls = append(c.calls, wikiCoverageCall{product: product, tool: tool, args: args})
+ if c.indexes == nil {
+ c.indexes = map[string]int{}
+ }
+ index := c.indexes[key]
+ c.indexes[key]++
+ if sequence := c.errors[key]; index < len(sequence) && sequence[index] != nil {
+ return nil, sequence[index]
+ }
+ sequence := c.responses[key]
+ if len(sequence) == 0 {
+ return nil, fmt.Errorf("unexpected MCP call %s", key)
+ }
+ if index >= len(sequence) {
+ index = len(sequence) - 1
+ }
+ return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: sequence[index]}}}, nil
+}
+
+func (c *wikiCoverageCaller) Format() string { return "json" }
+func (c *wikiCoverageCaller) DryRun() bool { return c.dryRun }
+func (c *wikiCoverageCaller) Fields() string { return "" }
+func (c *wikiCoverageCaller) JQ() string { return "" }
+
+func runWikiCoverageCLI(t *testing.T, caller *wikiCoverageCaller, args ...string) (map[string]any, error) {
+ t.Helper()
+ helpers.InitDepsForTest(t, caller)
+ root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
+ root.PersistentFlags().Bool("yes", false, "")
+ root.PersistentFlags().Bool("dry-run", false, "")
+ root.PersistentFlags().String("format", "json", "")
+ root.PersistentFlags().String("jq", "", "")
+ root.PersistentFlags().String("fields", "", "")
+ ctx, _ := output.WithResultStore(context.Background())
+ root.SetContext(ctx)
+ root.AddCommand(shortcut.Commands()...)
+ stdout := &bytes.Buffer{}
+ root.SetOut(stdout)
+ root.SetErr(&bytes.Buffer{})
+ root.SetArgs(append([]string{"wiki"}, args...))
+ executed, err := root.ExecuteC()
+ if err == nil {
+ if _, _, emitErr := output.EmitStoredResult(executed); emitErr != nil {
+ err = emitErr
+ }
+ }
+ if stdout.Len() == 0 {
+ return nil, err
+ }
+ var payload map[string]any
+ if decodeErr := json.Unmarshal(stdout.Bytes(), &payload); decodeErr != nil {
+ t.Fatalf("decode output %q: %v", stdout.String(), decodeErr)
+ }
+ if data, ok := payload["data"].(map[string]any); ok {
+ return data, err
+ }
+ return payload, err
+}
+
+func TestCrossPlatformCoverageWikiSpaceWorkflows(t *testing.T) {
+ t.Run("list default string limit", func(t *testing.T) {
+ caller := &wikiCoverageCaller{responses: map[string][]string{
+ "wiki/list_wikiSpaces": {`{"success":true,"result":{"wikiSpaces":[{"workspaceId":"w1","name":"Docs"}],"hasMore":false}}`},
+ }}
+ out, err := runWikiCoverageCLI(t, caller, "+space-list")
+ if err != nil || out["count"] != float64(1) || caller.calls[0].args["pageSize"] != 20 {
+ t.Fatalf("list output=%#v err=%v calls=%#v", out, err, caller.calls)
+ }
+ })
+
+ t.Run("list explicit legacy string limit and cursor", func(t *testing.T) {
+ caller := &wikiCoverageCaller{responses: map[string][]string{
+ "wiki/list_wikiSpaces": {`{"wikiSpaces":[],"hasMore":false}`},
+ }}
+ out, err := runWikiCoverageCLI(t, caller, "+space-list", "--type", "myWikiSpace", "--limit", "7", "--page-token", "next")
+ if err != nil || out["count"] != float64(0) || caller.calls[0].args["pageSize"] != 7 || caller.calls[0].args["pageToken"] != "next" {
+ t.Fatalf("list output=%#v err=%v calls=%#v", out, err, caller.calls)
+ }
+ })
+
+ for _, value := range []string{"bad", "0", "51"} {
+ t.Run("list rejects "+value, func(t *testing.T) {
+ caller := &wikiCoverageCaller{}
+ if _, err := runWikiCoverageCLI(t, caller, "+space-list", "--limit", value); err == nil || len(caller.calls) != 0 {
+ t.Fatalf("limit %q err=%v calls=%#v", value, err, caller.calls)
+ }
+ })
+ }
+
+ t.Run("search parses string limit", func(t *testing.T) {
+ caller := &wikiCoverageCaller{responses: map[string][]string{
+ "wiki/search_wikiSpaces": {`{"success":true,"wikiSpaces":[{"spaceId":"w2","spaceName":"Plan"}]}`},
+ }}
+ out, err := runWikiCoverageCLI(t, caller, "+space-search", "--query", "Plan", "--limit", "12")
+ if err != nil || out["count"] != float64(1) || caller.calls[0].args["pageSize"] != 12 {
+ t.Fatalf("search output=%#v err=%v calls=%#v", out, err, caller.calls)
+ }
+ })
+
+ t.Run("get requires business id", func(t *testing.T) {
+ caller := &wikiCoverageCaller{responses: map[string][]string{"wiki/get_wikiSpace": {`{"success":true,"result":{"name":"missing id"}}`}}}
+ if _, err := runWikiCoverageCLI(t, caller, "+space-get", "--workspace", "w"); err == nil {
+ t.Fatal("space without id succeeded")
+ }
+ })
+
+ t.Run("create dry-run", func(t *testing.T) {
+ caller := &wikiCoverageCaller{}
+ out, err := runWikiCoverageCLI(t, caller, "+space-create", "--name", "Docs", "--desc", "D", "--icon", "I", "--dry-run")
+ if err != nil || out["executed"] != false || len(caller.calls) != 0 {
+ t.Fatalf("dry-run output=%#v err=%v calls=%#v", out, err, caller.calls)
+ }
+ })
+
+ t.Run("create and exact readback", func(t *testing.T) {
+ caller := &wikiCoverageCaller{responses: map[string][]string{
+ "wiki/create_wikiSpace": {`{"success":true,"result":{"workspaceId":"w3"}}`},
+ "wiki/get_wikiSpace": {`{"success":true,"data":{"workspaceId":"w3","name":"Docs"}}`},
+ }}
+ out, err := runWikiCoverageCLI(t, caller, "+space-create", "--name", "Docs")
+ if err != nil || out["workspaceId"] != "w3" || len(caller.calls) != 2 {
+ t.Fatalf("create output=%#v err=%v calls=%#v", out, err, caller.calls)
+ }
+ })
+
+ t.Run("delete preflight dry-run and terminal", func(t *testing.T) {
+ dry := &wikiCoverageCaller{responses: map[string][]string{"wiki/get_wikiSpace": {`{"workspaceId":"w4","name":"Docs"}`}}}
+ out, err := runWikiCoverageCLI(t, dry, "+delete-space", "--workspace", "w4", "--dry-run", "--yes")
+ if err != nil || out["executed"] != false || len(dry.calls) != 1 {
+ t.Fatalf("delete dry-run output=%#v err=%v calls=%#v", out, err, dry.calls)
+ }
+ caller := &wikiCoverageCaller{responses: map[string][]string{
+ "wiki/get_wikiSpace": {`{"workspaceId":"w4","name":"Docs"}`},
+ "wiki/delete_wikiSpace": {`{"success":true}`},
+ }}
+ out, err = runWikiCoverageCLI(t, caller, "+space-delete", "--workspace", "w4", "--yes")
+ if err != nil || out["deleted"] != true || len(caller.calls) != 2 {
+ t.Fatalf("delete output=%#v err=%v calls=%#v", out, err, caller.calls)
+ }
+ })
+
+ for _, args := range [][]string{{"--name", strings.Repeat("界", 33)}, {"--name", "x", "--desc", strings.Repeat("界", 501)}} {
+ caller := &wikiCoverageCaller{}
+ if _, err := runWikiCoverageCLI(t, caller, append([]string{"+space-create"}, args...)...); err == nil || len(caller.calls) != 0 {
+ t.Fatalf("invalid create args succeeded: %v", args)
+ }
+ }
+}
+
+func TestCrossPlatformCoverageWikiMemberWritesUseTerminalEvidenceOnly(t *testing.T) {
+ for _, tc := range []struct {
+ command string
+ tool string
+ extra []string
+ }{
+ {command: "+member-add", tool: "add_member", extra: []string{"--role", "READER"}},
+ {command: "+member-update", tool: "update_member", extra: []string{"--role", "EDITOR"}},
+ {command: "+member-remove", tool: "remove_member"},
+ } {
+ t.Run(tc.command, func(t *testing.T) {
+ caller := &wikiCoverageCaller{responses: map[string][]string{"wiki/" + tc.tool: {`{"success":true}`}}}
+ args := append([]string{tc.command, "--workspace", "w", "--users", "u1,u2"}, tc.extra...)
+ out, err := runWikiCoverageCLI(t, caller, args...)
+ verification, _ := out["verification"].(map[string]any)
+ if err != nil || out["verifiedBy"] != "write_terminal_success" || verification["readbackAvailable"] != false || len(caller.calls) != 1 {
+ t.Fatalf("member output=%#v err=%v calls=%#v", out, err, caller.calls)
+ }
+ if caller.calls[0].tool == "list_member" {
+ t.Fatal("member write used capped list as readback")
+ }
+ if tc.tool != "remove_member" && caller.calls[0].args["roleId"] != strings.ToUpper(tc.extra[1]) {
+ t.Fatalf("role was not normalized: %#v", caller.calls[0].args)
+ }
+ })
+ }
+
+ t.Run("dry-run has no write", func(t *testing.T) {
+ caller := &wikiCoverageCaller{}
+ out, err := runWikiCoverageCLI(t, caller, "+member-add", "--workspace", "w", "--users", "u", "--role", "READER", "--dry-run")
+ if err != nil || out["executed"] != false || len(caller.calls) != 0 {
+ t.Fatalf("dry-run output=%#v err=%v calls=%#v", out, err, caller.calls)
+ }
+ })
+
+ t.Run("rejects missing terminal evidence", func(t *testing.T) {
+ caller := &wikiCoverageCaller{responses: map[string][]string{"wiki/add_member": {`{"result":{"accepted":true}}`}}}
+ if _, err := runWikiCoverageCLI(t, caller, "+member-add", "--workspace", "w", "--users", "u", "--role", "READER"); err == nil {
+ t.Fatal("member write without success=true succeeded")
+ }
+ })
+
+ many := make([]string, 31)
+ for index := range many {
+ many[index] = fmt.Sprintf("u%d", index)
+ }
+ caller := &wikiCoverageCaller{}
+ if _, err := runWikiCoverageCLI(t, caller, "+member-remove", "--workspace", "w", "--users", strings.Join(many, ",")); err == nil || len(caller.calls) != 0 {
+ t.Fatal("more than 30 members reached MCP")
+ }
+}
+
+func TestCrossPlatformCoverageWikiMemberListContracts(t *testing.T) {
+ caller := &wikiCoverageCaller{responses: map[string][]string{
+ "wiki/list_member": {`{"success":true,"members":[{"userId":"u1","nick":"A","roleId":"READER","type":"USER","outer":false}],"truncated":false}`},
+ }}
+ out, err := runWikiCoverageCLI(t, caller, "+member-list", "--workspace", "w", "--limit", "50", "--filter-role", "READER,EDITOR")
+ if err != nil || out["count"] != float64(1) || caller.calls[0].args["maxResults"] != 50 {
+ t.Fatalf("member list output=%#v err=%v calls=%#v", out, err, caller.calls)
+ }
+ for _, value := range []string{"0", "51"} {
+ caller = &wikiCoverageCaller{}
+ if _, err := runWikiCoverageCLI(t, caller, "+member-list", "--workspace", "w", "--limit", value); err == nil || len(caller.calls) != 0 {
+ t.Fatalf("invalid member limit %s reached MCP", value)
+ }
+ }
+}
+
+func TestCrossPlatformCoverageWikiReadWorkflows(t *testing.T) {
+ cases := []struct {
+ name string
+ args []string
+ responses map[string][]string
+ wantTool string
+ wantCount float64
+ }{
+ {name: "node list", args: []string{"+node-list", "--workspace", "w", "--folder", "f", "--limit", "2", "--cursor", "c"}, responses: map[string][]string{"doc/list_nodes": {`{"success":true,"nodes":[{"id":"n","title":"Doc","parentId":"f","spaceId":"w"}],"hasMore":false}`}}, wantTool: "list_nodes", wantCount: 1},
+ {name: "node search", args: []string{"+node-search", "--workspace", "w", "--query", "Doc", "--extensions", "adoc", "--limit", "2", "--cursor", "c"}, responses: map[string][]string{"doc/search_documents": {`{"success":true,"documents":[],"hasMore":false}`}}, wantTool: "search_documents", wantCount: 0},
+ {name: "feed list", args: []string{"+feed-list", "--workspace", "w", "--limit", "2", "--cursor", "c", "--exclude-file"}, responses: map[string][]string{"wiki/list_workspace_feeds": {`{"success":true,"feeds":[{"feedId":"x","feedType":"update","fileId":"n"}],"hasMore":false}`}}, wantTool: "list_workspace_feeds", wantCount: 1},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ caller := &wikiCoverageCaller{responses: tc.responses}
+ out, err := runWikiCoverageCLI(t, caller, tc.args...)
+ if err != nil || out["count"] != tc.wantCount || caller.calls[0].tool != tc.wantTool {
+ t.Fatalf("output=%#v err=%v calls=%#v", out, err, caller.calls)
+ }
+ })
+ }
+
+ t.Run("node get", func(t *testing.T) {
+ caller := &wikiCoverageCaller{responses: map[string][]string{"doc/get_document_info": {`{"success":true,"result":{"nodeId":"n","title":"Doc"}}`}}}
+ out, err := runWikiCoverageCLI(t, caller, "+node-get", "--node", "n")
+ if err != nil || out["nodeId"] != "n" {
+ t.Fatalf("node get output=%#v err=%v", out, err)
+ }
+ })
+}
+
+func TestCrossPlatformCoverageWikiWriteWorkflows(t *testing.T) {
+ t.Run("node create dry and verified", func(t *testing.T) {
+ dry := &wikiCoverageCaller{}
+ out, err := runWikiCoverageCLI(t, dry, "+node-create", "--workspace", "w", "--folder", "f", "--name", "Doc", "--type", "adoc", "--dry-run")
+ if err != nil || out["executed"] != false || len(dry.calls) != 0 {
+ t.Fatalf("create dry output=%#v err=%v calls=%#v", out, err, dry.calls)
+ }
+ caller := &wikiCoverageCaller{responses: map[string][]string{
+ "doc/create_file": {`{"success":true,"data":{"fileId":"n"}}`},
+ "doc/get_document_info": {`{"success":true,"nodeId":"n","workspaceId":"w","folderId":"f"}`},
+ }}
+ out, err = runWikiCoverageCLI(t, caller, "+node-create", "--workspace", "w", "--folder", "f", "--name", "Doc")
+ if err != nil || out["nodeId"] != "n" || len(caller.calls) != 2 {
+ t.Fatalf("create output=%#v err=%v calls=%#v", out, err, caller.calls)
+ }
+ })
+
+ t.Run("node copy dry and verified", func(t *testing.T) {
+ dry := &wikiCoverageCaller{}
+ out, err := runWikiCoverageCLI(t, dry, "+node-copy", "--workspace", "w", "--folder", "f", "--node", "source", "--dry-run", "--yes")
+ if err != nil || out["executed"] != false || len(dry.calls) != 0 {
+ t.Fatalf("copy dry output=%#v err=%v calls=%#v", out, err, dry.calls)
+ }
+ caller := &wikiCoverageCaller{responses: map[string][]string{
+ "doc/copy_document": {`{"success":true,"nodeId":"copy"}`},
+ "doc/get_document_info": {`{"success":true,"nodeId":"copy","workspaceId":"w"}`},
+ }}
+ out, err = runWikiCoverageCLI(t, caller, "+node-copy", "--workspace", "w", "--node", "source", "--yes")
+ if err != nil || out["nodeId"] != "copy" || len(caller.calls) != 2 {
+ t.Fatalf("copy output=%#v err=%v calls=%#v", out, err, caller.calls)
+ }
+ })
+
+ t.Run("move within wiki and to drive", func(t *testing.T) {
+ caller := &wikiCoverageCaller{responses: map[string][]string{
+ "doc/get_document_info": {`{"nodeId":"n","workspaceId":"old","folderId":"old-f"}`, `{"nodeId":"n","workspaceId":"target","folderId":"f"}`},
+ "doc/move_document": {`{"success":true}`},
+ }}
+ out, err := runWikiCoverageCLI(t, caller, "+move", "--workspace", "target", "--folder", "f", "--node", "n", "--yes")
+ if err != nil || out["nodeId"] != "n" || len(caller.calls) != 3 {
+ t.Fatalf("move output=%#v err=%v calls=%#v", out, err, caller.calls)
+ }
+ caller = &wikiCoverageCaller{responses: map[string][]string{
+ "doc/get_document_info": {`{"nodeId":"n","workspaceId":"old"}`, `{"nodeId":"n","workspaceId":"drive"}`},
+ "doc/move_document": {`{"success":true}`},
+ }}
+ out, err = runWikiCoverageCLI(t, caller, "+move-to-drive", "--node", "n", "--yes")
+ if err != nil || out["nodeId"] != "n" {
+ t.Fatalf("move-to-drive output=%#v err=%v calls=%#v", out, err, caller.calls)
+ }
+ })
+
+ t.Run("move dry-run only preflights", func(t *testing.T) {
+ caller := &wikiCoverageCaller{responses: map[string][]string{"doc/get_document_info": {`{"nodeId":"n","workspaceId":"old"}`}}}
+ out, err := runWikiCoverageCLI(t, caller, "+move", "--workspace", "target", "--node", "n", "--dry-run", "--yes")
+ if err != nil || out["executed"] != false || len(caller.calls) != 1 {
+ t.Fatalf("move dry output=%#v err=%v calls=%#v", out, err, caller.calls)
+ }
+ })
+
+ t.Run("node delete dry and terminal", func(t *testing.T) {
+ dry := &wikiCoverageCaller{responses: map[string][]string{"doc/get_document_info": {`{"nodeId":"n","workspaceId":"w"}`}}}
+ out, err := runWikiCoverageCLI(t, dry, "+node-delete", "--workspace", "w", "--node", "n", "--dry-run", "--yes")
+ if err != nil || out["executed"] != false || len(dry.calls) != 1 {
+ t.Fatalf("delete dry output=%#v err=%v calls=%#v", out, err, dry.calls)
+ }
+ caller := &wikiCoverageCaller{responses: map[string][]string{
+ "doc/get_document_info": {`{"nodeId":"n","workspaceId":"w"}`},
+ "doc/delete_document": {`{"success":true}`},
+ }}
+ out, err = runWikiCoverageCLI(t, caller, "+node-delete", "--workspace", "w", "--node", "n", "--yes")
+ if err != nil || out["deleted"] != true || len(caller.calls) != 2 {
+ t.Fatalf("delete output=%#v err=%v calls=%#v", out, err, caller.calls)
+ }
+ })
+}
+
+func TestCrossPlatformCoverageWikiResponseValidationBranches(t *testing.T) {
+ if _, err := requireWikiResponse(nil, "op"); err == nil {
+ t.Fatal("empty response accepted")
+ }
+ for _, data := range []map[string]any{{"success": "yes"}, {"success": false}, {"success": false, "message": "denied"}} {
+ if _, err := requireWikiResponse(data, "op"); err == nil {
+ t.Fatalf("invalid response accepted: %#v", data)
+ }
+ }
+ if _, err := requireWikiWrite(map[string]any{"result": map[string]any{"id": "x"}}, "op"); err == nil {
+ t.Fatal("write without terminal success accepted")
+ }
+ if _, err := requireWikiWrite(map[string]any{"success": false}, "op"); err == nil {
+ t.Fatal("failed write response accepted")
+ }
+ for _, data := range []map[string]any{
+ {"success": false}, {"result": "bad"}, {"result": map[string]any{}}, {"only": true},
+ } {
+ if _, err := requireWikiObject(data, "op"); err == nil {
+ t.Fatalf("invalid object accepted: %#v", data)
+ }
+ }
+ if object, err := requireWikiObject(map[string]any{"success": true, "id": "x"}, "op"); err != nil || object["id"] != "x" {
+ t.Fatalf("direct object=%#v err=%v", object, err)
+ }
+ for _, data := range []map[string]any{
+ {"success": false}, {"result": "bad"}, {"result": map[string]any{"items": "bad"}}, {"items": []any{1}}, {"success": true},
+ } {
+ if _, _, err := requireWikiCollection(data, "op", "items"); err == nil {
+ t.Fatalf("invalid collection accepted: %#v", data)
+ }
+ }
+ if value := nestedWikiString(map[string]any{"data": map[string]any{"id": " nested "}}, "id"); value != "nested" {
+ t.Fatalf("nested string=%q", value)
+ }
+ if value := nestedWikiString(map[string]any{"id": " direct "}, "id"); value != "direct" {
+ t.Fatalf("direct string=%q", value)
+ }
+ if value := nestedWikiString(map[string]any{"id": 1}, "id"); value != "" {
+ t.Fatalf("non-string=%q", value)
+ }
+
+ page := map[string]any{"nextToken": "n", "hasMore": true, "truncated": false, "totalCount": 1, "autoPageComplete": true, "autoPageStopReason": "done", "pagesFetched": 2}
+ out := map[string]any{}
+ addWikiPagination(out, page)
+ for _, key := range []string{"nextCursor", "hasMore", "truncated", "totalCount", "autoPageComplete", "autoPageStopReason", "pagesFetched"} {
+ if _, ok := out[key]; !ok {
+ t.Fatalf("pagination output missing %s: %#v", key, out)
+ }
+ }
+ rows := projectWikiRows([]any{map[string]any{"legacy": "x", "empty": nil}}, map[string][]string{"value": {"empty", "legacy"}})
+ if len(rows) != 1 || rows[0]["value"] != "x" {
+ t.Fatalf("projected rows=%#v", rows)
+ }
+}
+
+func TestCrossPlatformCoverageWikiAliasAndCancellationBranches(t *testing.T) {
+ caller := &wikiCoverageCaller{responses: map[string][]string{"wiki/remove_member": {`{"success":true}`}}}
+ out, err := runWikiCoverageCLI(t, caller, "+member-remove", "--workspace", "w", "--user", "u1")
+ if err != nil || out["userCount"] != float64(1) {
+ t.Fatalf("visible member alias output=%#v err=%v calls=%#v", out, err, caller.calls)
+ }
+
+ cmd := &cobra.Command{Use: "probe"}
+ cmd.Flags().StringSlice("users", nil, "")
+ cmd.Flags().StringSlice("user", nil, "")
+ rt := shortcut.RuntimeContextForTest(cmd, MemberRemove)
+ if got := wikiStringSliceFirst(rt, "users", "user"); len(got) != 0 {
+ t.Fatalf("unset string slice=%v", got)
+ }
+
+ pageCmd := &cobra.Command{Use: "page"}
+ pageCmd.Flags().Bool("page-all", true, "")
+ pageCmd.Flags().Int("page-limit", 2, "")
+ pageCmd.Flags().Int("max-items", 0, "")
+ pageCmd.Flags().Int("page-delay", 1, "")
+ pageCmd.Flags().String("cursor", "", "")
+ pageCmd.Flags().String("page-token", "", "")
+ cancelled, cancel := context.WithCancel(context.Background())
+ cancel()
+ pageCmd.SetContext(cancelled)
+ pageRT := shortcut.RuntimeContextForTest(pageCmd, SpaceList)
+ _, _, err = collectWikiPages(pageRT, "probe", 1, []string{"items"}, func(string, int) (map[string]any, error) {
+ return map[string]any{"items": []any{}, "hasMore": true, "nextCursor": "next"}, nil
+ })
+ if !errors.Is(err, context.Canceled) {
+ t.Fatalf("cancelled page delay err=%v", err)
+ }
+}
+
+func TestCrossPlatformCoverageWikiPaginationFailureModes(t *testing.T) {
+ t.Run("two pages", func(t *testing.T) {
+ caller := &wikiCoverageCaller{responses: map[string][]string{
+ "wiki/list_wikiSpaces": {
+ `{"wikiSpaces":[{"workspaceId":"w1"}],"hasMore":true,"nextCursor":"next"}`,
+ `{"wikiSpaces":[{"workspaceId":"w2"}],"hasMore":false}`,
+ },
+ }}
+ out, err := runWikiCoverageCLI(t, caller, "+space-list", "--limit", "1", "--page-all", "--page-limit", "2")
+ if err != nil || out["count"] != float64(2) || out["autoPageComplete"] != true {
+ t.Fatalf("pagination output=%#v err=%v calls=%#v", out, err, caller.calls)
+ }
+ })
+
+ t.Run("max items", func(t *testing.T) {
+ caller := &wikiCoverageCaller{responses: map[string][]string{
+ "wiki/list_wikiSpaces": {`{"wikiSpaces":[{"workspaceId":"w1"}],"hasMore":true,"nextCursor":"next"}`},
+ }}
+ out, err := runWikiCoverageCLI(t, caller, "+space-list", "--limit", "2", "--page-all", "--max-items", "1")
+ if err != nil || out["autoPageStopReason"] != "max_items" {
+ t.Fatalf("max-items output=%#v err=%v", out, err)
+ }
+ })
+
+ cases := []struct {
+ name string
+ response string
+ args []string
+ }{
+ {name: "missing has more", response: `{"wikiSpaces":[]}`, args: []string{"--page-all"}},
+ {name: "malformed has more", response: `{"wikiSpaces":[],"hasMore":"yes"}`, args: []string{"--page-all"}},
+ {name: "missing cursor", response: `{"wikiSpaces":[],"hasMore":true}`, args: []string{"--page-all"}},
+ {name: "stalled cursor", response: `{"wikiSpaces":[],"hasMore":true,"nextCursor":"same"}`, args: []string{"--cursor", "same", "--page-all"}},
+ {name: "page limit", response: `{"wikiSpaces":[],"hasMore":true,"nextCursor":"next"}`, args: []string{"--page-all", "--page-limit", "1"}},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ caller := &wikiCoverageCaller{responses: map[string][]string{"wiki/list_wikiSpaces": {tc.response}}}
+ if _, err := runWikiCoverageCLI(t, caller, append([]string{"+space-list"}, tc.args...)...); err == nil {
+ t.Fatalf("pagination failure %s succeeded", tc.name)
+ }
+ })
+ }
+
+ for _, args := range [][]string{{"--page-limit", "0", "--page-all"}, {"--max-items", "1"}, {"--page-delay", "1"}} {
+ caller := &wikiCoverageCaller{}
+ if _, err := runWikiCoverageCLI(t, caller, append([]string{"+space-list"}, args...)...); err == nil || len(caller.calls) != 0 {
+ t.Fatalf("invalid auto-page controls succeeded: %v", args)
+ }
+ }
+}
+
+func TestCrossPlatformCoverageWikiMCPFailuresPropagate(t *testing.T) {
+ caller := &wikiCoverageCaller{
+ responses: map[string][]string{"wiki/list_wikiSpaces": {`{"wikiSpaces":[]}`}},
+ errors: map[string][]error{"wiki/list_wikiSpaces": {errors.New("read failed")}},
+ }
+ if _, err := runWikiCoverageCLI(t, caller, "+space-list"); err == nil {
+ t.Fatal("MCP failure was swallowed")
+ }
+}
+
+func TestCrossPlatformCoverageWikiSpaceFailureBranches(t *testing.T) {
+ assertErr := func(name string, caller *wikiCoverageCaller, args ...string) {
+ t.Helper()
+ t.Run(name, func(t *testing.T) {
+ if _, err := runWikiCoverageCLI(t, caller, args...); err == nil {
+ t.Fatalf("%s unexpectedly succeeded; calls=%#v", name, caller.calls)
+ }
+ })
+ }
+ backend := errors.New("backend failed")
+ assertErr("search invalid limit", &wikiCoverageCaller{}, "+space-search", "--query", "x", "--limit", "bad")
+ assertErr("search call", &wikiCoverageCaller{errors: map[string][]error{"wiki/search_wikiSpaces": {backend}}}, "+space-search", "--query", "x")
+ assertErr("search collection", &wikiCoverageCaller{responses: map[string][]string{"wiki/search_wikiSpaces": {`{"success":true}`}}}, "+space-search", "--query", "x")
+ assertErr("get call", &wikiCoverageCaller{errors: map[string][]error{"wiki/get_wikiSpace": {backend}}}, "+space-get", "--workspace", "w")
+ assertErr("get object", &wikiCoverageCaller{responses: map[string][]string{"wiki/get_wikiSpace": {`{"success":true}`}}}, "+space-get", "--workspace", "w")
+ caller := &wikiCoverageCaller{responses: map[string][]string{"wiki/get_wikiSpace": {`{"workspaceId":"w","name":"Docs"}`}}}
+ if out, err := runWikiCoverageCLI(t, caller, "+space-get", "--workspace", "w"); err != nil || out["workspaceId"] != "w" {
+ t.Fatalf("space get output=%#v err=%v", out, err)
+ }
+
+ assertErr("create write call", &wikiCoverageCaller{errors: map[string][]error{"wiki/create_wikiSpace": {backend}}}, "+space-create", "--name", "Docs")
+ assertErr("create terminal", &wikiCoverageCaller{responses: map[string][]string{"wiki/create_wikiSpace": {`{"result":{"workspaceId":"w"}}`}}}, "+space-create", "--name", "Docs")
+ assertErr("create id", &wikiCoverageCaller{responses: map[string][]string{"wiki/create_wikiSpace": {`{"success":true}`}}}, "+space-create", "--name", "Docs")
+ assertErr("create readback call", &wikiCoverageCaller{responses: map[string][]string{"wiki/create_wikiSpace": {`{"success":true,"workspaceId":"w"}`}}, errors: map[string][]error{"wiki/get_wikiSpace": {backend}}}, "+space-create", "--name", "Docs")
+ assertErr("create readback object", &wikiCoverageCaller{responses: map[string][]string{"wiki/create_wikiSpace": {`{"success":true,"workspaceId":"w"}`}, "wiki/get_wikiSpace": {`{"success":true}`}}}, "+space-create", "--name", "Docs")
+ assertErr("create readback mismatch", &wikiCoverageCaller{responses: map[string][]string{"wiki/create_wikiSpace": {`{"success":true,"workspaceId":"w"}`}, "wiki/get_wikiSpace": {`{"workspaceId":"other"}`}}}, "+space-create", "--name", "Docs")
+ spaceMismatch := &wikiCoverageCaller{responses: map[string][]string{"wiki/create_wikiSpace": {`{"success":true,"result":{"workspaceId":"w"}}`}, "wiki/get_wikiSpace": {`{"success":true,"data":{"workspaceId":"other"}}`}}}
+ if _, err := runWikiCoverageCLI(t, spaceMismatch, "+space-create", "--name", "Docs"); err == nil || !strings.Contains(err.Error(), "不一致") {
+ t.Fatalf("space create mismatch error=%v calls=%#v", err, spaceMismatch.calls)
+ }
+
+ assertErr("delete preflight call", &wikiCoverageCaller{errors: map[string][]error{"wiki/get_wikiSpace": {backend}}}, "+delete-space", "--workspace", "w", "--yes")
+ assertErr("delete preflight object", &wikiCoverageCaller{responses: map[string][]string{"wiki/get_wikiSpace": {`{"success":true}`}}}, "+delete-space", "--workspace", "w", "--yes")
+ assertErr("delete write call", &wikiCoverageCaller{responses: map[string][]string{"wiki/get_wikiSpace": {`{"workspaceId":"w"}`}}, errors: map[string][]error{"wiki/delete_wikiSpace": {backend}}}, "+delete-space", "--workspace", "w", "--yes")
+ assertErr("delete terminal", &wikiCoverageCaller{responses: map[string][]string{"wiki/get_wikiSpace": {`{"workspaceId":"w"}`}, "wiki/delete_wikiSpace": {`{"result":{}}`}}}, "+delete-space", "--workspace", "w", "--yes")
+ delWrite := &wikiCoverageCaller{responses: map[string][]string{"wiki/get_wikiSpace": {`{"success":true,"result":{"workspaceId":"w"}}`}}, errors: map[string][]error{"wiki/delete_wikiSpace": {backend}}}
+ if _, err := runWikiCoverageCLI(t, delWrite, "+delete-space", "--workspace", "w", "--yes"); err == nil || !strings.Contains(err.Error(), "backend failed") {
+ t.Fatalf("delete write error=%v calls=%#v", err, delWrite.calls)
+ }
+ delTerminal := &wikiCoverageCaller{responses: map[string][]string{"wiki/get_wikiSpace": {`{"success":true,"result":{"workspaceId":"w"}}`}, "wiki/delete_wikiSpace": {`{"result":{}}`}}}
+ if _, err := runWikiCoverageCLI(t, delTerminal, "+delete-space", "--workspace", "w", "--yes"); err == nil || !strings.Contains(err.Error(), "success=true") {
+ t.Fatalf("delete terminal error=%v calls=%#v", err, delTerminal.calls)
+ }
+
+ assertErr("member list call", &wikiCoverageCaller{errors: map[string][]error{"wiki/list_member": {backend}}}, "+member-list", "--workspace", "w")
+ assertErr("member list collection", &wikiCoverageCaller{responses: map[string][]string{"wiki/list_member": {`{"success":true}`}}}, "+member-list", "--workspace", "w")
+ assertErr("member write call", &wikiCoverageCaller{errors: map[string][]error{"wiki/add_member": {backend}}}, "+member-add", "--workspace", "w", "--users", "u", "--role", "READER")
+}
+
+func TestCrossPlatformCoverageWikiNodeFailureBranches(t *testing.T) {
+ assertErr := func(name string, caller *wikiCoverageCaller, args ...string) {
+ t.Helper()
+ t.Run(name, func(t *testing.T) {
+ if _, err := runWikiCoverageCLI(t, caller, args...); err == nil {
+ t.Fatalf("%s unexpectedly succeeded; calls=%#v", name, caller.calls)
+ }
+ })
+ }
+ backend := errors.New("backend failed")
+ assertErr("list call", &wikiCoverageCaller{errors: map[string][]error{"doc/list_nodes": {backend}}}, "+node-list", "--workspace", "w")
+ assertErr("list collection", &wikiCoverageCaller{responses: map[string][]string{"doc/list_nodes": {`{"success":true}`}}}, "+node-list", "--workspace", "w")
+ assertErr("get call", &wikiCoverageCaller{errors: map[string][]error{"doc/get_document_info": {backend}}}, "+node-get", "--node", "n")
+ assertErr("get object", &wikiCoverageCaller{responses: map[string][]string{"doc/get_document_info": {`{"success":true}`}}}, "+node-get", "--node", "n")
+ assertErr("get id", &wikiCoverageCaller{responses: map[string][]string{"doc/get_document_info": {`{"name":"Doc"}`}}}, "+node-get", "--node", "n")
+ missingID := &wikiCoverageCaller{responses: map[string][]string{"doc/get_document_info": {`{"success":true,"result":{"name":"Doc"}}`}}}
+ if _, err := runWikiCoverageCLI(t, missingID, "+node-get", "--node", "n"); err == nil || !strings.Contains(err.Error(), "nodeId") {
+ t.Fatalf("missing node id error=%v calls=%#v", err, missingID.calls)
+ }
+ assertErr("search call", &wikiCoverageCaller{errors: map[string][]error{"doc/search_documents": {backend}}}, "+node-search", "--workspace", "w", "--query", "x")
+ assertErr("search collection", &wikiCoverageCaller{responses: map[string][]string{"doc/search_documents": {`{"success":true}`}}}, "+node-search", "--workspace", "w", "--query", "x")
+
+ createArgs := []string{"+node-create", "--workspace", "w", "--name", "Doc"}
+ assertErr("create write", &wikiCoverageCaller{errors: map[string][]error{"doc/create_file": {backend}}}, createArgs...)
+ assertErr("create terminal", &wikiCoverageCaller{responses: map[string][]string{"doc/create_file": {`{"result":{"fileId":"n"}}`}}}, createArgs...)
+ assertErr("create id", &wikiCoverageCaller{responses: map[string][]string{"doc/create_file": {`{"success":true}`}}}, createArgs...)
+ assertErr("create readback call", &wikiCoverageCaller{responses: map[string][]string{"doc/create_file": {`{"success":true,"fileId":"n"}`}}, errors: map[string][]error{"doc/get_document_info": {backend}}}, createArgs...)
+ assertErr("create readback object", &wikiCoverageCaller{responses: map[string][]string{"doc/create_file": {`{"success":true,"fileId":"n"}`}, "doc/get_document_info": {`{"success":true}`}}}, createArgs...)
+ assertErr("create mismatch", &wikiCoverageCaller{responses: map[string][]string{"doc/create_file": {`{"success":true,"fileId":"n"}`}, "doc/get_document_info": {`{"nodeId":"other"}`}}}, createArgs...)
+ createMismatch := &wikiCoverageCaller{responses: map[string][]string{"doc/create_file": {`{"success":true,"result":{"fileId":"n"}}`}, "doc/get_document_info": {`{"success":true,"result":{"nodeId":"other"}}`}}}
+ if _, err := runWikiCoverageCLI(t, createMismatch, createArgs...); err == nil || !strings.Contains(err.Error(), "不一致") {
+ t.Fatalf("create mismatch error=%v calls=%#v", err, createMismatch.calls)
+ }
+
+ copyArgs := []string{"+node-copy", "--workspace", "w", "--node", "source", "--yes"}
+ assertErr("copy write", &wikiCoverageCaller{errors: map[string][]error{"doc/copy_document": {backend}}}, copyArgs...)
+ assertErr("copy terminal", &wikiCoverageCaller{responses: map[string][]string{"doc/copy_document": {`{"result":{"fileId":"n"}}`}}}, copyArgs...)
+ assertErr("copy id", &wikiCoverageCaller{responses: map[string][]string{"doc/copy_document": {`{"success":true}`}}}, copyArgs...)
+ assertErr("copy readback call", &wikiCoverageCaller{responses: map[string][]string{"doc/copy_document": {`{"success":true,"fileId":"n"}`}}, errors: map[string][]error{"doc/get_document_info": {backend}}}, copyArgs...)
+ assertErr("copy readback object", &wikiCoverageCaller{responses: map[string][]string{"doc/copy_document": {`{"success":true,"fileId":"n"}`}, "doc/get_document_info": {`{"success":true}`}}}, copyArgs...)
+
+ moveArgs := []string{"+move", "--workspace", "target", "--node", "n", "--yes"}
+ assertErr("move preflight call", &wikiCoverageCaller{errors: map[string][]error{"doc/get_document_info": {backend}}}, moveArgs...)
+ assertErr("move preflight object", &wikiCoverageCaller{responses: map[string][]string{"doc/get_document_info": {`{"success":true}`}}}, moveArgs...)
+ assertErr("move write", &wikiCoverageCaller{responses: map[string][]string{"doc/get_document_info": {`{"nodeId":"n","workspaceId":"old"}`}}, errors: map[string][]error{"doc/move_document": {backend}}}, moveArgs...)
+ assertErr("move terminal", &wikiCoverageCaller{responses: map[string][]string{"doc/get_document_info": {`{"nodeId":"n","workspaceId":"old"}`}, "doc/move_document": {`{"result":{}}`}}}, moveArgs...)
+ assertErr("move readback call", &wikiCoverageCaller{responses: map[string][]string{"doc/get_document_info": {`{"nodeId":"n","workspaceId":"old"}`}, "doc/move_document": {`{"success":true}`}}, errors: map[string][]error{"doc/get_document_info": {nil, backend}}}, moveArgs...)
+ assertErr("move readback object", &wikiCoverageCaller{responses: map[string][]string{"doc/get_document_info": {`{"nodeId":"n","workspaceId":"old"}`, `{"success":true}`}, "doc/move_document": {`{"success":true}`}}}, moveArgs...)
+ assertErr("move id mismatch", &wikiCoverageCaller{responses: map[string][]string{"doc/get_document_info": {`{"nodeId":"n","workspaceId":"old"}`, `{"nodeId":"other","workspaceId":"target"}`}, "doc/move_document": {`{"success":true}`}}}, moveArgs...)
+ assertErr("move workspace mismatch", &wikiCoverageCaller{responses: map[string][]string{"doc/get_document_info": {`{"nodeId":"n","workspaceId":"old"}`, `{"nodeId":"n","workspaceId":"other"}`}, "doc/move_document": {`{"success":true}`}}}, moveArgs...)
+ assertErr("move folder mismatch", &wikiCoverageCaller{responses: map[string][]string{"doc/get_document_info": {`{"nodeId":"n","workspaceId":"old"}`, `{"nodeId":"n","workspaceId":"target","folderId":"other"}`}, "doc/move_document": {`{"success":true}`}}}, append(moveArgs, "--folder", "f")...)
+ assertErr("drive move unchanged", &wikiCoverageCaller{responses: map[string][]string{"doc/get_document_info": {`{"nodeId":"n","workspaceId":"same"}`, `{"nodeId":"n","workspaceId":"same"}`}, "doc/move_document": {`{"success":true}`}}}, "+move-to-drive", "--node", "n", "--yes")
+
+ deleteArgs := []string{"+node-delete", "--workspace", "w", "--node", "n", "--yes"}
+ assertErr("delete preflight call", &wikiCoverageCaller{errors: map[string][]error{"doc/get_document_info": {backend}}}, deleteArgs...)
+ assertErr("delete preflight object", &wikiCoverageCaller{responses: map[string][]string{"doc/get_document_info": {`{"success":true}`}}}, deleteArgs...)
+ assertErr("delete workspace", &wikiCoverageCaller{responses: map[string][]string{"doc/get_document_info": {`{"nodeId":"n","workspaceId":"other"}`}}}, deleteArgs...)
+ assertErr("delete write", &wikiCoverageCaller{responses: map[string][]string{"doc/get_document_info": {`{"nodeId":"n","workspaceId":"w"}`}}, errors: map[string][]error{"doc/delete_document": {backend}}}, deleteArgs...)
+ assertErr("delete terminal", &wikiCoverageCaller{responses: map[string][]string{"doc/get_document_info": {`{"nodeId":"n","workspaceId":"w"}`}, "doc/delete_document": {`{"result":{}}`}}}, deleteArgs...)
+ assertErr("feed collection", &wikiCoverageCaller{responses: map[string][]string{"wiki/list_workspace_feeds": {`{"success":true}`}}}, "+feed-list", "--workspace", "w")
+}
+
+func TestCrossPlatformCoverageWikiSecondPageFailures(t *testing.T) {
+ caller := &wikiCoverageCaller{
+ responses: map[string][]string{"wiki/list_wikiSpaces": {`{"wikiSpaces":[],"hasMore":true,"nextCursor":"next"}`}},
+ errors: map[string][]error{"wiki/list_wikiSpaces": {nil, errors.New("second page failed")}},
+ }
+ if _, err := runWikiCoverageCLI(t, caller, "+space-list", "--page-all"); err == nil {
+ t.Fatal("second-page transport error was swallowed")
+ }
+ caller = &wikiCoverageCaller{responses: map[string][]string{"wiki/list_wikiSpaces": {`{"wikiSpaces":[],"hasMore":true,"nextCursor":"next"}`, `{"success":true}`}}}
+ if _, err := runWikiCoverageCLI(t, caller, "+space-list", "--page-all"); err == nil {
+ t.Fatal("second-page malformed collection was swallowed")
+ }
+}
diff --git a/scripts/dev/wiki-shortcut-e2e.py b/scripts/dev/wiki-shortcut-e2e.py
index 84ecd391..33b14185 100755
--- a/scripts/dev/wiki-shortcut-e2e.py
+++ b/scripts/dev/wiki-shortcut-e2e.py
@@ -54,6 +54,13 @@ def check(label: str, condition: bool) -> None:
print(f"PASS {label}")
+def member_role(data: dict, user_id: str) -> str:
+ for member in data.get("members", []):
+ if member.get("id") == user_id:
+ return str(member.get("role") or "").upper()
+ return ""
+
+
def main() -> int:
if not DWS.exists():
raise E2EFailure("build ./dws first with make build")
@@ -142,12 +149,18 @@ def main() -> int:
check("member-list", members.get("count", 0) >= 1 and isinstance(members.get("members"), list))
added = invoke(["+member-add", "--workspace", workspace, "--users", MEMBER_ID, "--role", "READER"])
member_added = True
- check("member-add-readback", added.get("success") is True and added.get("verifiedBy") == "member_list_readback")
+ check("member-add-terminal", added.get("success") is True and added.get("verifiedBy") == "write_terminal_success")
+ after_add = invoke(["+member-list", "--workspace", workspace, "--limit", "50"])
+ check("member-add-fixture-readback", after_add.get("truncated") is not True and member_role(after_add, MEMBER_ID) == "READER")
updated = invoke(["+member-update", "--workspace", workspace, "--users", MEMBER_ID, "--role", "EDITOR"])
- check("member-update-readback", updated.get("success") is True and updated.get("verifiedBy") == "member_list_readback")
+ check("member-update-terminal", updated.get("success") is True and updated.get("verifiedBy") == "write_terminal_success")
+ after_update = invoke(["+member-list", "--workspace", workspace, "--limit", "50"])
+ check("member-update-fixture-readback", after_update.get("truncated") is not True and member_role(after_update, MEMBER_ID) == "EDITOR")
removed = invoke(["+member-remove", "--workspace", workspace, "--users", MEMBER_ID])
member_added = False
- check("member-remove-readback", removed.get("success") is True and removed.get("verifiedBy") == "member_list_readback")
+ check("member-remove-terminal", removed.get("success") is True and removed.get("verifiedBy") == "write_terminal_success")
+ after_remove = invoke(["+member-list", "--workspace", workspace, "--limit", "50"])
+ check("member-remove-fixture-readback", after_remove.get("truncated") is not True and member_role(after_remove, MEMBER_ID) == "")
feeds = invoke(["+feed-list", "--workspace", workspace, "--limit", "10"])
check("feed-list", isinstance(feeds.get("feeds"), list))