Compare commits

...

6 Commits

Author SHA1 Message Date
chenhuang
b88039eeca fix: improve error message when auto-detected identity is unsupported
When a user-only shortcut is invoked without --as and auto-detect
resolves to bot (no login / expired token), show a clear message
suggesting `lark-cli auth login` instead of the misleading
"--as bot is not supported".

Change-Id: I684e6a422a907f428a68e8e4724fa8c50b13045d
Co-Authored-By: AI
2026-04-13 15:55:20 +08:00
Tsai_Hui
3917b77e91 feat: add drive create-shortcut shortcut (#432) 2026-04-13 11:54:31 +08:00
wangzhengkui
dc0d92708b fix(mail): restrict --output-dir to current working directory (#376)
* fix(mail): restrict --output-dir to current working directory

Previously, mail +watch --output-dir accepted absolute paths (e.g.
/etc, /tmp) and home directory paths (~/), allowing writes to arbitrary
locations. Since mail content is sender-controlled, this posed a risk
of writing attacker-influenced data to sensitive system directories.

Now all --output-dir values go through validate.SafeOutputPath which:
- Rejects absolute paths and ~ expansion
- Resolves .. and symlinks
- Enforces the result stays under CWD

* fix(mail): reject tilde paths in --output-dir explicitly

SafeOutputPath treats ~/x as a literal relative path, silently creating
a directory named "~" under CWD. Reject ~ prefixed paths with a clear
error message instead.

* fix(mail): reject all tilde-prefixed paths and use ErrValidation

- Broaden ~ check from "~ || ~/" to "~" prefix, covering ~user/path forms
- Use output.ErrValidation for consistent error type (exit code 2)

* fix(mail): add post-mkdir EvalSymlinks + CWD re-verification (TOCTOU)

SafeOutputPath validates before MkdirAll, but an attacker could replace
the newly created directory with a symlink between mkdir and the first
write. Add EvalSymlinks after MkdirAll and re-verify the resolved path
is still under CWD.

Also broaden ~ rejection to all tilde-prefixed paths (~user/path) and
use output.ErrValidation for consistent error types.

* fix(mail): use validate.SafeOutputPath for post-mkdir TOCTOU check

Replace direct os.Getwd and filepath.EvalSymlinks calls with a second
SafeOutputPath call after MkdirAll. This satisfies the forbidigo lint
rule (no direct os/filepath calls in shortcuts/) while maintaining the
same TOCTOU protection.

* fix(mail): use original relative path for post-mkdir re-validation

SafeOutputPath rejects absolute paths, but after the first call
outputDir was already resolved to an absolute path. Pass the original
relative path to the second SafeOutputPath call so it can properly
re-validate after MkdirAll.

* fix(mail): remove redundant post-mkdir SafeOutputPath call

The second SafeOutputPath call after MkdirAll provided no real TOCTOU
protection: mail +watch is long-running, so the directory could be
replaced at any point during the session, not just between mkdir and
the check. The first SafeOutputPath already validates and resolves
the path; one call is sufficient.
2026-04-13 10:53:08 +08:00
Yuxuan Zhao
085ffd87f3 feat: add stable cli e2e tests (#401)
* feat: add stable bot-only cli e2e subset

Change-Id: I62edf59d179e407954f65f82e94cf5dcf4938080

* fix: address review comments on stable cli e2e tests

Change-Id: I4436100c30adf2694cd06953961f8d77f576fc1e

* fix: reduce flakiness in drive and im e2e helpers

Change-Id: I51e77d857f1fd9aec5ee34adf5045cc695239f21

* fix: document missing drive cleanup support

Change-Id: I3d4f034145bd69fb7640e707fcda05146b8754c7

* style: unify e2e cleanup comments

Change-Id: I40d906c9168754ad71ef9fb770ff4c340fc19beb

* test: update e2e assertions

Change-Id: I73c21b4b38d4ced7ea27cb327075957ec2b9a2a2

* test: stabilize cli e2e bot-only coverage

Change-Id: Ied897c37c4f42e446d55d110461aa34ae198195d
2026-04-12 16:52:41 +08:00
zero-my
f6b8091843 Feat/task section updates (#430)
* docs(task): document sections API resources and add URL parsing reminder

* feat(task): support --section-guid flag in tasklist-task-add shortcut

* docs(task): document sections API resources, permissions, and URL parsing
2026-04-12 16:12:16 +08:00
OwenYWT
0e7f507efb docs(lark-doc): clarify when markdown escaping is needed (#312)
* docs(lark-doc): clarify when markdown escaping is needed

* docs(lark-doc): fix escaped special character code span
2026-04-11 23:56:07 +08:00
41 changed files with 2615 additions and 42 deletions

View File

@@ -127,8 +127,8 @@ func (f *Factory) CheckIdentity(as core.Identity, supported []string) error {
list := strings.Join(supported, ", ")
if f.IdentityAutoDetected {
return output.ErrValidation(
"resolved identity %q (via auto-detect or default-as) is not supported, this command only supports: %s\nhint: use --as %s",
as, list, supported[0])
"this command requires %s identity, but no user login was found (auto-detected as %s)\nhint: run `lark-cli auth login` first, then retry",
list, as)
}
return fmt.Errorf("--as %s is not supported, this command only supports: %s", as, list)
}

View File

@@ -174,15 +174,15 @@ func TestCheckIdentity_Unsupported_AutoDetected(t *testing.T) {
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
f.IdentityAutoDetected = true
err := f.CheckIdentity(core.AsUser, []string{"bot"})
err := f.CheckIdentity(core.AsBot, []string{"user"})
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "resolved identity") {
t.Errorf("expected 'resolved identity' in error, got: %v", err)
if !strings.Contains(err.Error(), "requires user identity") {
t.Errorf("expected 'requires user identity' in error, got: %v", err)
}
if !strings.Contains(err.Error(), "hint: use --as bot") {
t.Errorf("expected hint in error, got: %v", err)
if !strings.Contains(err.Error(), "auth login") {
t.Errorf("expected 'auth login' hint in error, got: %v", err)
}
}

View File

@@ -33,6 +33,11 @@ const (
LarkErrRefreshRevoked = 20064 // refresh_token revoked
LarkErrRefreshAlreadyUsed = 20073 // refresh_token already consumed (single-use rotation)
LarkErrRefreshServerError = 20050 // refresh endpoint server-side error, retryable
// Drive shortcut / cross-space constraints.
LarkErrDriveResourceContention = 1061045 // resource contention occurred, please retry
LarkErrDriveCrossTenantUnit = 1064510 // cross tenant and unit not support
LarkErrDriveCrossBrand = 1064511 // cross brand not support
)
// ClassifyLarkError maps a Lark API error code + message to (exitCode, errType, hint).
@@ -60,6 +65,14 @@ func ClassifyLarkError(code int, msg string) (int, string, string) {
// rate limit
case LarkErrRateLimit:
return ExitAPI, "rate_limit", "please try again later"
// drive-specific constraints that benefit from actionable hints
case LarkErrDriveResourceContention:
return ExitAPI, "conflict", "please retry later and avoid concurrent duplicate requests"
case LarkErrDriveCrossTenantUnit:
return ExitAPI, "cross_tenant_unit", "operate on source and target within the same tenant and region/unit"
case LarkErrDriveCrossBrand:
return ExitAPI, "cross_brand", "operate on source and target within the same brand environment"
}
return ExitAPI, "api_error", ""

View File

@@ -0,0 +1,64 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package output
import (
"strings"
"testing"
)
// TestClassifyLarkError_DriveCreateShortcutConstraints verifies known Drive shortcut errors map to actionable hints.
func TestClassifyLarkError_DriveCreateShortcutConstraints(t *testing.T) {
t.Parallel()
tests := []struct {
name string
code int
wantExitCode int
wantType string
wantHint string
}{
{
name: "resource contention",
code: LarkErrDriveResourceContention,
wantExitCode: ExitAPI,
wantType: "conflict",
wantHint: "avoid concurrent duplicate requests",
},
{
name: "cross tenant unit",
code: LarkErrDriveCrossTenantUnit,
wantExitCode: ExitAPI,
wantType: "cross_tenant_unit",
wantHint: "same tenant and region/unit",
},
{
name: "cross brand",
code: LarkErrDriveCrossBrand,
wantExitCode: ExitAPI,
wantType: "cross_brand",
wantHint: "same brand environment",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
gotExitCode, gotType, gotHint := ClassifyLarkError(tt.code, "raw msg")
if gotExitCode != tt.wantExitCode {
t.Fatalf("exitCode=%d, want %d", gotExitCode, tt.wantExitCode)
}
if gotType != tt.wantType {
t.Fatalf("type=%q, want %q", gotType, tt.wantType)
}
if gotHint == "" {
t.Fatal("expected non-empty hint")
}
if !strings.Contains(gotHint, tt.wantHint) {
t.Fatalf("hint=%q, want substring %q", gotHint, tt.wantHint)
}
})
}
}

View File

@@ -0,0 +1,136 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"fmt"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
var driveCreateShortcutAllowedTypes = map[string]bool{
"file": true,
"docx": true,
"bitable": true,
"doc": true,
"sheet": true,
"mindnote": true,
"slides": true,
}
type driveCreateShortcutSpec struct {
FileToken string
FileType string
FolderToken string
}
func newDriveCreateShortcutSpec(runtime *common.RuntimeContext) driveCreateShortcutSpec {
return driveCreateShortcutSpec{
FileToken: strings.TrimSpace(runtime.Str("file-token")),
FileType: strings.ToLower(strings.TrimSpace(runtime.Str("type"))),
FolderToken: strings.TrimSpace(runtime.Str("folder-token")),
}
}
// RequestBody builds the create_shortcut API payload from the shortcut spec.
func (s driveCreateShortcutSpec) RequestBody() map[string]interface{} {
return map[string]interface{}{
"parent_token": s.FolderToken,
"refer_entity": map[string]interface{}{
"refer_token": s.FileToken,
"refer_type": s.FileType,
},
}
}
// DriveCreateShortcut creates a Drive shortcut for an existing file in another folder.
var DriveCreateShortcut = common.Shortcut{
Service: "drive",
Command: "+create-shortcut",
Description: "Create a Drive shortcut in another folder",
Risk: "write",
Scopes: []string{"space:document:shortcut"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "file-token", Desc: "source file token to reference", Required: true},
{Name: "type", Desc: "source file type (file, docx, bitable, doc, sheet, mindnote, slides)", Required: true},
{Name: "folder-token", Desc: "target folder token for the new shortcut", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateDriveCreateShortcutSpec(newDriveCreateShortcutSpec(runtime))
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
spec := newDriveCreateShortcutSpec(runtime)
return common.NewDryRunAPI().
Desc("Create a Drive shortcut").
POST("/open-apis/drive/v1/files/create_shortcut").
Desc("[1] Create shortcut").
Body(spec.RequestBody())
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec := newDriveCreateShortcutSpec(runtime)
fmt.Fprintf(
runtime.IO().ErrOut,
"Creating shortcut for %s %s in folder %s...\n",
spec.FileType,
common.MaskToken(spec.FileToken),
common.MaskToken(spec.FolderToken),
)
data, err := runtime.CallAPI(
"POST",
"/open-apis/drive/v1/files/create_shortcut",
nil,
spec.RequestBody(),
)
if err != nil {
return err
}
out := map[string]interface{}{
"created": true,
"source_file_token": spec.FileToken,
"source_type": spec.FileType,
"folder_token": spec.FolderToken,
}
if shortcutToken := common.GetString(data, "succ_shortcut_node", "token"); shortcutToken != "" {
out["shortcut_token"] = shortcutToken
}
if url := common.GetString(data, "succ_shortcut_node", "url"); url != "" {
out["url"] = url
}
if title := common.GetString(data, "succ_shortcut_node", "name"); title != "" {
out["title"] = title
}
runtime.Out(out, nil)
return nil
},
}
// validateDriveCreateShortcutSpec validates shortcut creation inputs before API execution.
func validateDriveCreateShortcutSpec(spec driveCreateShortcutSpec) error {
if err := validate.ResourceName(spec.FileToken, "--file-token"); err != nil {
return output.ErrValidation("%s", err)
}
if err := validate.ResourceName(spec.FolderToken, "--folder-token"); err != nil {
return output.ErrValidation("%s", err)
}
if spec.FileType == "wiki" {
return output.ErrValidation("unsupported file type: wiki. This shortcut only supports Drive file tokens; wiki documents must be resolved to their underlying file token first")
}
if spec.FileType == "folder" {
return output.ErrValidation("unsupported file type: folder. The create_shortcut API only supports Drive files, not folders")
}
if !driveCreateShortcutAllowedTypes[spec.FileType] {
return output.ErrValidation("unsupported file type: %s. Supported types: file, docx, bitable, doc, sheet, mindnote, slides", spec.FileType)
}
return nil
}

View File

@@ -0,0 +1,336 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
// TestValidateDriveCreateShortcutSpecRejectsUnsupportedTypes verifies unsupported source types are rejected early.
func TestValidateDriveCreateShortcutSpecRejectsUnsupportedTypes(t *testing.T) {
t.Parallel()
tests := []struct {
name string
spec driveCreateShortcutSpec
wantErr string
}{
{
name: "wiki",
spec: driveCreateShortcutSpec{
FileToken: "wiki_token_test",
FileType: "wiki",
FolderToken: "target_folder_token_test",
},
wantErr: "underlying file token first",
},
{
name: "folder",
spec: driveCreateShortcutSpec{
FileToken: "folder_token_test",
FileType: "folder",
FolderToken: "target_folder_token_test",
},
wantErr: "not folders",
},
{
name: "shortcut",
spec: driveCreateShortcutSpec{
FileToken: "shortcut_token_test",
FileType: "shortcut",
FolderToken: "target_folder_token_test",
},
wantErr: "Supported types",
},
{
name: "missing folder token",
spec: driveCreateShortcutSpec{
FileToken: "file_token_test",
FileType: "docx",
},
wantErr: "--folder-token must not be empty",
},
{
name: "unknown",
spec: driveCreateShortcutSpec{
FileToken: "file_token_test",
FileType: "unknown",
FolderToken: "target_folder_token_test",
},
wantErr: "Supported types",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := validateDriveCreateShortcutSpec(tt.spec)
if err == nil {
t.Fatal("expected validation error, got nil")
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("error = %q, want substring %q", err.Error(), tt.wantErr)
}
})
}
}
// TestDriveCreateShortcutDryRunIncludesSingleCreateRequest verifies dry-run only previews the create request.
func TestDriveCreateShortcutDryRunIncludesSingleCreateRequest(t *testing.T) {
t.Parallel()
cmd := &cobra.Command{Use: "drive +create-shortcut"}
cmd.Flags().String("file-token", "", "")
cmd.Flags().String("type", "", "")
cmd.Flags().String("folder-token", "", "")
if err := cmd.Flags().Set("file-token", " doc_token_test "); err != nil {
t.Fatalf("set --file-token: %v", err)
}
if err := cmd.Flags().Set("type", " DOCX "); err != nil {
t.Fatalf("set --type: %v", err)
}
if err := cmd.Flags().Set("folder-token", " folder_target_token_test "); err != nil {
t.Fatalf("set --folder-token: %v", err)
}
runtime := common.TestNewRuntimeContext(cmd, nil)
dry := DriveCreateShortcut.DryRun(context.Background(), runtime)
if dry == nil {
t.Fatal("DryRun returned nil")
}
data, err := json.Marshal(dry)
if err != nil {
t.Fatalf("marshal dry run: %v", err)
}
var got struct {
API []struct {
Method string `json:"method"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal dry run json: %v", err)
}
if len(got.API) != 1 {
t.Fatalf("expected 1 API call, got %d", len(got.API))
}
if got.API[0].Method != "POST" {
t.Fatalf("first method = %q, want POST", got.API[0].Method)
}
if got.API[0].Body["parent_token"] != "folder_target_token_test" {
t.Fatalf("parent_token = %#v, want folder_target_token_test", got.API[0].Body["parent_token"])
}
referEntity, _ := got.API[0].Body["refer_entity"].(map[string]interface{})
if referEntity["refer_token"] != "doc_token_test" || referEntity["refer_type"] != "docx" {
t.Fatalf("unexpected refer_entity: %#v", referEntity)
}
}
// TestDriveCreateShortcutUsesProvidedFolderToken verifies execution uses the explicit target folder token.
func TestDriveCreateShortcutUsesProvidedFolderToken(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
createStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/create_shortcut",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"succ_shortcut_node": map[string]interface{}{
"token": "shortcut_token_test",
"name": "shortcut_name_test",
"type": "docx",
"parent_token": "folder_target_token_test",
"url": "https://example.feishu.cn/docx/shortcut_token_test",
"shortcut_info": map[string]interface{}{
"target_type": "docx",
"target_token": "doc_token_test",
},
},
},
},
}
reg.Register(createStub)
err := mountAndRunDrive(t, DriveCreateShortcut, []string{
"+create-shortcut",
"--file-token", " doc_token_test ",
"--type", " DOCX ",
"--folder-token", " folder_target_token_test ",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
body := decodeCapturedJSONBody(t, createStub)
if body["parent_token"] != "folder_target_token_test" {
t.Fatalf("parent_token = %#v, want folder_target_token_test", body["parent_token"])
}
referEntity, _ := body["refer_entity"].(map[string]interface{})
if referEntity["refer_token"] != "doc_token_test" || referEntity["refer_type"] != "docx" {
t.Fatalf("unexpected refer_entity: %#v", referEntity)
}
data := decodeDriveEnvelope(t, stdout)
if data["shortcut_token"] != "shortcut_token_test" {
t.Fatalf("shortcut_token = %#v, want shortcut_token_test", data["shortcut_token"])
}
if data["folder_token"] != "folder_target_token_test" {
t.Fatalf("folder_token = %#v, want folder_target_token_test", data["folder_token"])
}
if data["source_file_token"] != "doc_token_test" {
t.Fatalf("source_file_token = %#v, want doc_token_test", data["source_file_token"])
}
if data["title"] != "shortcut_name_test" {
t.Fatalf("title = %#v, want shortcut_name_test", data["title"])
}
if data["url"] != "https://example.feishu.cn/docx/shortcut_token_test" {
t.Fatalf("url = %#v, want https://example.feishu.cn/docx/shortcut_token_test", data["url"])
}
if data["created"] != true {
t.Fatalf("created = %#v, want true", data["created"])
}
}
// TestDriveCreateShortcutValidateRequiresFolderToken verifies folder-token is mandatory.
func TestDriveCreateShortcutValidateRequiresFolderToken(t *testing.T) {
err := validateDriveCreateShortcutSpec(driveCreateShortcutSpec{
FileToken: "doc_token_test",
FileType: "docx",
})
if err == nil {
t.Fatal("expected validation error, got nil")
}
if !strings.Contains(err.Error(), "--folder-token must not be empty") {
t.Fatalf("unexpected error: %v", err)
}
}
// TestDriveCreateShortcutValidateRejectsWhitespaceOnlyFolderToken verifies runtime normalization rejects blank folder tokens.
func TestDriveCreateShortcutValidateRejectsWhitespaceOnlyFolderToken(t *testing.T) {
t.Parallel()
cmd := &cobra.Command{Use: "drive +create-shortcut"}
cmd.Flags().String("file-token", "", "")
cmd.Flags().String("type", "", "")
cmd.Flags().String("folder-token", "", "")
if err := cmd.Flags().Set("file-token", "doc_token_test"); err != nil {
t.Fatalf("set --file-token: %v", err)
}
if err := cmd.Flags().Set("type", " DOCX "); err != nil {
t.Fatalf("set --type: %v", err)
}
if err := cmd.Flags().Set("folder-token", " "); err != nil {
t.Fatalf("set --folder-token: %v", err)
}
runtime := common.TestNewRuntimeContext(cmd, nil)
err := DriveCreateShortcut.Validate(context.Background(), runtime)
if err == nil {
t.Fatal("expected validation error, got nil")
}
if !strings.Contains(err.Error(), "--folder-token must not be empty") {
t.Fatalf("unexpected error: %v", err)
}
}
// TestDriveCreateShortcutClassifiesKnownAPIConstraints verifies known API constraints surface as structured errors.
func TestDriveCreateShortcutClassifiesKnownAPIConstraints(t *testing.T) {
t.Parallel()
tests := []struct {
name string
code int
msg string
wantType string
wantHint string
wantMsgPart string
}{
{
name: "resource contention",
code: output.LarkErrDriveResourceContention,
msg: "resource contention occurred, please retry",
wantType: "conflict",
wantHint: "avoid concurrent duplicate requests",
wantMsgPart: "resource contention occurred",
},
{
name: "cross tenant and unit",
code: output.LarkErrDriveCrossTenantUnit,
msg: "cross tenant and unit not support",
wantType: "cross_tenant_unit",
wantHint: "same tenant and region/unit",
wantMsgPart: "cross tenant and unit not support",
},
{
name: "cross brand",
code: output.LarkErrDriveCrossBrand,
msg: "cross brand not support",
wantType: "cross_brand",
wantHint: "same brand environment",
wantMsgPart: "cross brand not support",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/create_shortcut",
Body: map[string]interface{}{
"code": float64(tt.code),
"msg": tt.msg,
},
})
err := mountAndRunDrive(t, DriveCreateShortcut, []string{
"+create-shortcut",
"--file-token", "doc_token_test",
"--type", "docx",
"--folder-token", "folder_token_test",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected API error, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected structured exit error, got %v", err)
}
if exitErr.Code != output.ExitAPI {
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitAPI)
}
if exitErr.Detail.Type != tt.wantType {
t.Fatalf("type = %q, want %q", exitErr.Detail.Type, tt.wantType)
}
if exitErr.Detail.Code != tt.code {
t.Fatalf("detail code = %d, want %d", exitErr.Detail.Code, tt.code)
}
if !strings.Contains(exitErr.Detail.Message, tt.wantMsgPart) {
t.Fatalf("message = %q, want substring %q", exitErr.Detail.Message, tt.wantMsgPart)
}
if !strings.Contains(exitErr.Detail.Hint, tt.wantHint) {
t.Fatalf("hint = %q, want substring %q", exitErr.Detail.Hint, tt.wantHint)
}
})
}
}

View File

@@ -9,6 +9,7 @@ import "github.com/larksuite/cli/shortcuts/common"
func Shortcuts() []common.Shortcut {
return []common.Shortcut{
DriveUpload,
DriveCreateShortcut,
DriveDownload,
DriveAddComment,
DriveExport,

View File

@@ -5,12 +5,14 @@ package drive
import "testing"
// TestShortcutsIncludesExpectedCommands verifies the drive shortcut registry contains the expected commands.
func TestShortcutsIncludesExpectedCommands(t *testing.T) {
t.Parallel()
got := Shortcuts()
want := []string{
"+upload",
"+create-shortcut",
"+download",
"+add-comment",
"+export",

View File

@@ -192,36 +192,23 @@ var MailWatch = common.Shortcut{
msgFormat := runtime.Str("msg-format")
outputDir := runtime.Str("output-dir")
if outputDir != "" {
if outputDir == "~" || strings.HasPrefix(outputDir, "~/") {
home, err := vfs.UserHomeDir()
if err != nil {
return fmt.Errorf("cannot expand ~: %w", err)
}
if outputDir == "~" {
outputDir = home
} else {
outputDir = filepath.Join(home, outputDir[2:])
}
} else if filepath.IsAbs(outputDir) {
outputDir = filepath.Clean(outputDir)
} else {
safePath, err := validate.SafeOutputPath(outputDir)
if err != nil {
return err
}
outputDir = safePath
// Reject all tilde-prefixed paths — SafeOutputPath treats "~/x" as a
// literal relative path (creating a directory named "~"), which is
// confusing. This also covers ~user/path forms.
if strings.HasPrefix(outputDir, "~") {
return output.ErrValidation("--output-dir does not support ~ expansion; use a relative path like ./output instead")
}
// Resolve symlinks on the output directory so all writes use the real
// filesystem path. This prevents a symlink from redirecting writes to
// an unintended location (TOCTOU mitigation).
// Enforce CWD containment: reject absolute paths, path traversal,
// and symlink escapes. SafeOutputPath returns a resolved absolute path
// under CWD, preventing writes to arbitrary system directories.
safePath, err := validate.SafeOutputPath(outputDir)
if err != nil {
return err
}
outputDir = safePath
if err := vfs.MkdirAll(outputDir, 0700); err != nil {
return fmt.Errorf("cannot create output directory %q: %w", outputDir, err)
}
resolved, err := filepath.EvalSymlinks(outputDir)
if err != nil {
return fmt.Errorf("cannot resolve output directory: %w", err)
}
outputDir = resolved
}
labelIDsInput := runtime.Str("label-ids")
folderIDsInput := runtime.Str("folder-ids")

View File

@@ -30,6 +30,7 @@ var AddTaskToTasklist = common.Shortcut{
Flags: []common.Flag{
{Name: "tasklist-id", Desc: "tasklist id", Required: true},
{Name: "task-id", Desc: "task id (comma-separated for multiple)", Required: true},
{Name: "section-guid", Desc: "section guid"},
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
@@ -40,6 +41,10 @@ var AddTaskToTasklist = common.Shortcut{
"tasklist_guid": extractTasklistGuid(runtime.Str("tasklist-id")),
}
if sectionGuid := strings.TrimSpace(runtime.Str("section-guid")); sectionGuid != "" {
body["section_guid"] = sectionGuid
}
return common.NewDryRunAPI().
POST("/open-apis/task/v2/tasks/" + taskId + "/add_tasklist").
Params(map[string]interface{}{"user_id_type": "open_id"}).
@@ -57,6 +62,10 @@ var AddTaskToTasklist = common.Shortcut{
"tasklist_guid": tasklistGuid,
}
if sectionGuid := strings.TrimSpace(runtime.Str("section-guid")); sectionGuid != "" {
body["section_guid"] = sectionGuid
}
var successful []map[string]interface{}
var failed []map[string]interface{}

View File

@@ -0,0 +1,43 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"strings"
"testing"
"github.com/larksuite/cli/internal/httpmock"
)
func TestAddTaskToTasklist_Success(t *testing.T) {
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/tasks/task-1/add_tasklist",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"task": map[string]interface{}{
"guid": "task-1",
},
},
},
})
s := AddTaskToTasklist
s.AuthTypes = []string{"bot", "user"}
args := []string{"+tasklist-task-add", "--tasklist-id", "tl-123", "--task-id", "task-1", "--section-guid", "sec-456", "--as", "bot", "--format", "json"}
err := runMountedTaskShortcut(t, s, args, f, stdout)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
out := stdout.String()
if !strings.Contains(out, `"tasklist_guid":"tl-123"`) && !strings.Contains(out, `"tasklist_guid": "tl-123"`) {
t.Errorf("expected tasklist_guid in output, got: %s", out)
}
}

View File

@@ -134,7 +134,8 @@ lark-cli docs +create --title "产品需求" --markdown '<callout emoji="💡" b
- 使用标准 Markdown 语法作为基础
- 使用自定义 XML 标签实现飞书特有功能(具体标签见各功能章节)
- 需要显示特殊字符时使用反斜杠转义:`* ~ ` $ [ ] < > { } | ^`
- 只有当字符会被解释为 Markdown / Lark 富文本语法时,才需要使用反斜杠转义:``* ~ ` $ [ ] < > { } | ^``
- 普通文本中的孤立字符不要过度转义。例如 `5 * 3``version~1.0``final_trajectory` 通常应保持原样,只有像 `*斜体*``**粗体**``~~删除线~~` 这种会触发格式化的写法,想按字面量显示时才需要转义
---
@@ -657,7 +658,7 @@ $$
## 最佳实践
- **空行分隔**:不同块类型之间用空行分隔
- **转义字符**特殊字符用 `\` 转义:`\*` `\~` `\``
- **转义字符**只有在字符会触发格式化时才用 `\` 转义。例如想输出字面量 `*斜体*` 时写成 `\*斜体\*`;但 `5 * 3``version~1.0``final_trajectory` 这类普通文本通常不需要转义
- **图片**:使用 URL系统自动下载上传
- **分栏**:列宽总和必须为 100
- **表格选择**:简单数据用 Markdown复杂嵌套用 `<lark-table>`

View File

@@ -194,6 +194,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive +<verb> [flags]`
|----------|------|
| [`+upload`](references/lark-drive-upload.md) | Upload a local file to Drive |
| [`+download`](references/lark-drive-download.md) | Download a file from Drive to local |
| [`+create-shortcut`](references/lark-drive-create-shortcut.md) | Create a shortcut to an existing Drive file in another folder |
| [`+add-comment`](references/lark-drive-add-comment.md) | Add a full-document comment, or a local comment to selected docx text (also supports wiki URL resolving to doc/docx) |
| [`+export`](references/lark-drive-export.md) | Export a doc/docx/sheet/bitable to a local file with limited polling |
| [`+export-download`](references/lark-drive-export-download.md) | Download an exported file by file_token |

View File

@@ -0,0 +1,103 @@
# drive +create-shortcut
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
在目标文件夹中为一个现有 Drive 文件创建快捷方式。
## 命令
```bash
# 为普通文件创建快捷方式
lark-cli drive +create-shortcut \
--folder-token <TARGET_FOLDER_TOKEN> \
--file-token <FILE_TOKEN> \
--type file
# 为新版文档创建快捷方式
lark-cli drive +create-shortcut \
--folder-token <TARGET_FOLDER_TOKEN> \
--file-token <DOCX_TOKEN> \
--type docx
# 为电子表格创建快捷方式
lark-cli drive +create-shortcut \
--folder-token <TARGET_FOLDER_TOKEN> \
--file-token <SHEET_TOKEN> \
--type sheet
# 仅预览即将发起的请求,不真正执行
lark-cli drive +create-shortcut \
--folder-token <TARGET_FOLDER_TOKEN> \
--file-token <DOCX_TOKEN> \
--type docx \
--dry-run
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--folder-token` | 是 | 目标父文件夹 token |
| `--file-token` | 是 | 源文件 token表示被引用的原始文件 |
| `--type` | 是 | 源文件类型,推荐值:`file``docx``doc``sheet``bitable``mindnote``slides` |
## 输入规则
- 该 shortcut 的最小输入是 `--folder-token` + `--file-token` + `--type`
- CLI 层会把 `--file-token``--type` 组装为底层 API 所需的 `refer_entity`
- `--file-token` 必须是 Drive 文件 token不要直接传 wiki 节点 token
- 如果来源是 `/wiki/...` 链接,必须先按 [`lark-drive`](../SKILL.md) 中的 wiki 解析流程拿到真实 `obj_token`,再创建快捷方式
- 目标位置必须是云空间文件夹;这个 shortcut 不是“复制文件内容”,而是“在另一个文件夹里挂一个引用入口”
## 类型说明
| 类型 | 说明 |
|------|------|
| `file` | 普通文件 |
| `docx` | 新版云文档 |
| `doc` | 旧版云文档 |
| `sheet` | 电子表格 |
| `bitable` | 多维表格 |
| `mindnote` | 思维笔记 |
| `slides` | 幻灯片 |
## 行为说明
- 成功时会调用 `POST /open-apis/drive/v1/files/create_shortcut`
- 该 shortcut 继承通用能力,可配合 `--as user|bot|auto``--format``--jq``--dry-run` 使用
- `--dry-run` 只输出请求方法、路径、身份和请求体预览,不会真正创建快捷方式
- 这是写入操作;执行前应确认目标文件夹和源文件都准确无误
## 限制
- 该接口不支持并发调用
- 调用频率上限为 5 QPS且 10000 次/天
- 不支持跨租户、跨地域创建快捷方式
- 不支持跨品牌创建快捷方式
- 如果目标父文件夹单层挂载数量超过限制,会返回 `1062507`
## 权限要求
- 当前调用身份需要能访问源文件
- 当前调用身份需要对目标文件夹有编辑权限
- 如果权限不足,常见表现为 `1061004 forbidden`
## 常见错误
| 错误码 / 错误信息 | 原因 | 处理建议 |
|------|------|------|
| `1061002 params error` | 缺少必填参数,或 `--file-token` / `--type` 组合无法构成有效源文件信息 | 检查 `--file-token``--type` 是否完整且匹配;如显式传了 `--folder-token`,再确认其值有效 |
| `1061003 not found` | 源文件或目标文件夹不存在 | 重新确认 token 是否正确、资源是否已删除 |
| `1061004 forbidden` | 对源文件没有访问权限,或对目标文件夹没有编辑权限 | 切换到有权限的身份,或先授予文档 / 文件夹权限 |
| `1061005 auth failed` | 身份类型或 access token 不正确 | 检查 `--as` 使用的身份及当前登录态 |
| `1061007 file has been delete` | 源文件已删除 | 确认原文件仍存在,再重新执行 |
| `1062507 parent node out of sibling num` | 目标文件夹单层挂载数超过上限 | 清理目标目录,或换一个父文件夹 |
| `1061045 resource contention occurred, please retry` | 平台内部资源争抢 | 稍后重试,不要并发重复调用 |
| `1064510 cross tenant and unit not support` | 跨租户或跨地域请求 | 改为在同租户、同地域范围内操作 |
| `1064511 cross brand not support` | 跨品牌请求 | 改为在同品牌环境内操作 |
## 参考
- [lark-drive](../SKILL.md) -- 云空间全部命令
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数

View File

@@ -83,6 +83,15 @@ lark-cli task <resource> <method> [flags] # 调用 API
- `add` — 添加任务成员
- `remove` — 移除任务成员
### sections
- `create` — 创建自定义分组
- `delete` — 删除自定义分组
- `get` — 获取自定义分组详情
- `list` — 获取自定义分组列表
- `patch` — 更新自定义分组
- `tasks` — 获取自定义分组任务列表
## 权限表
| 方法 | 所需 scope |
@@ -104,3 +113,9 @@ lark-cli task <resource> <method> [flags] # 调用 API
| `subtasks.list` | `task:task:read` |
| `members.add` | `task:task:write` |
| `members.remove` | `task:task:write` |
| `sections.create` | `task:section:write` |
| `sections.delete` | `task:section:write` |
| `sections.get` | `task:section:read` |
| `sections.list` | `task:section:read` |
| `sections.patch` | `task:section:write` |
| `sections.tasks` | `task:section:read` |

View File

@@ -12,6 +12,12 @@ lark-cli task +tasklist-task-add --tasklist-id "<tasklist_guid>" --task-id "<tas
# Add multiple tasks to a tasklist
lark-cli task +tasklist-task-add --tasklist-id "<tasklist_guid>" --task-id "<task_guid>,<another_task_guid>,<third_task_guid>"
# Add a task to a specific section in the tasklist
lark-cli task +tasklist-task-add \
--tasklist-id "<tasklist_guid>" \
--task-id "<task_guid>" \
--section-guid "<section_guid>"
```
## Parameters
@@ -20,6 +26,7 @@ lark-cli task +tasklist-task-add --tasklist-id "<tasklist_guid>" --task-id "<tas
|-----------|----------|-------------|
| `--tasklist-id <guid>` | Yes | The GUID of the tasklist, or a full AppLink URL. |
| `--task-id <guids>` | Yes | Comma-separated list of task GUIDs to add to the tasklist. For Feishu task applinks, use each task's `guid` query parameter, not the `suite_entity_num` / display task ID like `t104121`. |
| `--section-guid <guid>` | No | The GUID of the custom section to add the tasks to. If omitted, tasks will be added to the default section. |
## Workflow

View File

@@ -0,0 +1,77 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
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 TestBase_BasicWorkflow(t *testing.T) {
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute)
t.Cleanup(cancel)
baseName := "lark-cli-e2e-base-basic-" + clie2e.GenerateSuffix()
baseToken := createBaseWithRetry(t, ctx, baseName)
t.Run("get base", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"base", "+base-get", "--base-token", baseToken},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
returnedBaseToken := gjson.Get(result.Stdout, "data.base.app_token").String()
if returnedBaseToken == "" {
returnedBaseToken = gjson.Get(result.Stdout, "data.base.base_token").String()
}
assert.Equal(t, baseToken, returnedBaseToken, "stdout:\n%s", result.Stdout)
assert.NotEmpty(t, gjson.Get(result.Stdout, "data.base.name").String(), "stdout:\n%s", result.Stdout)
})
tableName := "lark-cli-e2e-table-basic-" + clie2e.GenerateSuffix()
tableID, primaryFieldID, primaryViewID := createTableWithRetry(
t,
parentT,
ctx,
baseToken,
tableName,
`[{"name":"Name","type":"text"}]`,
`{"name":"Main","type":"grid"}`,
)
t.Run("get table", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"base", "+table-get", "--base-token", baseToken, "--table-id", tableID},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
assert.Equal(t, tableID, gjson.Get(result.Stdout, "data.table.id").String())
assert.Equal(t, tableName, gjson.Get(result.Stdout, "data.table.name").String())
})
t.Run("list tables and find created table", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"base", "+table-list", "--base-token", baseToken},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
assert.True(t, gjson.Get(result.Stdout, `data.tables.#(id=="`+tableID+`")`).Exists(), "stdout:\n%s", result.Stdout)
})
require.NotEmpty(t, primaryFieldID)
require.NotEmpty(t, primaryViewID)
}

View File

@@ -0,0 +1,127 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
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 TestBase_RoleWorkflow(t *testing.T) {
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute)
t.Cleanup(cancel)
baseToken := createBaseWithRetry(t, ctx, "lark-cli-e2e-base-role-"+clie2e.GenerateSuffix())
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"base", "+advperm-enable", "--base-token", baseToken},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
roleName := "Reviewer-" + clie2e.GenerateSuffix()
createRole(t, ctx, baseToken, `{"role_name":"`+roleName+`","role_type":"custom_role"}`)
roleID := ""
parentT.Cleanup(func() {
if roleID == "" {
return
}
cleanupCtx, cancel := cleanupContext()
defer cancel()
deleteResult, deleteErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{
Args: []string{"base", "+role-delete", "--base-token", baseToken, "--role-id", roleID, "--yes"},
DefaultAs: "bot",
})
if deleteErr != nil || deleteResult.ExitCode != 0 {
reportCleanupFailure(parentT, "delete role "+roleID, deleteResult, deleteErr)
}
})
t.Run("list", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"base", "+role-list", "--base-token", baseToken},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
roleListPayload := gjson.Get(result.Stdout, "data.data").String()
require.NotEmpty(t, roleListPayload, "stdout:\n%s", result.Stdout)
assert.True(t, gjson.Valid(roleListPayload), "role list payload should be valid JSON: %s", roleListPayload)
roleItems := gjson.Get(roleListPayload, "base_roles").Array()
assert.NotEmpty(t, roleItems, "role list should contain at least one role: %s", roleListPayload)
found := false
for _, item := range roleItems {
rolePayload := item.String()
if !gjson.Valid(rolePayload) {
continue
}
if gjson.Get(rolePayload, "role_name").String() == roleName {
roleID = gjson.Get(rolePayload, "role_id").String()
found = true
break
}
}
require.True(t, found, "stdout:\n%s", result.Stdout)
require.NotEmpty(t, roleID, "stdout:\n%s", result.Stdout)
})
t.Run("get", func(t *testing.T) {
require.NotEmpty(t, roleID, "role ID should be resolved before get")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"base", "+role-get", "--base-token", baseToken, "--role-id", roleID},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
rolePayload := gjson.Get(result.Stdout, "data.data").String()
require.NotEmpty(t, rolePayload, "stdout:\n%s", result.Stdout)
require.True(t, gjson.Valid(rolePayload), "stdout:\n%s", result.Stdout)
assert.Equal(t, roleID, gjson.Get(rolePayload, "role_id").String())
})
t.Run("update", func(t *testing.T) {
require.NotEmpty(t, roleID, "role ID should be resolved before update")
updatedRoleName := roleName + " Updated"
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"base", "+role-update", "--base-token", baseToken, "--role-id", roleID, "--json", `{"role_name":"` + updatedRoleName + `","role_type":"custom_role"}`, "--yes"},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
getResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"base", "+role-get", "--base-token", baseToken, "--role-id", roleID},
DefaultAs: "bot",
})
require.NoError(t, err)
getResult.AssertExitCode(t, 0)
getResult.AssertStdoutStatus(t, true)
rolePayload := gjson.Get(getResult.Stdout, "data.data").String()
require.NotEmpty(t, rolePayload, "stdout:\n%s", getResult.Stdout)
require.True(t, gjson.Valid(rolePayload), "stdout:\n%s", getResult.Stdout)
assert.Equal(t, updatedRoleName, gjson.Get(rolePayload, "role_name").String())
})
}

