chore: import upstream snapshot with attribution
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / results (push) Has been cancelled
CI / deadcode (push) Has been cancelled
CI / e2e-live (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:22:54 +08:00
commit bf9395e022
2349 changed files with 588574 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
# Wiki CLI E2E Coverage
## Metrics
- Denominator: 6 leaf commands + 3 shortcut commands
- Covered: 9
- Coverage: 100.0%
## Summary
- TestWiki_NodeWorkflow: proves the full currently-tested bare-API surface; key `t.Run(...)` proof points are `create node as bot`, `get created node as bot`, `get space as bot`, `list spaces as bot`, `list nodes and find created node as bot`, `copy node as bot`, and `list nodes and find copied node as bot`.
- TestWiki_ShortcutWorkflow: covers the shortcut layer for `wiki +space-list`, `wiki +node-list`, and `wiki +node-copy` — flag→body mapping, envelope shape (`{spaces|nodes, has_more, page_token}` + `meta.count`), `--page-all` / `--page-limit` truncation, my_library alias resolution (user positive + bot validation rejection), and copy-source-survival.
## Command Table
| Status | Cmd | Type | Testcase | Key parameter shapes | Notes / uncovered reason |
| --- | --- | --- | --- | --- | --- |
| ✓ | wiki nodes copy | api | wiki_workflow_test.go::TestWiki_NodeWorkflow/copy node as bot | `space_id`; `node_token` in `--params`; target/title in `--data` | |
| ✓ | wiki nodes create | api | wiki_workflow_test.go::TestWiki_NodeWorkflow/create node as bot | `space_id` in `--params`; `node_type`; `obj_type`; `title` in `--data` | |
| ✓ | wiki nodes list | api | wiki/helpers_test.go::findWikiNodeByToken; wiki_workflow_test.go::TestWiki_NodeWorkflow/list nodes and find created node as bot; wiki_workflow_test.go::TestWiki_NodeWorkflow/list nodes and find copied node as bot | `space_id`; `page_size`; optional `page_token` | |
| ✓ | wiki spaces get | api | wiki_workflow_test.go::TestWiki_NodeWorkflow/get space as bot | `space_id` in `--params` | |
| ✓ | wiki spaces get_node | api | wiki_workflow_test.go::TestWiki_NodeWorkflow/get created node as bot | `token`; `obj_type` in `--params` | |
| ✓ | wiki spaces list | api | wiki_workflow_test.go::TestWiki_NodeWorkflow/list spaces as bot | `page_size` in `--params` | |
| ✓ | wiki +space-list | shortcut | wiki_shortcut_workflow_test.go::TestWiki_ShortcutWorkflow/+space-list: stable envelope shape | `--page-size`; `--format json`; bot identity | |
| ✓ | wiki +node-list | shortcut | wiki_shortcut_workflow_test.go::TestWiki_ShortcutWorkflow/+node-list: finds child under parent; +node-list: --page-limit caps the loop and exposes cursor; +node-list --space-id my_library --as bot: validation rejection; +node-list --space-id my_library --as user: resolves and lists | `--space-id`; `--parent-node-token`; `--page-all`; `--page-size`; `--page-limit`; my_library alias | |
| ✓ | wiki +node-copy | shortcut | wiki_shortcut_workflow_test.go::TestWiki_ShortcutWorkflow/+node-copy: copies child + verifies source survives + cleanup | `--space-id`; `--node-token`; `--target-space-id`; `--title` | |
+530
View File
@@ -0,0 +1,530 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package wiki
import (
"context"
"errors"
"fmt"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func createWikiNode(t *testing.T, parentT *testing.T, ctx context.Context, spaceID string, data map[string]any) (gjson.Result, *clie2e.Result, error) {
t.Helper()
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"api", "post", "/open-apis/wiki/v2/spaces/" + spaceID + "/nodes"},
DefaultAs: "bot",
Data: data,
})
if err != nil || result.ExitCode != 0 {
return gjson.Result{}, result, err
}
node := gjson.Get(result.Stdout, "data.node")
require.True(t, node.Exists(), "stdout:\n%s", result.Stdout)
nodeToken := node.Get("node_token").String()
require.NotEmpty(t, nodeToken, "stdout:\n%s", result.Stdout)
objType := node.Get("obj_type").String()
parentT.Cleanup(func() {
cleanupCtx, cancel := clie2e.CleanupContext()
defer cancel()
deleteResult, deleteErr := deleteWikiNodeAndVerify(cleanupCtx, spaceID, nodeToken, objType)
clie2e.ReportCleanupFailure(parentT, "delete wiki node "+nodeToken, deleteResult, deleteErr)
})
return node, result, nil
}
// createWikiNodeUnderAnyHost creates an isolated parent under an existing
// my_library root node. It avoids adding test nodes directly at the root level,
// whose single-layer limit is easy to exhaust when cleanup regresses. If the
// library is empty, it creates one reusable root host and keeps it for future
// test runs.
func createWikiNodeUnderAnyHost(t *testing.T, parentT *testing.T, ctx context.Context, title string) (gjson.Result, gjson.Result) {
t.Helper()
hosts := listWikiRootHosts(t, ctx)
if len(hosts) == 0 {
hosts = append(hosts, createWikiRootHost(t, ctx))
}
var layerLimitResults []string
for _, host := range hosts {
spaceID := host.Get("space_id").String()
hostNodeToken := host.Get("node_token").String()
if spaceID == "" || hostNodeToken == "" {
continue
}
node, result, err := createWikiNode(t, parentT, ctx, spaceID, map[string]any{
"node_type": "origin",
"obj_type": "docx",
"title": title,
"parent_node_token": hostNodeToken,
})
if err == nil && result.ExitCode == 0 {
return host, node
}
if isWikiLayerLimitResult(result) {
layerLimitResults = append(layerLimitResults, fmt.Sprintf("host=%s stdout=%s stderr=%s", hostNodeToken, result.Stdout, result.Stderr))
continue
}
require.NoError(t, err)
require.Failf(t, "create wiki node under host failed", "host=%s exit=%d stdout=%s stderr=%s", hostNodeToken, result.ExitCode, result.Stdout, result.Stderr)
}
require.Failf(t, "create wiki node under host failed", "all candidate hosts hit the single-layer node limit:\n%s", strings.Join(layerLimitResults, "\n"))
return gjson.Result{}, gjson.Result{}
}
func createWikiRootHost(t *testing.T, ctx context.Context) gjson.Result {
t.Helper()
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{"api", "post", "/open-apis/wiki/v2/spaces/my_library/nodes"},
DefaultAs: "bot",
Data: map[string]any{
"node_type": "origin",
"obj_type": "docx",
"title": "lark-cli-e2e-wiki-host",
},
}, clie2e.RetryOptions{})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
host := gjson.Get(result.Stdout, "data.node")
require.True(t, host.Exists(), "stdout:\n%s", result.Stdout)
require.NotEmpty(t, host.Get("space_id").String(), "stdout:\n%s", result.Stdout)
require.NotEmpty(t, host.Get("node_token").String(), "stdout:\n%s", result.Stdout)
return host
}
func listWikiRootHosts(t *testing.T, ctx context.Context) []gjson.Result {
t.Helper()
var hosts []gjson.Result
pageToken := ""
seenPageTokens := map[string]struct{}{}
for {
params := map[string]any{"page_size": 50}
if pageToken != "" {
if _, exists := seenPageTokens[pageToken]; exists {
t.Fatalf("wiki root host pagination loop detected for page_token %q", pageToken)
}
seenPageTokens[pageToken] = struct{}{}
params["page_token"] = pageToken
}
listResult, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{"api", "get", "/open-apis/wiki/v2/spaces/my_library/nodes"},
DefaultAs: "bot",
Params: params,
}, clie2e.RetryOptions{})
require.NoError(t, err)
listResult.AssertExitCode(t, 0)
listResult.AssertStdoutStatus(t, true)
parsed := gjson.Parse(listResult.Stdout)
hosts = append(hosts, parsed.Get("data.items").Array()...)
pageToken = parsed.Get("data.page_token").String()
if pageToken == "" || !parsed.Get("data.has_more").Bool() {
return hosts
}
}
}
func isWikiLayerLimitResult(result *clie2e.Result) bool {
if result == nil {
return false
}
combined := result.Stdout + "\n" + result.Stderr
return strings.Contains(combined, "131003") ||
strings.Contains(strings.ToLower(combined), "single-layer nodes")
}
func getWikiNode(t *testing.T, ctx context.Context, nodeToken string) gjson.Result {
t.Helper()
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"api", "get", "/open-apis/wiki/v2/spaces/get_node"},
DefaultAs: "bot",
Params: map[string]any{"token": nodeToken},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
node := gjson.Get(result.Stdout, "data.node")
require.True(t, node.Exists(), "stdout:\n%s", result.Stdout)
return node
}
func getWikiSpace(t *testing.T, ctx context.Context, spaceID string) gjson.Result {
t.Helper()
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"api", "get", "/open-apis/wiki/v2/spaces/" + spaceID},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
space := gjson.Get(result.Stdout, "data.space")
require.True(t, space.Exists(), "stdout:\n%s", result.Stdout)
return space
}
func listWikiSpaces(t *testing.T, ctx context.Context, pageSize int) gjson.Result {
t.Helper()
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"api", "get", "/open-apis/wiki/v2/spaces"},
DefaultAs: "bot",
Params: map[string]any{"page_size": pageSize},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
return gjson.Parse(result.Stdout)
}
type wikiNodeInfo struct {
NodeToken string
ObjType string
}
const (
wikiDeleteVisibilityTimeout = 30 * time.Second
wikiDeleteVisibilityPoll = 3 * time.Second
)
// deleteWikiNodeAndVerify removes a wiki node, then polls get_node until the
// original node token is gone. Wiki cleanup cannot use drive +delete because
// wiki origin nodes need the backing obj_token and parent nodes must delete
// children first.
func deleteWikiNodeAndVerify(ctx context.Context, spaceID, nodeToken, objType string) (*clie2e.Result, error) {
getResult, getErr := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{"api", "get", "/open-apis/wiki/v2/spaces/get_node"},
DefaultAs: "bot",
Params: map[string]any{"token": nodeToken},
}, clie2e.RetryOptions{})
if getErr != nil {
return getResult, getErr
}
if getResult == nil {
return nil, fmt.Errorf("get wiki node %s before delete returned nil result", nodeToken)
}
if getResult.ExitCode != 0 || !wikiAPISuccess(getResult.Stdout) {
if isWikiNodeDeletedResult(getResult) {
getResult.ExitCode = 0
getResult.RunErr = nil
return getResult, nil
}
return getResult, fmt.Errorf("get wiki node %s before delete failed: exit=%d stdout=%s stderr=%s", nodeToken, getResult.ExitCode, getResult.Stdout, getResult.Stderr)
}
node := gjson.Get(getResult.Stdout, "data.node")
originalNodeToken := nodeToken
if resolvedSpaceID := node.Get("space_id").String(); resolvedSpaceID != "" {
spaceID = resolvedSpaceID
}
if resolvedObjType := node.Get("obj_type").String(); resolvedObjType != "" {
objType = resolvedObjType
}
if objType == "" {
objType = "docx"
}
children, childListResult, childListErr := listWikiNodeChildren(ctx, spaceID, originalNodeToken)
if childListErr != nil || childListResult == nil || childListResult.ExitCode != 0 {
return childListResult, childListErr
}
for _, child := range children {
childDeleteResult, childDeleteErr := deleteWikiNodeAndVerify(ctx, spaceID, child.NodeToken, child.ObjType)
if childDeleteErr != nil || childDeleteResult == nil || (childDeleteResult.ExitCode != 0 && !isWikiNodeDeletedResult(childDeleteResult)) {
return childDeleteResult, childDeleteErr
}
}
deleteToken := originalNodeToken
if node.Get("node_type").String() == "origin" {
if objToken := node.Get("obj_token").String(); objToken != "" {
deleteToken = objToken
}
}
deleteResult, deleteErr := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{"api", "delete", "/open-apis/wiki/v2/spaces/" + spaceID + "/nodes/" + deleteToken},
DefaultAs: "bot",
Data: map[string]any{"obj_type": objType},
}, clie2e.RetryOptions{})
if deleteErr != nil || deleteResult == nil {
return deleteResult, deleteErr
}
if deleteResult.ExitCode != 0 || !wikiAPISuccess(deleteResult.Stdout) {
deleted, verifyErr := isWikiNodeDeleted(ctx, originalNodeToken)
if verifyErr != nil {
return deleteResult, verifyErr
}
if deleted {
deleteResult.ExitCode = 0
return deleteResult, nil
}
return deleteResult, fmt.Errorf("wiki node %s still exists after delete failed: exit=%d stdout=%s stderr=%s", originalNodeToken, deleteResult.ExitCode, deleteResult.Stdout, deleteResult.Stderr)
}
if err := waitWikiNodeDeleted(ctx, originalNodeToken); err != nil {
return deleteResult, err
}
return deleteResult, nil
}
func listWikiNodeChildren(ctx context.Context, spaceID, parentNodeToken string) ([]wikiNodeInfo, *clie2e.Result, error) {
var children []wikiNodeInfo
pageToken := ""
seenPageTokens := map[string]struct{}{}
for {
params := map[string]any{
"page_size": 50,
"parent_node_token": parentNodeToken,
}
if pageToken != "" {
if _, exists := seenPageTokens[pageToken]; exists {
return children, nil, fmt.Errorf("wiki children pagination loop detected for parent %s page_token %q", parentNodeToken, pageToken)
}
seenPageTokens[pageToken] = struct{}{}
params["page_token"] = pageToken
}
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{"api", "get", "/open-apis/wiki/v2/spaces/" + spaceID + "/nodes"},
DefaultAs: "bot",
Params: params,
}, clie2e.RetryOptions{})
if err != nil || result == nil || result.ExitCode != 0 {
return children, result, err
}
if !wikiAPISuccess(result.Stdout) {
return children, result, fmt.Errorf("list wiki node children for parent %s failed: stdout=%s stderr=%s", parentNodeToken, result.Stdout, result.Stderr)
}
parsed := gjson.Parse(result.Stdout)
for _, item := range parsed.Get("data.items").Array() {
nodeToken := item.Get("node_token").String()
if nodeToken == "" {
continue
}
objType := item.Get("obj_type").String()
if objType == "" {
objType = "docx"
}
children = append(children, wikiNodeInfo{NodeToken: nodeToken, ObjType: objType})
}
pageToken = parsed.Get("data.page_token").String()
if pageToken == "" || !parsed.Get("data.has_more").Bool() {
return children, result, nil
}
}
}
func waitWikiNodeDeleted(ctx context.Context, nodeToken string) error {
var lastTransientErr error
opts := clie2e.WaitOptions{
Timeout: wikiDeleteVisibilityTimeout,
Interval: wikiDeleteVisibilityPoll,
TimeoutError: func() error {
if lastTransientErr != nil {
return fmt.Errorf("wiki node %s delete verification kept hitting transient errors: %w", nodeToken, lastTransientErr)
}
return fmt.Errorf("wiki node %s still exists after delete", nodeToken)
},
}
return clie2e.WaitForCondition(ctx, opts, func() (bool, error) {
deleted, err := isWikiNodeDeleted(ctx, nodeToken)
if err != nil {
if isWikiVerifyTransientError(err) {
lastTransientErr = err
return false, nil
} else {
return false, err
}
}
if deleted {
return true, nil
}
return false, nil
})
}
func isWikiNodeDeleted(ctx context.Context, nodeToken string) (bool, error) {
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{"api", "get", "/open-apis/wiki/v2/spaces/get_node"},
DefaultAs: "bot",
Params: map[string]any{"token": nodeToken},
}, clie2e.RetryOptions{})
if err != nil {
return false, err
}
if result == nil {
return false, fmt.Errorf("verify wiki node %s after delete returned nil result", nodeToken)
}
if result.ExitCode == 0 && wikiAPISuccess(result.Stdout) {
return false, nil
}
if isWikiNodeDeletedResult(result) {
return true, nil
}
if isWikiVerifyTransientResult(result) {
return false, wikiVerifyTransientError{
err: fmt.Errorf("verify wiki node %s after delete hit transient response: exit=%d stdout=%s stderr=%s", nodeToken, result.ExitCode, result.Stdout, result.Stderr),
}
}
return false, fmt.Errorf("verify wiki node %s after delete: exit=%d stdout=%s stderr=%s", nodeToken, result.ExitCode, result.Stdout, result.Stderr)
}
type wikiVerifyTransientError struct {
err error
}
func (e wikiVerifyTransientError) Error() string {
return e.err.Error()
}
func (e wikiVerifyTransientError) Unwrap() error {
return e.err
}
func isWikiVerifyTransientError(err error) bool {
var transient wikiVerifyTransientError
return err != nil && errors.As(err, &transient)
}
func wikiAPISuccess(stdout string) bool {
if ok := gjson.Get(stdout, "ok"); ok.Exists() {
return ok.Bool()
}
if code := gjson.Get(stdout, "code"); code.Exists() {
return code.Int() == 0
}
return false
}
func isWikiNodeDeletedResult(result *clie2e.Result) bool {
if result == nil {
return false
}
if code := gjson.Get(result.Stdout, "error.code"); code.Exists() && code.Int() == 131005 {
return true
}
if code := gjson.Get(result.Stdout, "code"); code.Exists() && code.Int() == 131005 {
return true
}
combined := strings.ToLower(result.Stdout + "\n" + result.Stderr)
return strings.Contains(combined, "131005") ||
strings.Contains(combined, "node not found") ||
strings.Contains(combined, "not found")
}
func isWikiVerifyTransientResult(result *clie2e.Result) bool {
if result == nil {
return false
}
payload := result.Stdout
if strings.TrimSpace(payload) == "" {
payload = result.Stderr
}
if gjson.Get(payload, "error.type").String() != "internal" ||
gjson.Get(payload, "error.subtype").String() != "invalid_response" {
return false
}
message := strings.ToLower(gjson.Get(payload, "error.message").String())
return strings.Contains(message, "http 429") ||
strings.Contains(message, "http 500") ||
strings.Contains(message, "http 502") ||
strings.Contains(message, "http 503") ||
strings.Contains(message, "http 504")
}
func TestWikiVerifyTransientResult(t *testing.T) {
t.Run("matches invalid response from transient http status", func(t *testing.T) {
result := &clie2e.Result{
ExitCode: 5,
Stderr: `{"ok":false,"error":{"type":"internal","subtype":"invalid_response","message":"SDK returned an invalid JSON response: failed to parse TAT response (HTTP 429): invalid character 'r' looking for beginning of value"}}`,
}
require.True(t, isWikiVerifyTransientResult(result))
})
t.Run("does not match unrelated invalid response", func(t *testing.T) {
result := &clie2e.Result{
ExitCode: 5,
Stderr: `{"ok":false,"error":{"type":"internal","subtype":"invalid_response","message":"SDK returned an invalid JSON response: malformed body"}}`,
}
require.False(t, isWikiVerifyTransientResult(result))
})
t.Run("does not match api errors", func(t *testing.T) {
result := &clie2e.Result{
ExitCode: 1,
Stderr: `{"ok":false,"error":{"type":"api","subtype":"conflict","message":"resource contention occurred, please retry","retryable":true}}`,
}
require.False(t, isWikiVerifyTransientResult(result))
})
}
func findWikiNodeByToken(t *testing.T, ctx context.Context, spaceID string, nodeToken string, parentNodeTokens ...string) gjson.Result {
t.Helper()
pageToken := ""
lastStdout := ""
seenPageTokens := map[string]struct{}{}
for {
params := map[string]any{"page_size": 50}
if len(parentNodeTokens) > 0 && parentNodeTokens[0] != "" {
params["parent_node_token"] = parentNodeTokens[0]
}
if pageToken != "" {
if _, exists := seenPageTokens[pageToken]; exists {
t.Fatalf("wiki list pagination loop detected for page_token %q, last stdout:\n%s", pageToken, lastStdout)
}
seenPageTokens[pageToken] = struct{}{}
params["page_token"] = pageToken
}
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"api", "get", "/open-apis/wiki/v2/spaces/" + spaceID + "/nodes"},
DefaultAs: "bot",
Params: params,
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
lastStdout = result.Stdout
parsed := gjson.Parse(result.Stdout)
node := parsed.Get(`data.items.#(node_token=="` + nodeToken + `")`)
if node.Exists() {
return node
}
pageToken = parsed.Get("data.page_token").String()
if pageToken == "" || !parsed.Get("data.has_more").Bool() {
t.Fatalf("wiki node %q not found in listed pages, last stdout:\n%s", nodeToken, lastStdout)
}
}
}
@@ -0,0 +1,44 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package wiki
import (
"context"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestWikiMemberAddDryRun(t *testing.T) {
setWikiNodeCreateDryRunEnv(t)
t.Run("SupportsAppIDMemberType", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"wiki", "+member-add",
"--space-id", "space_42",
"--member-id", "cli_app_123",
"--member-type", "appid",
"--member-role", "member",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "POST", gjson.Get(result.Stdout, "api.0.method").String())
assert.Equal(t, "/open-apis/wiki/v2/spaces/space_42/members", gjson.Get(result.Stdout, "api.0.url").String())
assert.Equal(t, "cli_app_123", gjson.Get(result.Stdout, "api.0.body.member_id").String())
assert.Equal(t, "appid", gjson.Get(result.Stdout, "api.0.body.member_type").String())
assert.Equal(t, "member", gjson.Get(result.Stdout, "api.0.body.member_role").String())
})
}
@@ -0,0 +1,207 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package wiki
import (
"context"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func setWikiNodeCreateDryRunEnv(t *testing.T) {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "wiki_dryrun_test")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "wiki_dryrun_secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
}
// TestWikiNodeCreateDryRun pins the request shape and Validate behavior for
// `wiki +node-create`.
func TestWikiNodeCreateDryRun(t *testing.T) {
setWikiNodeCreateDryRunEnv(t)
t.Run("HappyPath_ExplicitSpaceID", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"wiki", "+node-create",
"--space-id", "123456",
"--title", "TestDoc",
"--obj-type", "docx",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "POST", gjson.Get(result.Stdout, "api.0.method").String())
assert.Equal(t, "/open-apis/wiki/v2/spaces/123456/nodes", gjson.Get(result.Stdout, "api.0.url").String())
assert.Equal(t, "origin", gjson.Get(result.Stdout, "api.0.body.node_type").String())
assert.Equal(t, "docx", gjson.Get(result.Stdout, "api.0.body.obj_type").String())
assert.Equal(t, "TestDoc", gjson.Get(result.Stdout, "api.0.body.title").String())
})
t.Run("HappyPath_WithParentNodeToken", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"wiki", "+node-create",
"--space-id", "123456",
"--parent-node-token", "wikcnABC123",
"--title", "ChildDoc",
"--obj-type", "docx",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
// 2-step: resolve parent node -> create node
assert.Equal(t, "GET", gjson.Get(result.Stdout, "api.0.method").String())
assert.Equal(t, "/open-apis/wiki/v2/spaces/get_node", gjson.Get(result.Stdout, "api.0.url").String())
assert.Equal(t, "wikcnABC123", gjson.Get(result.Stdout, "api.0.params.token").String())
assert.Equal(t, "POST", gjson.Get(result.Stdout, "api.1.method").String())
assert.Equal(t, "/open-apis/wiki/v2/spaces/123456/nodes", gjson.Get(result.Stdout, "api.1.url").String())
assert.Equal(t, "wikcnABC123", gjson.Get(result.Stdout, "api.1.body.parent_node_token").String())
})
t.Run("HappyPath_ShortcutNodeType", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"wiki", "+node-create",
"--space-id", "123456",
"--node-type", "shortcut",
"--origin-node-token", "wikcnORIG",
"--title", "ShortcutDoc",
"--obj-type", "docx",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "shortcut", gjson.Get(result.Stdout, "api.0.body.node_type").String())
assert.Equal(t, "wikcnORIG", gjson.Get(result.Stdout, "api.0.body.origin_node_token").String())
})
t.Run("RejectsShortcutWithoutOriginNodeToken", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"wiki", "+node-create",
"--space-id", "123456",
"--node-type", "shortcut",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
msg := validateWikiErrorMessage(result)
assert.Contains(t, msg, "--origin-node-token is required")
})
t.Run("RejectsOriginNodeTokenWithoutShortcutType", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"wiki", "+node-create",
"--space-id", "123456",
"--origin-node-token", "wikcnORIG",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
msg := validateWikiErrorMessage(result)
assert.Contains(t, msg, "--origin-node-token can only be used when --node-type=shortcut")
})
t.Run("RejectsBotWithoutSpaceIDOrParentNodeToken", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"wiki", "+node-create",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
msg := validateWikiErrorMessage(result)
assert.Contains(t, msg, "bot identity requires --space-id or --parent-node-token")
})
t.Run("RejectsBotWithMyLibrarySpaceID", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"wiki", "+node-create",
"--space-id", "my_library",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
msg := validateWikiErrorMessage(result)
assert.Contains(t, msg, "bot identity does not support --space-id my_library")
})
t.Run("RejectsInvalidObjType", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"wiki", "+node-create",
"--space-id", "123456",
"--obj-type", "pdf",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
msg := validateWikiErrorMessage(result)
assert.Contains(t, msg, `"pdf"`)
assert.Contains(t, msg, "invalid value")
})
}
func validateWikiErrorMessage(r *clie2e.Result) string {
if msg := gjson.Get(r.Stdout, "error.message").String(); msg != "" {
return msg
}
if msg := gjson.Get(r.Stderr, "error.message").String(); msg != "" {
return msg
}
return r.Stdout + r.Stderr
}
@@ -0,0 +1,247 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package wiki
import (
"context"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestWiki_ShortcutWorkflow exercises the shortcut layer (wiki +space-list,
// +node-list, +node-copy) end-to-end against a real Lark tenant. The existing
// TestWiki_NodeWorkflow only hits the bare `api` command, so it does not
// protect against regressions in shortcut-specific behavior — flag → body
// mapping, envelope shape ({spaces|nodes, has_more, page_token} + meta.count),
// auto-pagination, my_library alias resolution, or required-flag validation.
func TestWiki_ShortcutWorkflow(t *testing.T) {
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
suffix := clie2e.GenerateSuffix()
parentTitle := "lark-cli-e2e-wiki-sc-parent-" + suffix
childTitle := "lark-cli-e2e-wiki-sc-child-" + suffix
copyTitle := "lark-cli-e2e-wiki-sc-copy-" + suffix
var spaceID, parentNodeToken, childNodeToken, childObjType, copiedNodeToken string
// Setup: reuse an existing first-layer node in my_library as the host so
// we never bump the top-layer node count (the bot's my_library top layer
// has hit the API's "single-layer nodes ... upper limit" — code 131003 —
// in earlier CI runs because of leftover nodes). Then create a FRESH
// intermediate parent under that host, and put the test child under the
// fresh parent. We can't put the child directly under the host because
// leftover nodes from prior runs accumulate as the host's children, so
// `+node-list --parent-node-token=<host>` returns hundreds of unrelated
// nodes and the just-created child gets paged out (regardless of
// --page-limit) before the test can find it. An isolated intermediate
// parent always has exactly the children this test creates, so the
// pagination scan never has to dig through historical cruft.
t.Run("setup: locate my_library host node + create isolated parent + create test child", func(t *testing.T) {
host, isolatedParent := createWikiNodeUnderAnyHost(t, parentT, ctx, parentTitle)
spaceID = host.Get("space_id").String()
require.NotEmpty(t, spaceID, "host space_id must be present in listing")
parentNodeToken = isolatedParent.Get("node_token").String()
require.NotEmpty(t, parentNodeToken, "isolated parent node_token must be present after create")
// Create the test child UNDER the freshly-isolated parent.
child, result, err := createWikiNode(t, parentT, ctx, spaceID, map[string]any{
"node_type": "origin",
"obj_type": "docx",
"title": childTitle,
"parent_node_token": parentNodeToken,
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
childNodeToken = child.Get("node_token").String()
childObjType = child.Get("obj_type").String()
require.NotEmpty(t, childNodeToken)
})
// QA-P1: +space-list envelope shape is stable for JSON consumers.
// `spaces` must always be an array (never null), and pagination metadata
// fields must always exist so downstream agents can introspect.
t.Run("+space-list: stable envelope shape", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"wiki", "+space-list", "--page-size", "1"},
DefaultAs: "bot",
Format: "json",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := gjson.Parse(result.Stdout)
require.True(t, out.Get("ok").Bool(), "stdout:\n%s", result.Stdout)
assert.True(t, out.Get("data.spaces").Exists(), "data.spaces must exist")
assert.True(t, out.Get("data.spaces").IsArray(), "data.spaces must be an array, even when empty")
assert.True(t, out.Get("data.has_more").Exists(), "data.has_more must always be present")
assert.True(t, out.Get("data.page_token").Exists(), "data.page_token must always be present")
// meta.count uses `json:",omitempty"` in the envelope framework, so the
// field is dropped when the count is zero. Comparing values (gjson
// returns 0 for missing keys) keeps the assertion correct in both the
// "no spaces visible" and "some spaces" cases without requiring a
// framework-level change.
spacesLen := len(out.Get("data.spaces").Array())
assert.Equal(t, float64(spacesLen), out.Get("meta.count").Float(),
"meta.count must equal len(data.spaces) (or be omitted when zero); stdout:\n%s", result.Stdout)
})
// QA-P1: +node-list correctly maps flags onto the underlying request body
// and surfaces the child we just created under the parent.
t.Run("+node-list: finds child under parent", func(t *testing.T) {
require.NotEmpty(t, spaceID)
require.NotEmpty(t, parentNodeToken)
require.NotEmpty(t, childNodeToken)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"wiki", "+node-list",
"--space-id", spaceID,
"--parent-node-token", parentNodeToken,
"--page-all",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := gjson.Parse(result.Stdout)
require.True(t, out.Get("ok").Bool(), "stdout:\n%s", result.Stdout)
match := out.Get(`data.nodes.#(node_token=="` + childNodeToken + `")`)
require.True(t, match.Exists(), "+node-list did not return the child we created:\n%s", result.Stdout)
assert.Equal(t, childTitle, match.Get("title").String())
assert.Equal(t, parentNodeToken, match.Get("parent_node_token").String())
})
// QA-P2: --page-size 1 --page-all --page-limit 1 must aggregate exactly
// one page and surface the next cursor when has_more=true. This catches
// regressions where the pagination loop overruns the cap or fails to
// surface has_more / page_token.
t.Run("+node-list: --page-limit caps the loop and exposes cursor", func(t *testing.T) {
require.NotEmpty(t, spaceID)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"wiki", "+node-list",
"--space-id", spaceID,
"--page-size", "1",
"--page-all",
"--page-limit", "1",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := gjson.Parse(result.Stdout)
require.True(t, out.Get("ok").Bool(), "stdout:\n%s", result.Stdout)
nodes := out.Get("data.nodes").Array()
assert.LessOrEqual(t, len(nodes), 1, "--page-limit=1 + --page-size=1 should yield ≤1 node, got %d", len(nodes))
// has_more / page_token must still exist — never elided — so
// callers can resume regardless of whether the cap actually fired.
assert.True(t, out.Get("data.has_more").Exists())
assert.True(t, out.Get("data.page_token").Exists())
})
// QA-P1: +node-copy creates a copy under the same space and the source
// stays put (copy ≠ move). Cleanup deletes the copy. The copy is placed
// under the same host parent we use for the test child, so it doesn't
// add another top-layer node and trip the per-space limit.
t.Run("+node-copy: copies child + verifies source survives + cleanup", func(t *testing.T) {
require.NotEmpty(t, spaceID)
require.NotEmpty(t, parentNodeToken)
require.NotEmpty(t, childNodeToken)
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{
"wiki", "+node-copy",
"--space-id", spaceID,
"--node-token", childNodeToken,
"--target-parent-node-token", parentNodeToken,
"--title", copyTitle,
},
// +node-copy is now declared high-risk-write to align with the
// upstream API's `danger: true` flag, so the framework requires
// explicit confirmation before issuing the request.
Yes: true,
DefaultAs: "bot",
}, clie2e.RetryOptions{})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := gjson.Parse(result.Stdout)
require.True(t, out.Get("ok").Bool(), "stdout:\n%s", result.Stdout)
copiedNodeToken = out.Get("data.node_token").String()
copiedSpaceID := out.Get("data.space_id").String()
copiedObjType := out.Get("data.obj_type").String()
require.NotEmpty(t, copiedNodeToken, "stdout:\n%s", result.Stdout)
require.NotEmpty(t, copiedSpaceID)
assert.Equal(t, copyTitle, out.Get("data.title").String())
parentT.Cleanup(func() {
cleanupCtx, cancel := clie2e.CleanupContext()
defer cancel()
deleteResult, deleteErr := deleteWikiNodeAndVerify(cleanupCtx, copiedSpaceID, copiedNodeToken, copiedObjType)
clie2e.ReportCleanupFailure(parentT, "delete copied wiki node "+copiedNodeToken, deleteResult, deleteErr)
})
// Copy must be retrievable; source must still exist (copy ≠ move).
copied := getWikiNode(t, ctx, copiedNodeToken)
assert.Equal(t, copyTitle, copied.Get("title").String())
original := getWikiNode(t, ctx, childNodeToken)
assert.Equal(t, childTitle, original.Get("title").String(),
"source node must remain after +node-copy (copy is non-destructive)")
_ = childObjType // reserved for future +node-list filter checks
})
// QA-P2: bot identity must be rejected upfront when --space-id=my_library
// because the personal-library alias is per-user and meaningless for a
// tenant_access_token. The shortcut layer should fail before sending any
// HTTP request, with a validation error mentioning my_library.
t.Run("+node-list --space-id my_library --as bot: validation rejection", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"wiki", "+node-list", "--space-id", "my_library"},
DefaultAs: "bot",
})
require.NoError(t, err)
assert.NotEqual(t, 0, result.ExitCode, "bot + my_library must fail")
combined := strings.ToLower(result.Stdout + "\n" + result.Stderr)
assert.Contains(t, combined, "my_library",
"error must mention my_library to disambiguate from generic auth failures; got stdout=%s stderr=%s",
result.Stdout, result.Stderr)
})
// QA-P2: user identity must positively resolve --space-id=my_library to a
// real per-user space_id and proceed to list nodes. Skipped when no user
// token is available (matches the rest of the suite's user-flow gating).
t.Run("+node-list --space-id my_library --as user: resolves and lists", func(t *testing.T) {
clie2e.SkipWithoutUserToken(t)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"wiki", "+node-list", "--space-id", "my_library", "--page-size", "1"},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := gjson.Parse(result.Stdout)
require.True(t, out.Get("ok").Bool(), "stdout:\n%s", result.Stdout)
assert.True(t, out.Get("data.nodes").Exists(), "data.nodes must exist after my_library resolution")
assert.True(t, out.Get("data.nodes").IsArray(), "data.nodes must be an array")
// stderr must record the my_library resolution so users/agents can
// see what space_id the alias mapped to.
assert.Contains(t, result.Stderr, "Resolved my_library",
"expected my_library resolution log on stderr; got: %s", result.Stderr)
})
}
+142
View File
@@ -0,0 +1,142 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package wiki
import (
"context"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestWiki_NodeWorkflow(t *testing.T) {
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
suffix := clie2e.GenerateSuffix()
parentTitle := "lark-cli-e2e-wiki-parent-" + suffix
createdTitle := "lark-cli-e2e-wiki-create-" + suffix
copiedTitle := "lark-cli-e2e-wiki-copy-" + suffix
var spaceID string
var hostNodeToken string
var parentNodeToken string
var createdNodeToken string
var createdObjToken string
var copiedNodeToken string
var copiedSpaceID string
t.Run("create isolated parent node under host as bot", func(t *testing.T) {
host, parentNode := createWikiNodeUnderAnyHost(t, parentT, ctx, parentTitle)
spaceID = parentNode.Get("space_id").String()
hostNodeToken = host.Get("node_token").String()
parentNodeToken = parentNode.Get("node_token").String()
require.NotEmpty(t, spaceID)
require.NotEmpty(t, hostNodeToken)
require.NotEmpty(t, parentNodeToken)
assert.Equal(t, parentTitle, parentNode.Get("title").String())
})
t.Run("create node as bot", func(t *testing.T) {
require.NotEmpty(t, parentNodeToken, "parent node token should be created before child node")
node, result, err := createWikiNode(t, parentT, ctx, spaceID, map[string]any{
"node_type": "origin",
"obj_type": "docx",
"title": createdTitle,
"parent_node_token": parentNodeToken,
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
createdNodeToken = node.Get("node_token").String()
createdObjToken = node.Get("obj_token").String()
require.NotEmpty(t, createdNodeToken)
require.NotEmpty(t, createdObjToken)
assert.Equal(t, createdTitle, node.Get("title").String())
assert.Equal(t, "origin", node.Get("node_type").String())
assert.Equal(t, "docx", node.Get("obj_type").String())
assert.Equal(t, parentNodeToken, node.Get("parent_node_token").String())
})
t.Run("get created node as bot", func(t *testing.T) {
require.NotEmpty(t, createdNodeToken, "node token should be created before get_node")
node := getWikiNode(t, ctx, createdNodeToken)
assert.Equal(t, createdNodeToken, node.Get("node_token").String())
assert.Equal(t, createdObjToken, node.Get("obj_token").String())
assert.Equal(t, createdTitle, node.Get("title").String())
assert.Equal(t, spaceID, node.Get("space_id").String())
})
t.Run("get isolated parent node as bot", func(t *testing.T) {
require.NotEmpty(t, parentNodeToken, "parent node token should be created before get_node")
node := getWikiNode(t, ctx, parentNodeToken)
assert.Equal(t, parentNodeToken, node.Get("node_token").String())
assert.Equal(t, parentTitle, node.Get("title").String())
})
t.Run("get space as bot", func(t *testing.T) {
require.NotEmpty(t, spaceID, "space ID should be available before get")
space := getWikiSpace(t, ctx, spaceID)
assert.Equal(t, spaceID, space.Get("space_id").String())
assert.NotEmpty(t, space.Get("name").String())
})
t.Run("list spaces as bot", func(t *testing.T) {
result := listWikiSpaces(t, ctx, 1)
assert.True(t, result.Get("data.items").Exists(), "stdout:\n%s", result.Raw)
})
t.Run("list nodes and find isolated parent node as bot", func(t *testing.T) {
require.NotEmpty(t, spaceID, "space ID should be available before list")
require.NotEmpty(t, hostNodeToken, "host node token should be available before list")
require.NotEmpty(t, parentNodeToken, "parent node token should be available before list")
nodeItem := findWikiNodeByToken(t, ctx, spaceID, parentNodeToken, hostNodeToken)
assert.Equal(t, parentTitle, nodeItem.Get("title").String())
})
t.Run("copy node as bot", func(t *testing.T) {
require.NotEmpty(t, spaceID, "space ID should be available before copy")
require.NotEmpty(t, createdNodeToken, "node token should be available before copy")
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{"api", "post", "/open-apis/wiki/v2/spaces/" + spaceID + "/nodes/" + createdNodeToken + "/copy"},
DefaultAs: "bot",
Data: map[string]any{
"target_space_id": spaceID,
"target_parent_token": parentNodeToken,
"title": copiedTitle,
},
}, clie2e.RetryOptions{})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
copiedNodeToken = gjson.Get(result.Stdout, "data.node.node_token").String()
copiedSpaceID = gjson.Get(result.Stdout, "data.node.space_id").String()
copiedObjType := gjson.Get(result.Stdout, "data.node.obj_type").String()
require.NotEmpty(t, copiedNodeToken)
require.NotEmpty(t, copiedSpaceID)
parentT.Cleanup(func() {
cleanupCtx, cancel := clie2e.CleanupContext()
defer cancel()
deleteResult, deleteErr := deleteWikiNodeAndVerify(cleanupCtx, copiedSpaceID, copiedNodeToken, copiedObjType)
clie2e.ReportCleanupFailure(parentT, "delete copied wiki node "+copiedNodeToken, deleteResult, deleteErr)
})
})
t.Run("get copied node as bot", func(t *testing.T) {
require.NotEmpty(t, copiedNodeToken, "copied node token should be available before verification")
node := getWikiNode(t, ctx, copiedNodeToken)
assert.Equal(t, copiedTitle, node.Get("title").String())
})
}