View File

@@ -0,0 +1,164 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"context"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
const cleanupTimeout = 30 * time.Second
func reportCleanupFailure(parentT *testing.T, prefix string, result *clie2e.Result, err error) {
parentT.Helper()
if err != nil {
parentT.Errorf("%s: %v", prefix, err)
return
}
if result == nil {
parentT.Errorf("%s: nil result", prefix)
return
}
if isCleanupSuppressedResult(result) {
return
}
parentT.Errorf("%s failed: exit=%d stdout=%s stderr=%s", prefix, result.ExitCode, result.Stdout, result.Stderr)
}
func cleanupContext() (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), cleanupTimeout)
}
func isCleanupSuppressedResult(result *clie2e.Result) bool {
if result == nil {
return false
}
raw := strings.TrimSpace(result.Stdout)
if raw == "" {
raw = strings.TrimSpace(result.Stderr)
}
if raw == "" {
return false
}
start := strings.LastIndex(raw, "\n{")
if start >= 0 {
start++
} else {
start = strings.Index(raw, "{")
}
if start < 0 {
return false
}
payload := raw[start:]
if !gjson.Valid(payload) {
return false
}
if gjson.Get(payload, "error.type").String() != "api_error" {
return false
}
if gjson.Get(payload, "error.detail.type").String() == "not_found" ||
strings.Contains(strings.ToLower(gjson.Get(payload, "error.message").String()), "not found") {
return true
}
return gjson.Get(payload, "error.code").Int() == 800004135 ||
strings.Contains(strings.ToLower(gjson.Get(payload, "error.message").String()), " limited")
}
func createBaseWithRetry(t *testing.T, ctx context.Context, name string) string {
t.Helper()
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{"base", "+base-create", "--name", name, "--time-zone", "Asia/Shanghai"},
DefaultAs: "bot",
}, clie2e.RetryOptions{})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
baseToken := gjson.Get(result.Stdout, "data.base.app_token").String()
if baseToken == "" {
baseToken = gjson.Get(result.Stdout, "data.base.base_token").String()
}
require.NotEmpty(t, baseToken, "stdout:\n%s", result.Stdout)
return baseToken
}
func createTableWithRetry(t *testing.T, parentT *testing.T, ctx context.Context, baseToken string, name string, fieldsJSON string, viewJSON string) (tableID string, primaryFieldID string, primaryViewID string) {
t.Helper()
args := []string{"base", "+table-create", "--base-token", baseToken, "--name", name}
if fieldsJSON != "" {
args = append(args, "--fields", fieldsJSON)
}
if viewJSON != "" {
args = append(args, "--view", viewJSON)
}
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: args,
DefaultAs: "bot",
}, clie2e.RetryOptions{})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
tableID = gjson.Get(result.Stdout, "data.table.id").String()
if tableID == "" {
tableID = gjson.Get(result.Stdout, "data.table.table_id").String()
}
require.NotEmpty(t, tableID, "stdout:\n%s", result.Stdout)
primaryFieldID = gjson.Get(result.Stdout, "data.fields.0.id").String()
if primaryFieldID == "" {
primaryFieldID = gjson.Get(result.Stdout, "data.fields.0.field_id").String()
}
primaryViewID = gjson.Get(result.Stdout, "data.views.0.id").String()
if primaryViewID == "" {
primaryViewID = gjson.Get(result.Stdout, "data.views.0.view_id").String()
}
parentT.Cleanup(func() {
cleanupCtx, cancel := cleanupContext()
defer cancel()
deleteResult, deleteErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{
Args: []string{"base", "+table-delete", "--base-token", baseToken, "--table-id", tableID, "--yes"},
DefaultAs: "bot",
})
if deleteErr != nil || deleteResult.ExitCode != 0 {
reportCleanupFailure(parentT, "delete table "+tableID, deleteResult, deleteErr)
}
})
return tableID, primaryFieldID, primaryViewID
}
func createRole(t *testing.T, ctx context.Context, baseToken string, body string) string {
t.Helper()
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{"base", "+role-create", "--base-token", baseToken, "--json", body},
DefaultAs: "bot",
}, clie2e.RetryOptions{})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
return gjson.Get(result.Stdout, "data.role_id").String()
}

View File

@@ -0,0 +1,86 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package calendar
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"
)
// TestCalendar_CreateEvent tests the workflow of creating a calendar event.
func TestCalendar_CreateEvent(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
suffix := clie2e.GenerateSuffix()
eventSummary := "lark-cli-e2e-event-" + suffix
eventDescription := "test event description"
startAt := time.Now().UTC().Add(1 * time.Hour).Truncate(time.Minute)
endAt := startAt.Add(1 * time.Hour)
startTime := startAt.Format(time.RFC3339)
endTime := endAt.Format(time.RFC3339)
var eventID string
calendarID := getPrimaryCalendarID(t, ctx)
t.Run("create event with shortcut", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"calendar", "+create",
"--summary", eventSummary,
"--start", startTime,
"--end", endTime,
"--calendar-id", calendarID,
"--description", eventDescription,
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
eventID = gjson.Get(result.Stdout, "data.event_id").String()
require.NotEmpty(t, eventID)
})
t.Run("verify event created", func(t *testing.T) {
require.NotEmpty(t, eventID)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"calendar", "events", "get"},
DefaultAs: "bot",
Params: map[string]any{
"calendar_id": calendarID,
"event_id": eventID,
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
assert.Equal(t, eventSummary, gjson.Get(result.Stdout, "data.event.summary").String())
assert.Equal(t, eventDescription, gjson.Get(result.Stdout, "data.event.description").String())
assert.Equal(t, unixSecondsRFC3339(startAt), gjson.Get(result.Stdout, "data.event.start_time.timestamp").String())
assert.Equal(t, unixSecondsRFC3339(endAt), gjson.Get(result.Stdout, "data.event.end_time.timestamp").String())
})
t.Run("delete event", func(t *testing.T) {
require.NotEmpty(t, eventID)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"calendar", "events", "delete"},
DefaultAs: "bot",
Params: map[string]any{
"calendar_id": calendarID,
"event_id": eventID,
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
})
}

View File

@@ -0,0 +1,136 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package calendar
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"
)
// TestCalendar_ManageCalendar tests the workflow of managing calendars.
func TestCalendar_ManageCalendar(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
suffix := clie2e.GenerateSuffix()
calendarSummary := "lark-cli-e2e-cal-" + suffix
updatedCalendarSummary := calendarSummary + "-updated"
calendarDescription := "test calendar created by e2e"
var createdCalendarID string
t.Run("list calendars", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"calendar", "calendars", "list"},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
require.NotEmpty(t, gjson.Get(result.Stdout, "data.calendar_list").Array(), "stdout:\n%s", result.Stdout)
})
t.Run("get primary calendar", func(t *testing.T) {
primaryCalendarID := getPrimaryCalendarID(t, ctx)
require.NotEmpty(t, primaryCalendarID)
})
t.Run("create calendar", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"calendar", "calendars", "create"},
DefaultAs: "bot",
Data: map[string]any{
"summary": calendarSummary,
"description": calendarDescription,
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
createdCalendarID = gjson.Get(result.Stdout, "data.calendar.calendar_id").String()
require.NotEmpty(t, createdCalendarID)
})
t.Run("get created calendar", func(t *testing.T) {
require.NotEmpty(t, createdCalendarID)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"calendar", "calendars", "get"},
DefaultAs: "bot",
Params: map[string]any{
"calendar_id": createdCalendarID,
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
assert.Equal(t, createdCalendarID, gjson.Get(result.Stdout, "data.calendar_id").String())
assert.Equal(t, calendarSummary, gjson.Get(result.Stdout, "data.summary").String())
assert.Equal(t, calendarDescription, gjson.Get(result.Stdout, "data.description").String())
})
t.Run("find created calendar in list", func(t *testing.T) {
require.NotEmpty(t, createdCalendarID)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"calendar", "calendars", "list"},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
require.True(t, gjson.Get(result.Stdout, `data.calendar_list.#(calendar_id=="`+createdCalendarID+`")`).Exists(), "stdout:\n%s", result.Stdout)
})
t.Run("update calendar", func(t *testing.T) {
require.NotEmpty(t, createdCalendarID)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"calendar", "calendars", "patch"},
DefaultAs: "bot",
Params: map[string]any{
"calendar_id": createdCalendarID,
},
Data: map[string]any{
"summary": updatedCalendarSummary,
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
})
t.Run("verify updated calendar", func(t *testing.T) {
require.NotEmpty(t, createdCalendarID)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"calendar", "calendars", "get"},
DefaultAs: "bot",
Params: map[string]any{
"calendar_id": createdCalendarID,
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
assert.Equal(t, updatedCalendarSummary, gjson.Get(result.Stdout, "data.summary").String())
})
t.Run("delete calendar", func(t *testing.T) {
require.NotEmpty(t, createdCalendarID)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"calendar", "calendars", "delete"},
DefaultAs: "bot",
Params: map[string]any{
"calendar_id": createdCalendarID,
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
})
}

View File

@@ -0,0 +1,35 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package calendar
import (
"context"
"strconv"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func getPrimaryCalendarID(t *testing.T, ctx context.Context) string {
t.Helper()
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"calendar", "calendars", "primary"},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
calendarID := gjson.Get(result.Stdout, "data.calendars.0.calendar.calendar_id").String()
require.NotEmpty(t, calendarID, "stdout:\n%s", result.Stdout)
return calendarID
}
func unixSecondsRFC3339(t time.Time) string {
return strconv.FormatInt(t.Unix(), 10)
}

View File

@@ -0,0 +1,51 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package contact
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
)
func TestContact_GetUser_BotWorkflow(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
var targetOpenID string
t.Run("discover-user-via-api", func(t *testing.T) {
// Bot identity cannot use +search-user or +get-user (self).
// However, it CAN call the raw API to list users if it has contact permissions.
// We use this to discover a real open_id for the next step.
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"api", "get", "/open-apis/contact/v3/users"},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
targetOpenID = gjson.Get(result.Stdout, "data.items.0.open_id").String()
require.NotEmpty(t, targetOpenID, "expected to find at least one user via raw API")
})
t.Run("get-user-by-id-as-bot", func(t *testing.T) {
require.NotEmpty(t, targetOpenID, "targetOpenID should be populated")
// DefaultAs is automatically "bot" in the clie2e framework
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"contact", "+get-user", "--user-id", targetOpenID},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
returnedID := gjson.Get(result.Stdout, "data.user.open_id").String()
require.Equal(t, targetOpenID, returnedID)
})
}

View File

@@ -16,6 +16,7 @@ import (
"strings"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/tidwall/gjson"
@@ -57,6 +58,15 @@ type Result struct {
RunErr error
}
// RetryOptions configures retry behavior for flaky external API calls.
type RetryOptions struct {
Attempts int
InitialDelay time.Duration
MaxDelay time.Duration
BackoffMultiple int
ShouldRetry func(*Result) bool
}
// RunCmd executes lark-cli and captures stdout/stderr/exit code.
func RunCmd(ctx context.Context, req Request) (*Result, error) {
binaryPath, err := ResolveBinaryPath(req)
@@ -99,6 +109,63 @@ func RunCmd(ctx context.Context, req Request) (*Result, error) {
return result, nil
}
// RunCmdWithRetry reruns a command when the result matches the configured retry condition.
func RunCmdWithRetry(ctx context.Context, req Request, opts RetryOptions) (*Result, error) {
if opts.Attempts <= 0 {
opts.Attempts = 4
}
if opts.InitialDelay <= 0 {
opts.InitialDelay = 1 * time.Second
}
if opts.MaxDelay <= 0 {
opts.MaxDelay = 6 * time.Second
}
if opts.BackoffMultiple <= 1 {
opts.BackoffMultiple = 2
}
if opts.ShouldRetry == nil {
opts.ShouldRetry = func(result *Result) bool {
return result != nil && result.ExitCode != 0
}
}
delay := opts.InitialDelay
var lastResult *Result
for attempt := 1; attempt <= opts.Attempts; attempt++ {
result, err := RunCmd(ctx, req)
if err != nil {
return nil, err
}
lastResult = result
if attempt == opts.Attempts || !opts.ShouldRetry(result) {
return result, nil
}
timer := time.NewTimer(delay)
select {
case <-ctx.Done():
timer.Stop()
return lastResult, nil
case <-timer.C:
}
nextDelay := delay * time.Duration(opts.BackoffMultiple)
if nextDelay > opts.MaxDelay {
delay = opts.MaxDelay
} else {
delay = nextDelay
}
}
return lastResult, nil
}
// GenerateSuffix returns a high-entropy UTC timestamp suffix suitable for remote test resource names.
func GenerateSuffix() string {
now := time.Now().UTC()
return fmt.Sprintf("%s-%09d", now.Format("20060102-150405"), now.Nanosecond())
}
// ResolveBinaryPath finds the CLI binary path using request, env, then PATH.
func ResolveBinaryPath(req Request) (string, error) {
if req.BinaryPath != "" {

View File

@@ -0,0 +1,48 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package docs
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"
)
// TestDocs_CreateAndFetchWorkflow tests the create and fetch lifecycle.
func TestDocs_CreateAndFetchWorkflow(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
suffix := clie2e.GenerateSuffix()
folderName := "lark-cli-e2e-docs-folder-" + suffix
docTitle := "lark-cli-e2e-docs-" + suffix
docContent := "# Test Document\n\nThis document was created by lark-cli e2e test."
folderToken := createDocsFolderWithRetry(t, ctx, folderName)
var docToken string
t.Run("create", func(t *testing.T) {
docToken = createDocWithRetry(t, ctx, folderToken, docTitle, docContent)
})
t.Run("fetch", func(t *testing.T) {
require.NotEmpty(t, docToken, "document token should be created before fetch")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"docs", "+fetch",
"--doc", docToken,
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
assert.Equal(t, docTitle, gjson.Get(result.Stdout, "data.title").String())
})
}

View File

@@ -0,0 +1,67 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package docs
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"
)
// TestDocs_UpdateWorkflow tests the create, update, and verify lifecycle.
func TestDocs_UpdateWorkflow(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
suffix := clie2e.GenerateSuffix()
folderName := "lark-cli-e2e-update-folder-" + suffix
originalTitle := "lark-cli-e2e-update-" + suffix
updatedTitle := "lark-cli-e2e-update-updated-" + suffix
originalContent := "# Original\n\nThis is the original content."
updatedContent := "# Updated\n\nThis is the updated content."
folderToken := createDocsFolderWithRetry(t, ctx, folderName)
var docToken string
t.Run("create", func(t *testing.T) {
docToken = createDocWithRetry(t, ctx, folderToken, originalTitle, originalContent)
})
t.Run("update-title-and-content", func(t *testing.T) {
require.NotEmpty(t, docToken, "document token should be created before update")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"docs", "+update",
"--doc", docToken,
"--mode", "overwrite",
"--markdown", updatedContent,
"--new-title", updatedTitle,
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
})
t.Run("verify", func(t *testing.T) {
require.NotEmpty(t, docToken, "document token should be created before verify")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"docs", "+fetch",
"--doc", docToken,
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
assert.Equal(t, updatedTitle, gjson.Get(result.Stdout, "data.title").String())
})
}

View File

@@ -0,0 +1,54 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package docs
import (
"context"
"testing"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func createDocsFolderWithRetry(t *testing.T, ctx context.Context, name string) string {
t.Helper()
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{"drive", "files", "create_folder"},
Data: map[string]any{
"name": name,
"folder_token": "",
},
}, clie2e.RetryOptions{})
require.NoError(t, err)
result.AssertExitCode(t, 0)
folderToken := gjson.Get(result.Stdout, "data.token").String()
require.NotEmpty(t, folderToken, "stdout:\n%s", result.Stdout)
return folderToken
}
func createDocWithRetry(t *testing.T, ctx context.Context, folderToken string, title string, markdown string) string {
t.Helper()
require.NotEmpty(t, folderToken, "folder token is required")
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{
"docs", "+create",
"--folder-token", folderToken,
"--title", title,
"--markdown", markdown,
},
}, clie2e.RetryOptions{})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
docToken := gjson.Get(result.Stdout, "data.doc_id").String()
require.NotEmpty(t, docToken, "stdout:\n%s", result.Stdout)
return docToken
}

View File

@@ -0,0 +1,29 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
)
// TestDrive_FilesCreateFolderWorkflow tests the files create_folder resource command.
func TestDrive_FilesCreateFolderWorkflow(t *testing.T) {
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
suffix := clie2e.GenerateSuffix()
folderName := "lark-cli-e2e-drive-folder-" + suffix
t.Run("create_folder", func(t *testing.T) {
folderToken := createDriveFolder(t, parentT, ctx, folderName)
if folderToken == "" {
t.Fatalf("folder token should be available")
}
})
}

View File

@@ -0,0 +1,44 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"testing"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// createDriveFolder creates a private folder for the current workflow and
// deletes it during cleanup.
func createDriveFolder(t *testing.T, parentT *testing.T, ctx context.Context, name string) string {
t.Helper()
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"drive", "files", "create_folder"},
DefaultAs: "bot",
Data: map[string]any{
"name": name,
"folder_token": "",
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
folderToken := gjson.Get(result.Stdout, "data.token").String()
require.NotEmpty(t, folderToken, "stdout:\n%s", result.Stdout)
parentT.Cleanup(func() {
clie2e.RunCmd(context.Background(), clie2e.Request{
Args: []string{"drive", "files", "delete"},
DefaultAs: "bot",
Params: map[string]any{"file_token": folderToken, "type": "folder"},
})
})
return folderToken
}

View File

@@ -0,0 +1,124 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
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"
)
// TestIM_ChatUpdateWorkflow tests the +chat-update shortcut.
func TestIM_ChatUpdateWorkflow(t *testing.T) {
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
suffix := clie2e.GenerateSuffix()
originalName := "lark-cli-e2e-im-update-" + suffix
updatedName := originalName + "-updated"
updatedDescription := "Updated description for e2e test"
chatID := createChat(t, parentT, ctx, originalName)
t.Run("update chat name", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"im", "+chat-update",
"--chat-id", chatID,
"--name", updatedName,
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
})
t.Run("update chat description", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"im", "+chat-update",
"--chat-id", chatID,
"--description", updatedDescription,
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
})
t.Run("get updated chat", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"im", "chats", "get"},
Params: map[string]any{"chat_id": chatID},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
assert.Equal(t, updatedName, gjson.Get(result.Stdout, "data.name").String())
assert.Equal(t, updatedDescription, gjson.Get(result.Stdout, "data.description").String())
})
}
// TestIM_ChatsGetWorkflow tests the im chats get command.
func TestIM_ChatsGetWorkflow(t *testing.T) {
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
suffix := clie2e.GenerateSuffix()
chatName := "lark-cli-e2e-chats-get-" + suffix
chatID := createChat(t, parentT, ctx, chatName)
t.Run("get chat info", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"im", "chats", "get"},
Params: map[string]any{"chat_id": chatID},
})
require.NoError(t, err)
t.Logf("chats get result: %s", result.Stdout)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
dataExists := gjson.Get(result.Stdout, "data").Exists()
require.True(t, dataExists, "data object should exist")
chatNameGot := gjson.Get(result.Stdout, "data.name").String()
require.Equal(t, chatName, chatNameGot)
})
}
// TestIM_ChatsLinkWorkflow tests the im chats link command.
func TestIM_ChatsLinkWorkflow(t *testing.T) {
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
suffix := clie2e.GenerateSuffix()
chatName := "lark-cli-e2e-chats-link-" + suffix
chatID := createChat(t, parentT, ctx, chatName)
t.Run("get chat share link", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"im", "chats", "link"},
Params: map[string]any{"chat_id": chatID},
Data: map[string]any{
"validity_period": "week",
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
shareLink := gjson.Get(result.Stdout, "data.share_link").String()
require.NotEmpty(t, shareLink, "share_link should not be empty")
t.Logf("Generated share link: %s", shareLink)
})
}

View File

@@ -0,0 +1,60 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"testing"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// createChat creates a private chat with the given name and returns the chatID.
// The chat will be automatically cleaned up via parentT.Cleanup().
// Note: Chat deletion is not available via lark-cli im command.
func createChat(t *testing.T, parentT *testing.T, ctx context.Context, name string) string {
t.Helper()
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"im", "+chat-create",
"--name", name,
"--type", "private",
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
chatID := gjson.Get(result.Stdout, "data.chat_id").String()
require.NotEmpty(t, chatID, "chat_id should not be empty")
parentT.Cleanup(func() {
// No IM chat delete command is currently available in lark-cli,
// so created chats are intentionally left in the test account.
})
return chatID
}
// sendMessage sends a text message to the specified chat and returns the messageID.
func sendMessage(t *testing.T, parentT *testing.T, ctx context.Context, chatID string, text string) string {
t.Helper()
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"im", "+messages-send",
"--chat-id", chatID,
"--text", text,
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
messageID := gjson.Get(result.Stdout, "data.message_id").String()
require.NotEmpty(t, messageID, "message_id should not be empty")
return messageID
}

View File

@@ -0,0 +1,53 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
)
// TestIM_MessagesReplyWorkflow tests the +messages-reply shortcut.
func TestIM_MessagesReplyWorkflow(t *testing.T) {
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
suffix := clie2e.GenerateSuffix()
chatName := "lark-cli-e2e-im-reply-" + suffix
originalMessage := "Original message for reply test"
replyText := "This is a reply"
chatID := createChat(t, parentT, ctx, chatName)
messageID := sendMessage(t, parentT, ctx, chatID, originalMessage)
t.Run("reply to message with text", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"im", "+messages-reply",
"--message-id", messageID,
"--text", replyText,
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
})
t.Run("reply to message with markdown", func(t *testing.T) {
markdownReply := "**Bold** reply"
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"im", "+messages-reply",
"--message-id", messageID,
"--markdown", markdownReply,
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
})
}

View File

@@ -0,0 +1,239 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"context"
"encoding/json"
"fmt"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestSheets_CRUDE2EWorkflow tests the full lifecycle of spreadsheet operations
// using all shortcut methods: +create, +read, +write, +append, +find, +info, +export
func TestSheets_CRUDE2EWorkflow(t *testing.T) {
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
suffix := clie2e.GenerateSuffix()
spreadsheetToken := ""
sheetID := ""
t.Run("create spreadsheet with +create", func(t *testing.T) {
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{"sheets", "+create", "--title", "lark-cli-e2e-sheets-" + suffix},
}, clie2e.RetryOptions{})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
spreadsheetToken = gjson.Get(result.Stdout, "data.spreadsheet_token").String()
require.NotEmpty(t, spreadsheetToken, "spreadsheet token should not be empty, stdout: %s", result.Stdout)
parentT.Cleanup(func() {
// Best-effort cleanup - spreadsheets don't have a direct delete shortcut
// The spreadsheet will be cleaned up by the test environment if needed
})
})
t.Run("get spreadsheet info with +info", func(t *testing.T) {
require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"sheets", "+info", "--spreadsheet-token", spreadsheetToken},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
assert.Equal(t, spreadsheetToken, gjson.Get(result.Stdout, "data.spreadsheet.spreadsheet.token").String())
sheetID = gjson.Get(result.Stdout, "data.sheets.sheets.0.sheet_id").String()
require.NotEmpty(t, sheetID, "sheet_id should not be empty, stdout: %s", result.Stdout)
})
t.Run("write data with +write", func(t *testing.T) {
require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required")
require.NotEmpty(t, sheetID, "sheet_id is required")
values := [][]any{
{"Name", "Age", "City"},
{"Alice", 25, "Beijing"},
{"Bob", 30, "Shanghai"},
}
valuesJSON, _ := json.Marshal(values)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"sheets", "+write",
"--spreadsheet-token", spreadsheetToken,
"--sheet-id", sheetID,
"--range", "A1:C3",
"--values", string(valuesJSON),
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
})
t.Run("read data with +read", func(t *testing.T) {
require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required")
require.NotEmpty(t, sheetID, "sheet_id is required")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"sheets", "+read",
"--spreadsheet-token", spreadsheetToken,
"--sheet-id", sheetID,
"--range", "A1:C3",
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
// Verify the data was written correctly
values := gjson.Get(result.Stdout, "data.valueRange.values")
require.True(t, values.IsArray(), "values should be an array, stdout: %s", result.Stdout)
assert.Equal(t, "Name", values.Array()[0].Array()[0].String())
assert.Equal(t, "Alice", values.Array()[1].Array()[0].String())
})
t.Run("append rows with +append", func(t *testing.T) {
require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required")
require.NotEmpty(t, sheetID, "sheet_id is required")
values := [][]any{{"Charlie", 28, "Guangzhou"}}
valuesJSON, _ := json.Marshal(values)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"sheets", "+append",
"--spreadsheet-token", spreadsheetToken,
"--sheet-id", sheetID,
"--range", "A4:C4",
"--values", string(valuesJSON),
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
})
t.Run("find cells with +find", func(t *testing.T) {
require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required")
require.NotEmpty(t, sheetID, "sheet_id is required")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"sheets", "+find",
"--spreadsheet-token", spreadsheetToken,
"--sheet-id", sheetID,
"--find", "Alice",
"--range", fmt.Sprintf("%s!A1:C10", sheetID),
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, true, gjson.Get(result.Stdout, "ok").Bool(), "stdout:\n%s", result.Stdout)
matchedCells := gjson.Get(result.Stdout, "data.find_result.matched_cells")
require.True(t, matchedCells.IsArray(), "matched_cells should be an array, stdout: %s", result.Stdout)
assert.True(t, len(matchedCells.Array()) > 0, "should find at least one cell containing 'Alice'")
})
t.Run("export spreadsheet with +export", func(t *testing.T) {
require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required")
// Export is an async operation; verify it initiates correctly
// The command may have filesystem race issues but the API call succeeds
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"sheets", "+export",
"--spreadsheet-token", spreadsheetToken,
"--file-extension", "xlsx",
},
})
require.NoError(t, err)
// Export initiates successfully and returns file_token even if there's a temp file race
assert.NotEmpty(t, gjson.Get(result.Stdout, "data.file_token").String(),
"export should return file_token, stdout: %s", result.Stdout)
})
}
// TestSheets_SpreadsheetsResource tests the spreadsheets resource methods
func TestSheets_SpreadsheetsResource(t *testing.T) {
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
suffix := clie2e.GenerateSuffix()
spreadsheetToken := ""
t.Run("create spreadsheet with spreadsheets create", func(t *testing.T) {
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{"sheets", "spreadsheets", "create"},
Data: map[string]any{
"title": "lark-cli-e2e-sheets-resource-" + suffix,
},
}, clie2e.RetryOptions{})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
spreadsheetToken = gjson.Get(result.Stdout, "data.spreadsheet.spreadsheet_token").String()
require.NotEmpty(t, spreadsheetToken, "spreadsheet token should not be empty, stdout: %s", result.Stdout)
parentT.Cleanup(func() {
// Best-effort cleanup
})
})
t.Run("get spreadsheet with spreadsheets get", func(t *testing.T) {
require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"sheets", "spreadsheets", "get"},
Params: map[string]any{"spreadsheet_token": spreadsheetToken},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
assert.Equal(t, spreadsheetToken, gjson.Get(result.Stdout, "data.spreadsheet.token").String())
assert.NotEmpty(t, gjson.Get(result.Stdout, "data.spreadsheet.url").String())
})
t.Run("patch spreadsheet with spreadsheets patch", func(t *testing.T) {
require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required")
updatedTitle := "lark-cli-e2e-sheets-patched-" + suffix
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"sheets", "spreadsheets", "patch"},
Params: map[string]any{"spreadsheet_token": spreadsheetToken},
Data: map[string]any{"title": updatedTitle},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
// Verify the title was updated by fetching the spreadsheet
getResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"sheets", "spreadsheets", "get"},
Params: map[string]any{"spreadsheet_token": spreadsheetToken},
})
require.NoError(t, err)
getResult.AssertExitCode(t, 0)
getResult.AssertStdoutStatus(t, 0)
// Verify the title was actually updated
assert.Equal(t, updatedTitle, gjson.Get(getResult.Stdout, "data.spreadsheet.title").String())
})
}

View File

@@ -0,0 +1,174 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"context"
"encoding/json"
"fmt"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestSheets_FilterWorkflow tests the spreadsheet sheet filter operations
func TestSheets_FilterWorkflow(t *testing.T) {
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
suffix := clie2e.GenerateSuffix()
spreadsheetToken := ""
sheetID := ""
t.Run("create spreadsheet with initial data", func(t *testing.T) {
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{"sheets", "+create", "--title", "lark-cli-e2e-sheets-filter-" + suffix},
}, clie2e.RetryOptions{})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
spreadsheetToken = gjson.Get(result.Stdout, "data.spreadsheet_token").String()
require.NotEmpty(t, spreadsheetToken, "spreadsheet token should not be empty, stdout: %s", result.Stdout)
parentT.Cleanup(func() {
// No sheets delete command is currently available in lark-cli,
// so created spreadsheets are intentionally left in the test account.
})
})
t.Run("get sheet info", func(t *testing.T) {
require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"sheets", "+info", "--spreadsheet-token", spreadsheetToken},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
sheetID = gjson.Get(result.Stdout, "data.sheets.sheets.0.sheet_id").String()
require.NotEmpty(t, sheetID, "sheet_id should not be empty, stdout: %s", result.Stdout)
})
t.Run("write test data for filtering", func(t *testing.T) {
require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required")
require.NotEmpty(t, sheetID, "sheet_id is required")
values := [][]any{
{"Name", "Score", "Grade"},
{"Alice", 85, "B"},
{"Bob", 92, "A"},
{"Charlie", 78, "C"},
{"Diana", 95, "A"},
}
valuesJSON, _ := json.Marshal(values)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"sheets", "+write",
"--spreadsheet-token", spreadsheetToken,
"--sheet-id", sheetID,
"--range", "A1:C5",
"--values", string(valuesJSON),
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
})
t.Run("create filter with spreadsheet.sheet.filters create", func(t *testing.T) {
require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required")
require.NotEmpty(t, sheetID, "sheet_id is required")
filterData := map[string]any{
"range": fmt.Sprintf("%s!A1:D5", sheetID),
"col": "C",
"filter_type": "multiValue",
"condition": map[string]any{
"filter_type": "multiValue",
"expected": []any{"A", "B"},
},
}
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"sheets", "spreadsheet.sheet.filters", "create"},
Params: map[string]any{
"spreadsheet_token": spreadsheetToken,
"sheet_id": sheetID,
},
Data: filterData,
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
})
t.Run("get filter with spreadsheet.sheet.filters get", func(t *testing.T) {
require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required")
require.NotEmpty(t, sheetID, "sheet_id is required")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"sheets", "spreadsheet.sheet.filters", "get"},
Params: map[string]any{
"spreadsheet_token": spreadsheetToken,
"sheet_id": sheetID,
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
filterInfo := gjson.Get(result.Stdout, "data.sheet_filter_info")
require.True(t, filterInfo.Exists(), "filter info should exist, stdout: %s", result.Stdout)
})
t.Run("update filter with spreadsheet.sheet.filters update", func(t *testing.T) {
require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required")
require.NotEmpty(t, sheetID, "sheet_id is required")
filterData := map[string]any{
"col": "B",
"filter_type": "number",
"condition": map[string]any{
"filter_type": "number",
"compare_type": "greater",
"expected": []any{80},
},
}
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"sheets", "spreadsheet.sheet.filters", "update"},
Params: map[string]any{
"spreadsheet_token": spreadsheetToken,
"sheet_id": sheetID,
},
Data: filterData,
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
})
t.Run("delete filter with spreadsheet.sheet.filters delete", func(t *testing.T) {
require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required")
require.NotEmpty(t, sheetID, "sheet_id is required")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"sheets", "spreadsheet.sheet.filters", "delete"},
Params: map[string]any{
"spreadsheet_token": spreadsheetToken,
"sheet_id": sheetID,
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
})
}

View File

@@ -19,7 +19,7 @@ func TestTask_CommentWorkflow(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
suffix := time.Now().UTC().Format("20060102-150405")
suffix := clie2e.GenerateSuffix()
commentContent := "lark-cli-e2e-comment-" + suffix
taskGUID := createTask(t, parentT, ctx, clie2e.Request{
Args: []string{"task", "+create"},

View File

@@ -19,7 +19,7 @@ func TestTask_ReminderWorkflow(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
suffix := time.Now().UTC().Format("20060102-150405")
suffix := clie2e.GenerateSuffix()
taskGUID := createTask(t, parentT, ctx, clie2e.Request{
Args: []string{"task", "+create"},
Data: map[string]any{
@@ -57,9 +57,9 @@ func TestTask_ReminderWorkflow(t *testing.T) {
})
t.Run("remove reminder", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{"task", "+reminder", "--task-id", taskGUID, "--remove"},
})
}, clie2e.RetryOptions{})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)

View File

@@ -19,7 +19,7 @@ func TestTask_StatusWorkflow(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
suffix := time.Now().UTC().Format("20060102-150405")
suffix := clie2e.GenerateSuffix()
taskGUID := createTask(t, parentT, ctx, clie2e.Request{
Args: []string{"task", "+create"},
Data: map[string]any{

View File

@@ -19,7 +19,7 @@ func TestTask_TasklistAddTaskWorkflow(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
suffix := time.Now().UTC().Format("20060102-150405")
suffix := clie2e.GenerateSuffix()
tasklistName := "lark-cli-e2e-tasklist-add-" + suffix
taskSummary := "lark-cli-e2e-tasklist-add-task-" + suffix

View File

@@ -19,7 +19,7 @@ func TestTask_TasklistWorkflow(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
suffix := time.Now().UTC().Format("20060102-150405")
suffix := clie2e.GenerateSuffix()
tasklistName := "lark-cli-e2e-tasklist-" + suffix
taskSummary := "lark-cli-e2e-task-in-tasklist-" + suffix
taskDescription := "created by tests/cli_e2e/task"

View File

@@ -0,0 +1,70 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package wiki
import (
"context"
"testing"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func createWikiNode(t *testing.T, ctx context.Context, req clie2e.Request) gjson.Result {
t.Helper()
result, err := clie2e.RunCmdWithRetry(ctx, req, clie2e.RetryOptions{})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
node := gjson.Get(result.Stdout, "data.node")
require.True(t, node.Exists(), "stdout:\n%s", result.Stdout)
return node
}
func findWikiNodeByToken(t *testing.T, ctx context.Context, spaceID string, nodeToken string) gjson.Result {
t.Helper()
require.NotEmpty(t, spaceID, "space ID is required")
require.NotEmpty(t, nodeToken, "node token is required")
pageToken := ""
seenPageTokens := map[string]struct{}{}
for {
params := map[string]any{
"space_id": spaceID,
"page_size": 50,
}
if pageToken != "" {
if _, seen := seenPageTokens[pageToken]; seen {
t.Fatalf("wiki node list pagination loop detected for space %q, repeated page_token %q", spaceID, pageToken)
}
seenPageTokens[pageToken] = struct{}{}
params["page_token"] = pageToken
}
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"wiki", "nodes", "list"},
DefaultAs: "bot",
Params: params,
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
node := gjson.Get(result.Stdout, `data.items.#(node_token=="`+nodeToken+`")`)
if node.Exists() {
return node
}
hasMore := gjson.Get(result.Stdout, "data.has_more").Bool()
pageToken = gjson.Get(result.Stdout, "data.page_token").String()
if !hasMore || pageToken == "" {
t.Fatalf("wiki node %q not found in listed pages, last stdout:\n%s", nodeToken, result.Stdout)
}
}
}

View File

@@ -0,0 +1,150 @@
// 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) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
suffix := clie2e.GenerateSuffix()
createdTitle := "lark-cli-e2e-wiki-create-" + suffix
copiedTitle := "lark-cli-e2e-wiki-copy-" + suffix
var spaceID string
var createdNodeToken string
var createdObjToken string
var copiedNodeToken string
t.Run("create node", func(t *testing.T) {
node := createWikiNode(t, ctx, clie2e.Request{
Args: []string{"wiki", "nodes", "create"},
DefaultAs: "bot",
Params: map[string]any{
"space_id": "my_library",
},
Data: map[string]any{
"node_type": "origin",
"obj_type": "docx",
"title": createdTitle,
},
})
spaceID = node.Get("space_id").String()
createdNodeToken = node.Get("node_token").String()
createdObjToken = node.Get("obj_token").String()
require.NotEmpty(t, spaceID)
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())
})
t.Run("get created node", func(t *testing.T) {
require.NotEmpty(t, createdNodeToken, "node token should be created before get_node")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"wiki", "spaces", "get_node"},
DefaultAs: "bot",
Params: map[string]any{
"token": createdNodeToken,
"obj_type": "wiki",
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
assert.Equal(t, createdNodeToken, gjson.Get(result.Stdout, "data.node.node_token").String())
assert.Equal(t, createdObjToken, gjson.Get(result.Stdout, "data.node.obj_token").String())
assert.Equal(t, createdTitle, gjson.Get(result.Stdout, "data.node.title").String())
assert.Equal(t, spaceID, gjson.Get(result.Stdout, "data.node.space_id").String())
})
t.Run("get space", func(t *testing.T) {
require.NotEmpty(t, spaceID, "space ID should be available before get")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"wiki", "spaces", "get"},
DefaultAs: "bot",
Params: map[string]any{
"space_id": spaceID,
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
assert.Equal(t, spaceID, gjson.Get(result.Stdout, "data.space.space_id").String())
assert.NotEmpty(t, gjson.Get(result.Stdout, "data.space.name").String(), "stdout:\n%s", result.Stdout)
})
t.Run("list spaces", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"wiki", "spaces", "list"},
DefaultAs: "bot",
Params: map[string]any{
"page_size": 1,
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
assert.True(t, gjson.Get(result.Stdout, "data.page_token").Exists(), "stdout:\n%s", result.Stdout)
assert.True(t, gjson.Get(result.Stdout, "data.items").Exists(), "stdout:\n%s", result.Stdout)
})
t.Run("list nodes and find created node", func(t *testing.T) {
require.NotEmpty(t, spaceID, "space ID should be available before list")
require.NotEmpty(t, createdNodeToken, "node token should be available before list")
nodeItem := findWikiNodeByToken(t, ctx, spaceID, createdNodeToken)
assert.Equal(t, createdTitle, nodeItem.Get("title").String())
assert.Equal(t, createdObjToken, nodeItem.Get("obj_token").String())
})
t.Run("copy node", 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")
copiedNode := createWikiNode(t, ctx, clie2e.Request{
Args: []string{"wiki", "nodes", "copy"},
DefaultAs: "bot",
Params: map[string]any{
"space_id": spaceID,
"node_token": createdNodeToken,
},
Data: map[string]any{
"target_space_id": spaceID,
"title": copiedTitle,
},
})
copiedNodeToken = copiedNode.Get("node_token").String()
require.NotEmpty(t, copiedNodeToken)
assert.Equal(t, copiedTitle, copiedNode.Get("title").String())
assert.Equal(t, spaceID, copiedNode.Get("space_id").String())
assert.NotEqual(t, createdNodeToken, copiedNodeToken)
})
t.Run("list nodes and find copied node", func(t *testing.T) {
require.NotEmpty(t, spaceID, "space ID should be available before second list")
require.NotEmpty(t, copiedNodeToken, "copied node token should be available before second list")
nodeItem := findWikiNodeByToken(t, ctx, spaceID, copiedNodeToken)
assert.Equal(t, copiedTitle, nodeItem.Get("title").String())
})
}