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
+35
View File
@@ -0,0 +1,35 @@
# CLI E2E Tests
This directory contains end-to-end tests for `lark-cli`.
The purpose of this module is to verify real CLI workflows from a user-facing perspective: run the compiled binary, execute commands end to end, and catch regressions that are not obvious from unit tests alone.
## What Is Here
- `core.go`, `core_test.go`: the shared E2E test harness and its own tests
- `demo/`: reference testcase(s)
- `cli-e2e-testcase-writer/`: the local skill for adding or updating testcase files in this module
## For Contributors
When writing or updating testcases under `tests/cli_e2e`, install and use this skill first:
```bash
npx skills add ./tests/cli_e2e/cli-e2e-testcase-writer
```
Then follow `tests/cli_e2e/cli-e2e-testcase-writer/SKILL.md`.
Example prompt:
```text
Use $cli-e2e-testcase-writer to write lark-cli xxx domain related testcases.
Put them under tests/cli_e2e/xxx.
```
## Run
```bash
make build
go test ./tests/cli_e2e/... -count=1
```
@@ -0,0 +1,57 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
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"
)
// TestAppsAccessScopeGetDryRun pins URL shape and --app-id requirement for the
// read-side companion of +access-scope-set. Response passthrough (scope enum,
// split user/department/chat arrays) is covered by unit tests in shortcuts/apps.
func TestAppsAccessScopeGetDryRun(t *testing.T) {
setAppsDryRunEnv(t)
t.Run("HappyPath", 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{
"apps", "+access-scope-get",
"--app-id", "app_x",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "GET", gjson.Get(result.Stdout, "api.0.method").String())
assert.Equal(t, "/open-apis/spark/v1/apps/app_x/access-scope", gjson.Get(result.Stdout, "api.0.url").String())
// GET request: no body and no query params.
assert.False(t, gjson.Get(result.Stdout, "api.0.body").Exists())
assert.False(t, gjson.Get(result.Stdout, "api.0.params").Exists())
})
t.Run("RejectsMissingAppID", 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{"apps", "+access-scope-get", "--dry-run"},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
assert.Contains(t, validateErrorMessage(result), `required flag(s) "app-id" not set`)
})
}
@@ -0,0 +1,193 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
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"
)
// TestAppsAccessScopeSetDryRun pins the user-facing scope-string -> server-enum
// mapping (public->All, tenant->Tenant, specific->Range) and the three-way
// mutex between specific / public / tenant.
func TestAppsAccessScopeSetDryRun(t *testing.T) {
setAppsDryRunEnv(t)
t.Run("SpecificMapsToRange", 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{
"apps", "+access-scope-set",
"--app-id", "app_x",
"--scope", "specific",
"--targets", `[{"type":"user","id":"ou_x"},{"type":"chat","id":"oc_x"}]`,
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "PUT", gjson.Get(result.Stdout, "api.0.method").String())
assert.Equal(t, "/open-apis/spark/v1/apps/app_x/access-scope", gjson.Get(result.Stdout, "api.0.url").String())
assert.Equal(t, "Range", gjson.Get(result.Stdout, "api.0.body.scope").String())
assert.Equal(t, "ou_x", gjson.Get(result.Stdout, "api.0.body.users.0").String())
assert.Equal(t, "oc_x", gjson.Get(result.Stdout, "api.0.body.chats.0").String())
assert.False(t, gjson.Get(result.Stdout, "api.0.body.departments").Exists(),
"empty department list must be omitted")
assert.False(t, gjson.Get(result.Stdout, "api.0.body.apply_config").Exists())
})
t.Run("SpecificWithApplyConfig", 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{
"apps", "+access-scope-set",
"--app-id", "app_x",
"--scope", "specific",
"--targets", `[{"type":"user","id":"ou_x"}]`,
"--apply-enabled",
"--approver", "ou_y",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.True(t, gjson.Get(result.Stdout, "api.0.body.apply_config.enabled").Bool())
assert.Equal(t, "ou_y", gjson.Get(result.Stdout, "api.0.body.apply_config.approvers.0").String())
})
t.Run("PublicMapsToAll", 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{
"apps", "+access-scope-set",
"--app-id", "app_x",
"--scope", "public",
"--require-login=false",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "All", gjson.Get(result.Stdout, "api.0.body.scope").String())
assert.False(t, gjson.Get(result.Stdout, "api.0.body.require_login").Bool())
assert.False(t, gjson.Get(result.Stdout, "api.0.body.users").Exists())
assert.False(t, gjson.Get(result.Stdout, "api.0.body.apply_config").Exists())
})
t.Run("TenantMapsToTenant", 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{
"apps", "+access-scope-set",
"--app-id", "app_x",
"--scope", "tenant",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "Tenant", gjson.Get(result.Stdout, "api.0.body.scope").String())
// scope is the only body field in tenant mode.
assert.False(t, gjson.Get(result.Stdout, "api.0.body.require_login").Exists())
assert.False(t, gjson.Get(result.Stdout, "api.0.body.users").Exists())
})
t.Run("RejectsSpecificMissingTargets", 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{
"apps", "+access-scope-set",
"--app-id", "app_x",
"--scope", "specific",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
assert.Contains(t, validateErrorMessage(result), "--targets is required")
})
t.Run("RejectsTenantWithExtraFlags", 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{
"apps", "+access-scope-set",
"--app-id", "app_x",
"--scope", "tenant",
"--targets", `[]`,
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
assert.Contains(t, validateErrorMessage(result), "no extra flags allowed")
})
t.Run("RejectsBadTargetType", 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{
"apps", "+access-scope-set",
"--app-id", "app_x",
"--scope", "specific",
"--targets", `[{"type":"group","id":"oc_x"}]`,
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
assert.Contains(t, validateErrorMessage(result), "must be one of")
})
t.Run("RejectsApproverWithoutApplyEnabled", 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{
"apps", "+access-scope-set",
"--app-id", "app_x",
"--scope", "specific",
"--targets", `[{"type":"user","id":"ou_x"}]`,
"--approver", "ou_y",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
assert.Contains(t, validateErrorMessage(result), "--apply-enabled")
})
}
@@ -0,0 +1,168 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
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"
)
// TestAppsCreateDryRun pins the request shape and Validate behavior for
// `apps +create`. The shortcut is UAT-only and posts to the registered
// /open-apis/spark/v1 namespace; both are checked here.
func TestAppsCreateDryRun(t *testing.T) {
setAppsDryRunEnv(t)
t.Run("HappyPath_HTMLAppType", 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{
"apps", "+create",
"--name", "Demo",
"--app-type", "html",
"--dry-run",
},
DefaultAs: "user",
})
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/spark/v1/apps", gjson.Get(result.Stdout, "api.0.url").String())
assert.Equal(t, "Demo", gjson.Get(result.Stdout, "api.0.body.name").String())
assert.Equal(t, "html", gjson.Get(result.Stdout, "api.0.body.app_type").String())
// Optional fields stay omitted when not provided.
assert.False(t, gjson.Get(result.Stdout, "api.0.body.description").Exists())
assert.False(t, gjson.Get(result.Stdout, "api.0.body.icon_url").Exists())
})
t.Run("AllFields", 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{
"apps", "+create",
"--name", "Demo",
"--app-type", "html",
"--description", "survey app",
"--icon-url", "https://example.com/icon.svg",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "Demo", gjson.Get(result.Stdout, "api.0.body.name").String())
assert.Equal(t, "html", gjson.Get(result.Stdout, "api.0.body.app_type").String())
assert.Equal(t, "survey app", gjson.Get(result.Stdout, "api.0.body.description").String())
assert.Equal(t, "https://example.com/icon.svg", gjson.Get(result.Stdout, "api.0.body.icon_url").String())
})
t.Run("RejectsMissingName", 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{
"apps", "+create",
"--app-type", "html",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
assert.Contains(t, validateErrorMessage(result), `required flag(s) "name" not set`)
})
t.Run("RejectsBlankName", 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{
"apps", "+create",
"--name", " ",
"--app-type", "html",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
msg := validateErrorMessage(result)
assert.Contains(t, msg, "--name is required")
})
t.Run("RejectsMissingAppType", 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{
"apps", "+create",
"--name", "Demo",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
assert.Contains(t, validateErrorMessage(result), `required flag(s) "app-type" not set`)
})
t.Run("RejectsInvalidAppType", 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{
"apps", "+create",
"--name", "Demo",
"--app-type", "spa",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
msg := validateErrorMessage(result)
assert.Contains(t, msg, "invalid value")
assert.Contains(t, msg, "full_stack")
})
t.Run("RejectsLegacyUppercaseAppType", func(t *testing.T) {
// --app-type is a strict lowercase enum (html / full_stack); the CLI does
// not normalize case. Legacy uppercase "HTML" is rejected — backend
// compatibility for legacy values is a server concern the client does not
// surface.
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"apps", "+create",
"--name", "Demo",
"--app-type", "HTML",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
msg := validateErrorMessage(result)
assert.Contains(t, msg, "invalid value")
assert.Contains(t, msg, "HTML")
})
}
@@ -0,0 +1,50 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
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"
)
// TestAppsDBEnvCreateDryRun pins +db-env-create URL `/apps/{app_id}/db_dev_init` 和 sync_data body 透传。
// Risk: high-risk-write 在 dry-run 下不需要 --yes 确认。
func TestAppsDBEnvCreateDryRun(t *testing.T) {
setAppsDryRunEnv(t)
t.Run("DefaultSyncDataFalse", 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{"apps", "+db-env-create", "--app-id", "app_x", "--environment", "dev", "--dry-run"},
DefaultAs: "user",
})
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/spark/v1/apps/app_x/db_dev_init", gjson.Get(result.Stdout, "api.0.url").String())
assert.Equal(t, "false", gjson.Get(result.Stdout, "api.0.body.sync_data").String())
})
t.Run("SyncDataTrue", 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{"apps", "+db-env-create", "--app-id", "app_x", "--environment", "dev", "--sync-data", "--dry-run"},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "true", gjson.Get(result.Stdout, "api.0.body.sync_data").String())
})
}
@@ -0,0 +1,68 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
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"
)
// TestAppsDBExecuteDryRun pins +db-execute 复用存量 URLCLI 永远走 DBA 模式
// ?transactional=false),sql body 由 --sql 透传,默认不传 env(空值,由服务端按 workspace 定分支)。
func TestAppsDBExecuteDryRun(t *testing.T) {
setAppsDryRunEnv(t)
t.Run("DefaultEnvUnsetAndTransactionalFalse", 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{"apps", "+db-execute", "--app-id", "app_x", "--sql", "SELECT 1", "--dry-run"},
DefaultAs: "user",
})
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/spark/v1/apps/app_x/sql_commands", gjson.Get(result.Stdout, "api.0.url").String())
assert.Equal(t, "SELECT 1", gjson.Get(result.Stdout, "api.0.body.sql").String())
assert.Equal(t, "false", gjson.Get(result.Stdout, "api.0.params.transactional").String(),
"CLI is DBA mode → must send transactional=false in query")
assert.False(t, gjson.Get(result.Stdout, "api.0.body.transactional").Exists(),
"transactional should be in query, not body")
assert.False(t, gjson.Get(result.Stdout, "api.0.params.env").Exists(),
"default: no --environment → env key must be omitted (server picks workspace default branch)")
})
t.Run("OnlineEnvSwitch", 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{"apps", "+db-execute", "--app-id", "app_x", "--sql", "SELECT 1", "--environment", "online", "--dry-run"},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "online", gjson.Get(result.Stdout, "api.0.params.env").String())
})
t.Run("RejectsEmptySQL", 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{"apps", "+db-execute", "--app-id", "app_x", "--sql", " ", "--dry-run"},
DefaultAs: "user",
})
require.NoError(t, err)
assert.NotEqual(t, 0, result.ExitCode, "empty --sql must fail validation")
})
}
@@ -0,0 +1,82 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
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"
)
// TestAppsDBTableGetDryRun pins +db-table-get 复用存量 URL。
// 没有独立 --ddl flag —— 由 --format 同时驱动 CLI 渲染和 server 请求形态:
//
// --format pretty → CLI 给 server 带 ?format=ddl
// --format json / table / ndjson / csv(含默认)→ CLI 不传 format query
func TestAppsDBTableGetDryRun(t *testing.T) {
setAppsDryRunEnv(t)
t.Run("DefaultFormatJSONOmitsFormatQuery", 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{"apps", "+db-table-get", "--app-id", "app_x", "--table", "orders", "--dry-run"},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "GET", gjson.Get(result.Stdout, "api.0.method").String())
assert.Equal(t, "/open-apis/spark/v1/apps/app_x/tables/orders", gjson.Get(result.Stdout, "api.0.url").String())
assert.False(t, gjson.Get(result.Stdout, "api.0.params.format").Exists(),
"default (json) should omit format query")
})
t.Run("PrettyFormatSendsFormatDDL", 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{"apps", "+db-table-get", "--app-id", "app_x", "--table", "orders", "--format", "pretty", "--dry-run"},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
// pretty 模式 dry-run 输出是 plain text 列表(非 JSON envelope),用 substring 校验 query。
assert.Contains(t, result.Stdout, "format=ddl",
"--format pretty must trigger ?format=ddl")
})
t.Run("TableFormatOmitsFormatQuery", 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{"apps", "+db-table-get", "--app-id", "app_x", "--table", "orders", "--format", "table", "--dry-run"},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.False(t, gjson.Get(result.Stdout, "api.0.params.format").Exists())
})
t.Run("RequiresTableFlag", 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{"apps", "+db-table-get", "--app-id", "app_x", "--dry-run"},
DefaultAs: "user",
})
require.NoError(t, err)
assert.NotEqual(t, 0, result.ExitCode, "missing --table must fail")
})
}
@@ -0,0 +1,75 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
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"
)
// TestAppsDBTableListDryRun pins +db-table-list 复用存量 URL/apps/{app_id}/tables
// 不带 /db/),cursor 分页参数与 env 透传,且不发 include_stats query。
func TestAppsDBTableListDryRun(t *testing.T) {
setAppsDryRunEnv(t)
t.Run("DefaultsToNoEnvAndPageSize20", 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{"apps", "+db-table-list", "--app-id", "app_x", "--dry-run"},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "GET", gjson.Get(result.Stdout, "api.0.method").String())
assert.Equal(t, "/open-apis/spark/v1/apps/app_x/tables", gjson.Get(result.Stdout, "api.0.url").String())
assert.False(t, gjson.Get(result.Stdout, "api.0.params.env").Exists(),
"default: no --environment → env key must be omitted (server picks workspace default branch)")
assert.Equal(t, "20", gjson.Get(result.Stdout, "api.0.params.page_size").String())
assert.False(t, gjson.Get(result.Stdout, "api.0.params.page_token").Exists(),
"empty page_token must be omitted")
assert.False(t, gjson.Get(result.Stdout, "api.0.params.include_stats").Exists(),
"CLI should not send include_stats query (server returns stats by default)")
})
t.Run("CustomPaginationAndDevEnv", 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{"apps", "+db-table-list",
"--app-id", "app_x", "--environment", "dev",
"--page-size", "50", "--page-token", "cursor-abc",
"--dry-run"},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "dev", gjson.Get(result.Stdout, "api.0.params.env").String())
assert.Equal(t, "50", gjson.Get(result.Stdout, "api.0.params.page_size").String())
assert.Equal(t, "cursor-abc", gjson.Get(result.Stdout, "api.0.params.page_token").String())
})
t.Run("RejectsBlankAppID", 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{"apps", "+db-table-list", "--app-id", " ", "--dry-run"},
DefaultAs: "user",
})
require.NoError(t, err)
assert.NotEqual(t, 0, result.ExitCode, "blank app-id must fail validation")
assert.Contains(t, validateErrorMessage(result), "app-id")
})
}
@@ -0,0 +1,82 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"path/filepath"
"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 TestAppsEnvPullDryRun(t *testing.T) {
setAppsDryRunEnv(t)
t.Run("DefaultPath", 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{
"apps", "+env-pull",
"--app-id", "app_x",
"--dry-run",
},
DefaultAs: "user",
})
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/spark/v1/apps/app_x/env_vars", gjson.Get(result.Stdout, "api.0.url").String())
assert.Equal(t, "dev", gjson.Get(result.Stdout, "api.0.body.env").String())
assert.False(t, gjson.Get(result.Stdout, "api.0.body.include_values").Exists())
assert.False(t, gjson.Get(result.Stdout, "api.0.params").Exists())
assert.True(t, gjson.Get(result.Stdout, "project_path").Exists())
assert.Contains(t, gjson.Get(result.Stdout, "env_file").String(), ".env.local")
assert.False(t, gjson.Get(result.Stdout, "env_keys").Exists())
})
t.Run("CustomProjectPath", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
projectDir := filepath.Join(t.TempDir(), "demo")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"apps", "+env-pull",
"--app-id", "app_x",
"--project-path", projectDir,
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, projectDir, gjson.Get(result.Stdout, "project_path").String())
assert.Equal(t, filepath.Join(projectDir, ".env.local"), gjson.Get(result.Stdout, "env_file").String())
})
t.Run("MissingAppID", 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{
"apps", "+env-pull",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
assert.Contains(t, result.Stdout+result.Stderr, `--app-id is required`)
})
}
@@ -0,0 +1,93 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"path/filepath"
"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"
)
func TestAppsGitCredentialInitDryRun(t *testing.T) {
setAppsDryRunEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"apps", "+git-credential-init",
"--app-id", "app_xxx",
"--dry-run",
},
BinaryPath: "../../../lark-cli",
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "GET", gjson.Get(result.Stdout, "api.0.method").String())
assert.Equal(t, "/open-apis/spark/v1/apps/app_xxx/git_info", gjson.Get(result.Stdout, "api.0.url").String())
assert.Equal(t, "app_xxx", gjson.Get(result.Stdout, "api.0.params.app_id").String())
assert.False(t, gjson.Get(result.Stdout, "api.0.body").Exists())
assert.Equal(t, "api-plus-local-setup", gjson.Get(result.Stdout, "mode").String())
assert.Equal(t, "initialize_local_git_credential", gjson.Get(result.Stdout, "action").String())
assert.True(t, strings.HasSuffix(gjson.Get(result.Stdout, "metadata_file").String(), filepath.Join("spark", "app_xxx", "git.json")))
assert.Equal(t, int64(3), gjson.Get(result.Stdout, "local_effects.#").Int())
assert.Equal(t, "save the issued PAT in the local system credential store", gjson.Get(result.Stdout, "local_effects.0").String())
assert.Equal(t, "write app-scoped git credential metadata", gjson.Get(result.Stdout, "local_effects.1").String())
assert.Equal(t, "configure a URL-scoped Git credential helper in global git config when possible", gjson.Get(result.Stdout, "local_effects.2").String())
}
func TestAppsGitCredentialListDryRun(t *testing.T) {
setAppsDryRunEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"apps", "+git-credential-list", "--dry-run"},
BinaryPath: "../../../lark-cli",
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "Preview local Git credential listing (no API call, read-only local state).", gjson.Get(result.Stdout, "description").String())
assert.Equal(t, "local-read-only", gjson.Get(result.Stdout, "mode").String())
assert.Equal(t, "list_local_git_credentials", gjson.Get(result.Stdout, "action").String())
assert.Equal(t, int64(0), gjson.Get(result.Stdout, "api.#").Int())
assert.Contains(t, gjson.Get(result.Stdout, "storage_root").String(), filepath.Join("", "spark"))
assert.Equal(t, "scan app-scoped git credential metadata under the CLI config directory", gjson.Get(result.Stdout, "reads.0").String())
}
func TestAppsGitCredentialRemoveDryRun(t *testing.T) {
setAppsDryRunEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"apps", "+git-credential-remove", "--app-id", "app_xxx", "--dry-run"},
BinaryPath: "../../../lark-cli",
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "Preview local Git credential cleanup (no API call; would clean up local-only state).", gjson.Get(result.Stdout, "description").String())
assert.Equal(t, "local-cleanup-only", gjson.Get(result.Stdout, "mode").String())
assert.Equal(t, "remove_local_git_credential", gjson.Get(result.Stdout, "action").String())
assert.Equal(t, "app_xxx", gjson.Get(result.Stdout, "app_id").String())
assert.Equal(t, int64(0), gjson.Get(result.Stdout, "api.#").Int())
assert.True(t, strings.HasSuffix(gjson.Get(result.Stdout, "metadata_file").String(), filepath.Join("spark", "app_xxx", "git.json")))
assert.Equal(t, "read app-scoped git credential metadata", gjson.Get(result.Stdout, "effects.0").String())
}
@@ -0,0 +1,122 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"encoding/json"
"net/url"
"os"
"path/filepath"
"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 TestAppsGitCredentialListLocalE2E(t *testing.T) {
env := setupAppsGitCredentialLocalEnv(t)
seedAppsGitCredentialMetadata(t, env.configDir, "app_a", "https://example.com/git/u/a.git", "pat-ref-a")
seedAppsGitCredentialMetadata(t, env.configDir, "app_b", "https://example.com/git/u/b.git", "pat-ref-b")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"apps", "+git-credential-list"},
DefaultAs: "user",
Env: env.vars,
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, int64(2), gjson.Get(result.Stdout, "data.count").Int())
credentials := map[string]gjson.Result{}
for _, credential := range gjson.Get(result.Stdout, "data.credentials").Array() {
credentials[credential.Get("app_id").String()] = credential
}
require.Contains(t, credentials, "app_a")
require.Contains(t, credentials, "app_b")
assert.Equal(t, "https://example.com/git/u/a.git", credentials["app_a"].Get("repository_url").String())
assert.Equal(t, "missing_secret", credentials["app_a"].Get("status").String())
assert.Equal(t, "https://example.com/git/u/b.git", credentials["app_b"].Get("repository_url").String())
assert.Equal(t, "missing_secret", credentials["app_b"].Get("status").String())
assert.False(t, credentials["app_a"].Get("expires_at").Exists())
assert.False(t, credentials["app_a"].Get("expired").Exists())
}
func TestAppsGitCredentialRemoveLocalE2E(t *testing.T) {
env := setupAppsGitCredentialLocalEnv(t)
metadataPath := seedAppsGitCredentialMetadata(t, env.configDir, "app_xxx", "https://example.com/git/u/app.git", "pat-ref-remove")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"apps", "+git-credential-remove", "--app-id", "app_xxx"},
DefaultAs: "user",
Env: env.vars,
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "app_xxx", gjson.Get(result.Stdout, "data.app_id").String())
assert.True(t, gjson.Get(result.Stdout, "data.removed").Bool())
assert.NoFileExists(t, metadataPath)
}
type appsGitCredentialLocalEnv struct {
configDir string
vars map[string]string
}
func setupAppsGitCredentialLocalEnv(t *testing.T) appsGitCredentialLocalEnv {
t.Helper()
configDir := t.TempDir()
homeDir := t.TempDir()
gitConfig := filepath.Join(t.TempDir(), ".gitconfig")
return appsGitCredentialLocalEnv{
configDir: configDir,
vars: map[string]string{
"LARKSUITE_CLI_CONFIG_DIR": configDir,
"LARKSUITE_CLI_APP_ID": "apps_local_test",
"LARKSUITE_CLI_APP_SECRET": "apps_local_secret",
"LARKSUITE_CLI_BRAND": "feishu",
"LARKSUITE_CLI_NO_UPDATE_NOTIFIER": "1",
"LARKSUITE_CLI_NO_SKILLS_NOTIFIER": "1",
"LARKSUITE_CLI_DATA_DIR": filepath.Join(homeDir, ".local", "share"),
"HOME": homeDir,
"GIT_CONFIG_GLOBAL": gitConfig,
"GIT_CONFIG_NOSYSTEM": "1",
"GIT_TERMINAL_PROMPT": "0",
},
}
}
func seedAppsGitCredentialMetadata(t *testing.T, configDir, appID, gitHTTPURL, patRef string) string {
t.Helper()
dir := filepath.Join(configDir, "spark", url.PathEscape(appID))
require.NoError(t, os.MkdirAll(dir, 0700))
path := filepath.Join(dir, "git.json")
payload := map[string]any{
"version": 1,
"app_id": appID,
"git_http_url": gitHTTPURL,
"profile": "default",
"profile_app_id": "apps_local_test",
"user_open_id": "ou_local_test",
"username": "x-access-token",
"pat_ref": patRef,
"status": "confirmed",
"expires_at": time.Now().Add(24 * time.Hour).Unix(),
"updated_at": time.Now().Unix(),
}
data, err := json.MarshalIndent(payload, "", " ")
require.NoError(t, err)
require.NoError(t, os.WriteFile(path, append(data, '\n'), 0600))
return path
}
@@ -0,0 +1,326 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"os"
"path/filepath"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestAppsHTMLPublishDryRun exercises the walker / manifest layer without
// packing or uploading. --path goes through LocalFileIO which bounds reads to
// the runtime cwd, so each sub-test seeds fixtures in a t.TempDir and runs
// the binary with WorkDir set to that dir + relative --path.
//
// Hidden files are intentionally included — the walker is deliberately not
// filtering, so the manifest must reflect everything the user pointed --path
// at. Users are documented to pass clean build output directories (e.g.
// ./dist), not source trees.
func TestAppsHTMLPublishDryRun(t *testing.T) {
setAppsDryRunEnv(t)
t.Run("Directory_ReportsManifest", func(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(dir, "dist"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, "dist", "index.html"), []byte("<html><body>hi</body></html>"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(dir, "dist", "logo.svg"), []byte("<svg/>"), 0o644))
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"apps", "+html-publish",
"--app-id", "app_x",
"--path", "./dist",
"--dry-run",
},
DefaultAs: "user",
WorkDir: dir,
})
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/spark/v1/apps/app_x/upload_and_release_html_code", gjson.Get(result.Stdout, "api.0.url").String())
// file_count / files / total_size_bytes sit at envelope top level
// (not under api.0.body — manifest is dry-run metadata, not the HTTP body).
assert.Equal(t, int64(2), gjson.Get(result.Stdout, "file_count").Int())
assert.Greater(t, gjson.Get(result.Stdout, "total_size_bytes").Int(), int64(0))
files := gjson.Get(result.Stdout, "files").Array()
require.Len(t, files, 2)
names := []string{files[0].String(), files[1].String()}
assert.Contains(t, names, "index.html")
assert.Contains(t, names, "logo.svg")
})
t.Run("SingleFile_OneEntry", func(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "page.html"), []byte("<html></html>"), 0o644))
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"apps", "+html-publish",
"--app-id", "app_x",
"--path", "page.html",
"--dry-run",
},
DefaultAs: "user",
WorkDir: dir,
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, int64(1), gjson.Get(result.Stdout, "file_count").Int())
assert.Equal(t, "page.html", gjson.Get(result.Stdout, "files.0").String())
})
t.Run("HiddenFilesIncludedExceptGit", func(t *testing.T) {
// The walker filters the .git directory (and a .git gitdir pointer file)
// so a stray repo under --path doesn't ship its history / remote URL to a
// public share URL. Generic hidden files like .DS_Store are NOT filtered —
// only .git is — so users still see everything else they pointed --path at.
dir := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(dir, "dist"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, "dist", "index.html"), []byte("<html/>"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(dir, "dist", ".DS_Store"), []byte("noise"), 0o644))
require.NoError(t, os.Mkdir(filepath.Join(dir, "dist", ".git"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, "dist", ".git", "HEAD"), []byte("ref: refs/heads/main\n"), 0o644))
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"apps", "+html-publish",
"--app-id", "app_x",
"--path", "./dist",
"--dry-run",
},
DefaultAs: "user",
WorkDir: dir,
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
// index.html + .DS_Store kept; .git/HEAD filtered out → 2 files.
assert.Equal(t, int64(2), gjson.Get(result.Stdout, "file_count").Int(),
"walker must keep non-.git hidden files but drop .git; got: %s", result.Stdout)
names := gjson.Get(result.Stdout, "files").Array()
var got []string
for _, n := range names {
got = append(got, n.String())
}
assert.Contains(t, got, "index.html")
assert.Contains(t, got, ".DS_Store")
assert.NotContains(t, got, ".git/HEAD", "walker must exclude .git contents")
})
t.Run("EmptyDir_ManifestEmpty", func(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(dir, "dist"), 0o755))
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"apps", "+html-publish",
"--app-id", "app_x",
"--path", "./dist",
"--dry-run",
},
DefaultAs: "user",
WorkDir: dir,
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, int64(0), gjson.Get(result.Stdout, "file_count").Int())
assert.Equal(t, int64(0), gjson.Get(result.Stdout, "total_size_bytes").Int())
assert.Contains(t, gjson.Get(result.Stdout, "validation_error").String(), "index.html",
"empty dir should report index.html validation_error: %s", result.Stdout)
})
t.Run("MissingIndexHTML_SurfacesValidationError", func(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(dir, "dist"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, "dist", "page.html"), []byte("<html/>"), 0o644))
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"apps", "+html-publish",
"--app-id", "app_x",
"--path", "./dist",
"--dry-run",
},
DefaultAs: "user",
WorkDir: dir,
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, int64(1), gjson.Get(result.Stdout, "file_count").Int())
assert.Equal(t, "page.html", gjson.Get(result.Stdout, "files.0").String())
assert.Contains(t, gjson.Get(result.Stdout, "validation_error").String(), "index.html")
})
t.Run("RejectsMissingAppID", func(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(dir, "dist"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, "dist", "index.html"), []byte("<html/>"), 0o644))
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"apps", "+html-publish",
"--path", "./dist",
"--dry-run",
},
DefaultAs: "user",
WorkDir: dir,
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
assert.Contains(t, validateErrorMessage(result), `required flag(s) "app-id" not set`)
})
t.Run("RejectsMissingPath", 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{
"apps", "+html-publish",
"--app-id", "app_x",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
assert.Contains(t, validateErrorMessage(result), `required flag(s) "path" not set`)
})
t.Run("RejectsSensitivePathsByDefault", func(t *testing.T) {
// Validate scans candidates for well-known credential files and rejects
// when any are found. Dry-run also fails (Validate runs before the
// dry-run branch) — that's the point: dry-run preview matches Execute.
dir := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(dir, "dist"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, "dist", "index.html"), []byte("<html/>"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(dir, "dist", ".env"), []byte("SECRET=xxx\n"), 0o644))
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"apps", "+html-publish",
"--app-id", "app_x",
"--path", "./dist",
"--dry-run",
},
DefaultAs: "user",
WorkDir: dir,
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
assert.Contains(t, validateErrorMessage(result), ".env",
"validation error should name the offending file: %s", result.Stderr)
})
t.Run("AllowSensitiveOverride", func(t *testing.T) {
// --allow-sensitive bypasses the credential-file gate (legitimate
// cases: docs site shipping example .env files). Dry-run output
// surfaces a sensitive_waived field so the caller still sees what
// was let through.
dir := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(dir, "dist"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, "dist", "index.html"), []byte("<html/>"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(dir, "dist", ".env.example"), []byte("API_KEY=replace-me\n"), 0o644))
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"apps", "+html-publish",
"--app-id", "app_x",
"--path", "./dist",
"--allow-sensitive",
"--dry-run",
},
DefaultAs: "user",
WorkDir: dir,
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
waived := gjson.Get(result.Stdout, "sensitive_waived").Array()
require.Len(t, waived, 1, "expected sensitive_waived to list the file, got: %s", result.Stdout)
assert.Equal(t, ".env.example", waived[0].String())
})
t.Run("CleanCwdAllowed", func(t *testing.T) {
// --path "." is no longer hard-rejected. A cwd that doesn't contain
// well-known credential files is a valid publish target.
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "index.html"), []byte("<html/>"), 0o644))
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"apps", "+html-publish",
"--app-id", "app_x",
"--path", ".",
"--dry-run",
},
DefaultAs: "user",
WorkDir: dir,
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, int64(1), gjson.Get(result.Stdout, "file_count").Int())
})
t.Run("TrimsAppIDAndPath", func(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(dir, "dist"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, "dist", "index.html"), []byte("<html/>"), 0o644))
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"apps", "+html-publish",
"--app-id", " app_x ",
"--path", " ./dist ",
"--dry-run",
},
DefaultAs: "user",
WorkDir: dir,
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "/open-apis/spark/v1/apps/app_x/upload_and_release_html_code",
gjson.Get(result.Stdout, "api.0.url").String())
assert.Equal(t, int64(1), gjson.Get(result.Stdout, "file_count").Int(),
"path trimming must produce the same manifest as untrimmed input")
})
}
+139
View File
@@ -0,0 +1,139 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
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"
)
// TestAppsListDryRun pins cursor-pagination params: default page_size=20 is
// always written; empty --page-token is omitted; negative page_size is passed
// through unchanged (server is the source of truth for range validation).
func TestAppsListDryRun(t *testing.T) {
setAppsDryRunEnv(t)
t.Run("DefaultPageSize", 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{"apps", "+list", "--dry-run"},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "GET", gjson.Get(result.Stdout, "api.0.method").String())
assert.Equal(t, "/open-apis/spark/v1/apps", gjson.Get(result.Stdout, "api.0.url").String())
assert.Equal(t, "20", gjson.Get(result.Stdout, "api.0.params.page_size").String())
assert.False(t, gjson.Get(result.Stdout, "api.0.params.page_token").Exists(),
"empty page_token must be omitted")
})
t.Run("CustomPageSize", 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{"apps", "+list", "--page-size", "50", "--dry-run"},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "50", gjson.Get(result.Stdout, "api.0.params.page_size").String())
})
t.Run("WithPageToken", 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{"apps", "+list", "--page-token", "cursor_abc", "--dry-run"},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "cursor_abc", gjson.Get(result.Stdout, "api.0.params.page_token").String())
assert.Equal(t, "20", gjson.Get(result.Stdout, "api.0.params.page_size").String())
})
t.Run("WithKeywordOwnershipAppType", 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{"apps", "+list",
"--keyword", "survey", "--ownership", "mine", "--app-type", "html",
"--dry-run"},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "survey", gjson.Get(result.Stdout, "api.0.params.keyword").String())
assert.Equal(t, "mine", gjson.Get(result.Stdout, "api.0.params.ownership").String())
assert.Equal(t, "html", gjson.Get(result.Stdout, "api.0.params.app_type").String())
})
t.Run("OmitsEmptyFilters", 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{"apps", "+list", "--dry-run"},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
for _, p := range []string{"keyword", "ownership", "app_type"} {
assert.False(t, gjson.Get(result.Stdout, "api.0.params."+p).Exists(),
"empty %s must be omitted", p)
}
})
t.Run("RejectsInvalidOwnership", 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{"apps", "+list", "--ownership", "bogus", "--dry-run"},
DefaultAs: "user",
})
require.NoError(t, err)
assert.NotEqual(t, 0, result.ExitCode, "invalid --ownership enum must be rejected")
})
t.Run("RejectsLegacyUppercaseAppType", 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{"apps", "+list", "--app-type", "HTML", "--dry-run"},
DefaultAs: "user",
})
require.NoError(t, err)
assert.NotEqual(t, 0, result.ExitCode, "legacy uppercase --app-type must be rejected")
})
t.Run("NegativePageSizePassesThrough", func(t *testing.T) {
// By design CLI does not bound page_size; server validates. Test pins that
// invariant so a well-meaning client-side check doesn't sneak in.
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"apps", "+list", "--page-size", "-1", "--dry-run"},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "-1", gjson.Get(result.Stdout, "api.0.params.page_size").String())
})
}
@@ -0,0 +1,100 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
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"
)
// TestAppsUpdateDryRun pins partial-update semantics: PATCH with only the
// fields the user supplied; --app-id and at-least-one-field are both required.
func TestAppsUpdateDryRun(t *testing.T) {
setAppsDryRunEnv(t)
t.Run("PartialFieldsName", 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{
"apps", "+update",
"--app-id", "app_x",
"--name", "v2",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "PATCH", gjson.Get(result.Stdout, "api.0.method").String())
assert.Equal(t, "/open-apis/spark/v1/apps/app_x", gjson.Get(result.Stdout, "api.0.url").String())
assert.Equal(t, "v2", gjson.Get(result.Stdout, "api.0.body.name").String())
assert.False(t, gjson.Get(result.Stdout, "api.0.body.description").Exists(),
"description must be omitted when not provided")
})
t.Run("WithDescription", 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{
"apps", "+update",
"--app-id", "app_x",
"--name", "v2",
"--description", "updated",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "v2", gjson.Get(result.Stdout, "api.0.body.name").String())
assert.Equal(t, "updated", gjson.Get(result.Stdout, "api.0.body.description").String())
})
t.Run("RejectsMissingAppID", 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{
"apps", "+update",
"--name", "v2",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
assert.Contains(t, validateErrorMessage(result), `required flag(s) "app-id" not set`)
})
t.Run("RejectsNoFields", 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{
"apps", "+update",
"--app-id", "app_x",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
msg := validateErrorMessage(result)
assert.Contains(t, msg, "at least one")
})
}
+35
View File
@@ -0,0 +1,35 @@
# Apps CLI E2E Coverage
## Metrics
- Denominator: 9 leaf commands (all user-visible shortcuts)
- Command coverage: 100% (9/9)
- API dry-run coverage: 100% (7/7 API-backed commands)
- Local E2E coverage: 100% (2/2 local-only commands)
- Live coverage: 0%
## Summary
- `TestAppsCreateDryRun`: happy path with `--app-type html`, all-fields shape, rejection paths (missing name, missing app-type, invalid app-type, legacy uppercase `HTML`). `--app-type` is a strict lowercase enum (`html`/`full_stack`); the CLI does not normalize case — legacy uppercase compatibility is a server concern.
- `TestAppsUpdateDryRun`: partial-field PATCH semantics; `--app-id` and at-least-one-field validation.
- `TestAppsListDryRun`: default `page_size=20`; empty `--page-token` omitted; negative size passed through to server (no client-side bound check); `--keyword`/`--ownership`/`--app-type` pass-through + empty-omission; invalid `--ownership` and legacy uppercase `--app-type` enum rejection.
- `TestAppsAccessScopeSetDryRun`: CLI input `specific`/`public`/`tenant` -> server enum `Range`/`All`/`Tenant`; `apply_config.approvers` shape; four mutex rejection paths.
- `TestAppsAccessScopeGetDryRun`: URL shape; no body/params on GET; `--app-id` required.
- `TestAppsHTMLPublishDryRun`: walker manifest for directory + single file; hidden files intentionally included (design decision); empty dir / missing `index.html` produce envelope `validation_error` field (dry-run exits 0 advisory, not blocking); both required-flag rejections.
- `TestAppsGitCredentialInitDryRun`: URL shape for issuing an app Git PAT; no body; `app_id` query metadata included.
- `TestAppsGitCredentialListLocalE2E`: local-only command scans every app storage directory and reports repository URL and status without exposing PAT or expiry details.
- `TestAppsGitCredentialRemoveLocalE2E`: local cleanup command removes app-scoped metadata under an isolated config dir.
Blocked: Live E2E intentionally not implemented yet. Apps has no `+delete` endpoint (OAPI doc explicitly defers archive/delete), so a create-and-cleanup workflow would leak tenant state. Revisit when the server exposes `DELETE /apps/{appId}`.
## Command Table
| Status | Cmd | Type | Testcase | Key parameter shapes | Notes / uncovered reason |
| --- | --- | --- | --- | --- | --- |
| ✓ | apps +create | shortcut | apps_create_dryrun_test.go::TestAppsCreateDryRun | `--name`, `--app-type` (required, case-sensitive, `html`/`full_stack`), `--description`, `--icon-url` | live blocked: no +delete to clean up |
| ✓ | apps +update | shortcut | apps_update_dryrun_test.go::TestAppsUpdateDryRun | `--app-id`; at least one of `--name`/`--description` | live blocked: no +delete |
| ✓ | apps +list | shortcut | apps_list_dryrun_test.go::TestAppsListDryRun | `--keyword`; `--ownership` (enum all/mine/shared); `--app-type` (enum html/full_stack); `--page-size` default 20; `--page-token` cursor | live blocked: needs tenant fixtures |
| ✓ | apps +access-scope-set | shortcut | apps_access_scope_set_dryrun_test.go::TestAppsAccessScopeSetDryRun | `--scope specific/public/tenant`; `--targets` JSON; `--apply-enabled --approver`; `--require-login` | live blocked: needs real open_ids |
| ✓ | apps +access-scope-get | shortcut | apps_access_scope_get_dryrun_test.go::TestAppsAccessScopeGetDryRun | `--app-id` | live blocked: depends on +access-scope-set state |
| ✓ | apps +html-publish | shortcut | apps_html_publish_dryrun_test.go::TestAppsHTMLPublishDryRun | `--app-id`, `--path` (file or directory containing `index.html`) | live blocked: real upload has side effects; no rollback API |
| ✓ | apps +git-credential-init | shortcut | apps_git_credential_dryrun_test.go::TestAppsGitCredentialInitDryRun | `--app-id`; dry-run `GET /open-apis/spark/v1/apps/{app_id}/git_info` | live blocked: issues short-lived repository PAT |
| ✓ | apps +git-credential-list | shortcut | apps_git_credential_local_test.go::TestAppsGitCredentialListLocalE2E | no `--app-id`; scans all local app storage directories and reports `app_id`, repository URL, and status without PAT or expiry | local E2E only: no dry-run API because command is local read only |
| ✓ | apps +git-credential-remove | shortcut | apps_git_credential_local_test.go::TestAppsGitCredentialRemoveLocalE2E | `--app-id`; deletes local metadata, keychain PAT, and Git config | local E2E only: no dry-run API because command is local cleanup only |
+34
View File
@@ -0,0 +1,34 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"testing"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/tidwall/gjson"
)
// setAppsDryRunEnv isolates config and supplies stub credentials so dry-run
// short-circuits before identity / scope resolution touches a real keychain.
// Apps shortcuts are UAT-only, so tests pass DefaultAs:"user" to the harness.
func setAppsDryRunEnv(t *testing.T) {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "apps_dryrun_test")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "apps_dryrun_secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
}
// validateErrorMessage extracts the structured error.message from a dry-run
// Validate-stage failure envelope. Repo convention is "stdout first, stderr
// fallback" — markdown / drive_search emit the JSON envelope to stdout (exit
// 0), apps currently emits to stderr (exit 2). Reading both orders shields
// tests from runner-internal routing changes.
func validateErrorMessage(r *clie2e.Result) string {
if msg := gjson.Get(r.Stdout, "error.message").String(); msg != "" {
return msg
}
return gjson.Get(r.Stderr, "error.message").String()
}
@@ -0,0 +1,121 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"context"
"os"
"path/filepath"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestBase_AttachmentDryRun(t *testing.T) {
setBaseDryRunConfigEnv(t)
workDir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(workDir, "report.txt"), []byte("hello"), 0o600))
require.NoError(t, os.Mkdir(filepath.Join(workDir, "downloads"), 0o700))
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
t.Run("upload", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"base", "+record-upload-attachment",
"--base-token", "app_x",
"--table-id", "tbl_x",
"--record-id", "rec_x",
"--field-id", "fld_att",
"--file", "report.txt",
"--dry-run",
},
WorkDir: workDir,
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
require.Equal(t, "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields/fld_att", gjson.Get(out, "api.0.url").String(), out)
require.Equal(t, "/open-apis/drive/v1/medias/upload_all", gjson.Get(out, "api.1.url").String(), out)
require.Equal(t, "/open-apis/base/v3/bases/app_x/tables/tbl_x/append_attachments", gjson.Get(out, "api.2.url").String(), out)
require.Equal(t, "<uploaded_file_token>", gjson.Get(out, "api.2.body.attachments.rec_x.fld_att.0.file_token").String(), out)
})
t.Run("download", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"base", "+record-download-attachment",
"--base-token", "app_x",
"--table-id", "tbl_x",
"--record-id", "rec_x",
"--file-token", "box_a",
"--output", "downloads",
"--dry-run",
},
WorkDir: workDir,
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
require.Equal(t, "/open-apis/base/v3/bases/app_x/tables/tbl_x/get_attachments", gjson.Get(out, "api.0.url").String(), out)
require.Equal(t, "/open-apis/drive/v1/medias/%3Cfile_token%3E/download", gjson.Get(out, "api.1.url").String(), out)
require.Equal(t, "<extra_info_if_present>", gjson.Get(out, "api.1.params.extra").String(), out)
})
t.Run("download all", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"base", "+record-download-attachment",
"--base-token", "app_x",
"--table-id", "tbl_x",
"--record-id", "rec_x",
"--output", "downloads",
"--dry-run",
},
WorkDir: workDir,
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
require.Equal(t, "/open-apis/base/v3/bases/app_x/tables/tbl_x/get_attachments", gjson.Get(out, "api.0.url").String(), out)
require.Equal(t, "/open-apis/drive/v1/medias/%3Cfile_token%3E/download", gjson.Get(out, "api.1.url").String(), out)
})
t.Run("remove", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"base", "+record-remove-attachment",
"--base-token", "app_x",
"--table-id", "tbl_x",
"--record-id", "rec_x",
"--field-id", "fld_att",
"--file-token", "box_a",
"--dry-run",
},
WorkDir: workDir,
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
require.Equal(t, "/open-apis/base/v3/bases/app_x/tables/tbl_x/remove_attachments", gjson.Get(out, "api.0.url").String(), out)
require.Equal(t, "box_a", gjson.Get(out, "api.0.body.attachments.rec_x.fld_att.0.file_token").String(), out)
})
}
func setBaseDryRunConfigEnv(t *testing.T) {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "base_dryrun_test")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "base_dryrun_secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
}
@@ -0,0 +1,69 @@
// 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 as bot", 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, _, _ := createTableWithRetry(
t,
parentT,
ctx,
baseToken,
tableName,
`[{"name":"Name","type":"text"}]`,
`{"name":"Main","type":"grid"}`,
)
t.Run("get table as bot", 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 as bot", func(t *testing.T) {
table := findBaseTableByID(t, ctx, baseToken, tableID)
assert.Equal(t, tableID, table.Get("id").String())
assert.Equal(t, tableName, table.Get("name").String())
})
}
@@ -0,0 +1,154 @@
// 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/require"
"github.com/tidwall/gjson"
)
func TestBaseBlockDryRun(t *testing.T) {
setBaseDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
t.Run("list all", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"base", "+base-block-list",
"--base-token", "app_x",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
require.Equal(t, "/open-apis/base/v3/bases/app_x/blocks/list", gjson.Get(out, "api.0.url").String(), out)
require.Equal(t, "POST", gjson.Get(out, "api.0.method").String(), out)
require.False(t, gjson.Get(out, "api.0.body.parent_id").Exists(), out)
})
t.Run("list folder", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"base", "+base-block-list",
"--base-token", "app_x",
"--parent-id", "blk_folder",
"--type", "docx",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
require.Equal(t, "/open-apis/base/v3/bases/app_x/blocks/list", gjson.Get(out, "api.0.url").String(), out)
require.Equal(t, "blk_folder", gjson.Get(out, "api.0.body.parent_id").String(), out)
require.False(t, gjson.Get(out, "api.0.body.type").Exists(), out)
})
t.Run("create", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"base", "+base-block-create",
"--base-token", "app_x",
"--type", "docx",
"--name", "Spec",
"--parent-id", "blk_folder",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
require.Equal(t, "/open-apis/base/v3/bases/app_x/blocks", gjson.Get(out, "api.0.url").String(), out)
require.Equal(t, "POST", gjson.Get(out, "api.0.method").String(), out)
require.Equal(t, "docx", gjson.Get(out, "api.0.body.type").String(), out)
require.Equal(t, "Spec", gjson.Get(out, "api.0.body.name").String(), out)
require.Equal(t, "blk_folder", gjson.Get(out, "api.0.body.parent_id").String(), out)
})
t.Run("move root", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"base", "+base-block-move",
"--base-token", "app_x",
"--block-id", "blk_a",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
require.Equal(t, "/open-apis/base/v3/bases/app_x/blocks/blk_a/move", gjson.Get(out, "api.0.url").String(), out)
require.Equal(t, "POST", gjson.Get(out, "api.0.method").String(), out)
require.True(t, gjson.Get(out, "api.0.body.parent_id").Exists(), out)
require.Equal(t, "Null", gjson.Get(out, "api.0.body.parent_id").Type.String(), out)
})
t.Run("move after", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"base", "+base-block-move",
"--base-token", "app_x",
"--block-id", "blk_a",
"--parent-id", "blk_folder",
"--after-id", "blk_b",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
require.Equal(t, "/open-apis/base/v3/bases/app_x/blocks/blk_a/move", gjson.Get(out, "api.0.url").String(), out)
require.Equal(t, "blk_folder", gjson.Get(out, "api.0.body.parent_id").String(), out)
require.Equal(t, "blk_b", gjson.Get(out, "api.0.body.after_id").String(), out)
})
t.Run("rename", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"base", "+base-block-rename",
"--base-token", "app_x",
"--block-id", "blk_a",
"--name", "Renamed",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
require.Equal(t, "/open-apis/base/v3/bases/app_x/blocks/blk_a/rename", gjson.Get(out, "api.0.url").String(), out)
require.Equal(t, "POST", gjson.Get(out, "api.0.method").String(), out)
require.Equal(t, "Renamed", gjson.Get(out, "api.0.body.name").String(), out)
})
t.Run("delete", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"base", "+base-block-delete",
"--base-token", "app_x",
"--block-id", "blk_a",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
require.Equal(t, "/open-apis/base/v3/bases/app_x/blocks/blk_a", gjson.Get(out, "api.0.url").String(), out)
require.Equal(t, "DELETE", gjson.Get(out, "api.0.method").String(), out)
})
}
@@ -0,0 +1,112 @@
// 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/require"
"github.com/tidwall/gjson"
)
func TestBaseCreateDryRun(t *testing.T) {
setBaseDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"base", "+base-create",
"--name", "Project Tracker",
"--table-name", "Tasks",
"--time-zone", "Asia/Shanghai",
"--fields", `[{"name":"Title","type":"text"},{"name":"Status","type":"text"}]`,
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
require.Equal(t, "/open-apis/base/v3/bases", gjson.Get(out, "api.0.url").String(), out)
require.Equal(t, "POST", gjson.Get(out, "api.0.method").String(), out)
require.Equal(t, "Project Tracker", gjson.Get(out, "api.0.body.name").String(), out)
require.Equal(t, "Asia/Shanghai", gjson.Get(out, "api.0.body.time_zone").String(), out)
require.Equal(t, "/open-apis/base/v3/bases/%3Ccreated_base_token%3E/tables", gjson.Get(out, "api.1.url").String(), out)
require.Equal(t, "GET", gjson.Get(out, "api.1.method").String(), out)
require.Equal(t, int64(0), gjson.Get(out, "api.1.params.offset").Int(), out)
require.Equal(t, int64(100), gjson.Get(out, "api.1.params.limit").Int(), out)
require.Equal(t, "/open-apis/base/v3/bases/%3Ccreated_base_token%3E/tables", gjson.Get(out, "api.2.url").String(), out)
require.Equal(t, "POST", gjson.Get(out, "api.2.method").String(), out)
require.Equal(t, "Tasks", gjson.Get(out, "api.2.body.name").String(), out)
require.Equal(t, "Title", gjson.Get(out, "api.2.body.fields.0.name").String(), out)
require.Equal(t, "Status", gjson.Get(out, "api.2.body.fields.1.name").String(), out)
require.Equal(t, "/open-apis/base/v3/bases/%3Ccreated_base_token%3E/tables/%3Cdefault_table_id%3E", gjson.Get(out, "api.3.url").String(), out)
require.Equal(t, "DELETE", gjson.Get(out, "api.3.method").String(), out)
}
func TestBaseCreateDryRunTableNameOnlyRenamesDefaultTable(t *testing.T) {
setBaseDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"base", "+base-create",
"--name", "Project Tracker",
"--table-name", "Tasks",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
require.Equal(t, "/open-apis/base/v3/bases", gjson.Get(out, "api.0.url").String(), out)
require.Equal(t, "POST", gjson.Get(out, "api.0.method").String(), out)
require.Equal(t, "Project Tracker", gjson.Get(out, "api.0.body.name").String(), out)
require.Equal(t, "/open-apis/base/v3/bases/%3Ccreated_base_token%3E/tables", gjson.Get(out, "api.1.url").String(), out)
require.Equal(t, "GET", gjson.Get(out, "api.1.method").String(), out)
require.Equal(t, "/open-apis/base/v3/bases/%3Ccreated_base_token%3E/tables/%3Cdefault_table_id%3E", gjson.Get(out, "api.2.url").String(), out)
require.Equal(t, "PATCH", gjson.Get(out, "api.2.method").String(), out)
require.Equal(t, "Tasks", gjson.Get(out, "api.2.body.name").String(), out)
require.False(t, gjson.Get(out, "api.3").Exists(), out)
}
func TestBaseCreateDryRunFieldsOnlyUsesDefaultTableName(t *testing.T) {
setBaseDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"base", "+base-create",
"--name", "Project Tracker",
"--fields", `[{"name":"Title","type":"text"}]`,
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
require.Equal(t, "/open-apis/base/v3/bases/%3Ccreated_base_token%3E/tables", gjson.Get(out, "api.2.url").String(), out)
require.Equal(t, "POST", gjson.Get(out, "api.2.method").String(), out)
require.Equal(t, "Table 1", gjson.Get(out, "api.2.body.name").String(), out)
require.Equal(t, "Title", gjson.Get(out, "api.2.body.fields.0.name").String(), out)
}
@@ -0,0 +1,61 @@
// 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/assert"
"github.com/stretchr/testify/require"
)
func TestBaseDashboardBlockGetDataDryRun(t *testing.T) {
setBaseDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"base", "+dashboard-block-get-data",
"--base-token", "app_x",
"--block-id", "blk_chart",
"--dry-run",
},
BinaryPath: "../../../lark-cli",
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
output := strings.TrimSpace(result.Stdout)
assert.Contains(t, output, "/open-apis/base/v3/bases/app_x/dashboards/blocks/blk_chart/data")
assert.Contains(t, output, `"method": "GET"`)
assert.Contains(t, output, `"block_id": "blk_chart"`)
assert.Contains(t, output, `"base_token": "app_x"`)
}
func TestBaseDashboardBlockGetDataDryRun_MissingRequiredFlags(t *testing.T) {
setBaseDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"base", "+dashboard-block-get-data",
"--dry-run",
},
BinaryPath: "../../../lark-cli",
DefaultAs: "bot",
})
require.NoError(t, err)
assert.NotEqual(t, 0, result.ExitCode)
assert.Contains(t, result.Stderr, "base-token")
assert.Contains(t, result.Stderr, "block-id")
}
@@ -0,0 +1,45 @@
// 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/require"
"github.com/tidwall/gjson"
)
func TestBaseFieldCreateDryRunArrayCompat(t *testing.T) {
setBaseDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"base", "+field-create",
"--base-token", "app_x",
"--table-id", "tbl_x",
"--json", `[{"name":"A","type":"text"},{"name":"B","type":"text"}]`,
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
require.Equal(t, "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields", gjson.Get(out, "api.0.url").String(), out)
require.Equal(t, "POST", gjson.Get(out, "api.0.method").String(), out)
require.Equal(t, "A", gjson.Get(out, "api.0.body.name").String(), out)
require.Equal(t, "text", gjson.Get(out, "api.0.body.type").String(), out)
require.Equal(t, "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields", gjson.Get(out, "api.1.url").String(), out)
require.Equal(t, "POST", gjson.Get(out, "api.1.method").String(), out)
require.Equal(t, "B", gjson.Get(out, "api.1.body.name").String(), out)
require.Equal(t, "text", gjson.Get(out, "api.1.body.type").String(), out)
}
@@ -0,0 +1,57 @@
// 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/assert"
"github.com/stretchr/testify/require"
)
func TestBaseFormDetailDryRun(t *testing.T) {
setBaseDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"base", "+form-detail",
"--share-token", "shrXXXX",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
output := strings.TrimSpace(result.Stdout)
assert.Contains(t, output, "/open-apis/base/v3/bases/tables/forms/detail")
assert.Contains(t, output, `"share_token"`)
assert.Contains(t, output, "shrXXXX")
assert.Contains(t, output, `"method": "POST"`)
}
func TestBaseFormDetailDryRun_MissingShareToken(t *testing.T) {
setBaseDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"base", "+form-detail",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
assert.NotEqual(t, 0, result.ExitCode)
assert.Contains(t, result.Stderr, "share-token")
}
@@ -0,0 +1,88 @@
// 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/require"
"github.com/tidwall/gjson"
)
func TestBaseListDryRunRejectsOutOfRangeLimit(t *testing.T) {
setBaseDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"base", "+table-list",
"--base-token", "app_x",
"--limit", "101",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
require.Equal(t, "validation", gjson.Get(result.Stderr, "error.type").String(), result.Stderr)
require.Equal(t, "invalid_argument", gjson.Get(result.Stderr, "error.subtype").String(), result.Stderr)
require.Equal(t, "--limit", gjson.Get(result.Stderr, "error.param").String(), result.Stderr)
require.Contains(t, gjson.Get(result.Stderr, "error.message").String(), "must be between 1 and 100")
require.Empty(t, result.Stdout)
}
func TestBaseListDryRunAcceptsPageSizeAliasForLimit(t *testing.T) {
setBaseDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"base", "+table-list",
"--base-token", "app_x",
"--page-size", "40",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
require.Equal(t, int64(0), gjson.Get(result.Stdout, "api.0.params.offset").Int(), result.Stdout)
require.Equal(t, int64(40), gjson.Get(result.Stdout, "api.0.params.limit").Int(), result.Stdout)
require.False(t, gjson.Get(result.Stdout, "api.0.params.page_size").Exists(), result.Stdout)
}
func TestBaseListDryRunRejectsLimitPageSizeConflict(t *testing.T) {
setBaseDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"base", "+table-list",
"--base-token", "app_x",
"--limit", "20",
"--page-size", "40",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
require.Equal(t, "validation", gjson.Get(result.Stderr, "error.type").String(), result.Stderr)
require.Equal(t, "invalid_argument", gjson.Get(result.Stderr, "error.subtype").String(), result.Stderr)
require.Equal(t, "--page-size", gjson.Get(result.Stderr, "error.param").String(), result.Stderr)
require.Contains(t, gjson.Get(result.Stderr, "error.message").String(), "mutually exclusive")
require.Empty(t, result.Stdout)
}
@@ -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 as bot", 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 role as bot", 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 role as bot", 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())
})
}
+100
View File
@@ -0,0 +1,100 @@
# Base CLI E2E Coverage
## Metrics
- Denominator: 78 leaf commands
- Covered: 19
- Coverage: 24.4%
## Summary
- TestBase_BasicWorkflow: proves `+base-create`, `+base-get`, `+table-create`, `+table-get`, and `+table-list`; key `t.Run(...)` proof points are `get base as bot`, `get table as bot`, and `list tables and find created table as bot`.
- TestBaseBlockDryRun: proves the five `+base-block-*` shortcuts request shapes without touching live data.
- TestBaseFieldCreateDryRunArrayCompat: proves `+field-create` dry-run request shape for the internal JSON-array compatibility path.
- TestBase_RoleWorkflow: proves `+advperm-enable`, `+role-create`, `+role-list`, `+role-get`, and `+role-update`; key `t.Run(...)` proof points are `list as bot`, `get as bot`, and `update as bot`.
- Cleanup note: `+table-delete` and `+role-delete` only run in cleanup and are intentionally left uncovered.
- Blocked area: dashboard, field, form, record, view, and workflow operations still lack deterministic create/read/update workflows in this suite.
## Command Table
| Status | Cmd | Type | Testcase | Key parameter shapes | Notes / uncovered reason |
| --- | --- | --- | --- | --- | --- |
| ✕ | base +advperm-disable | shortcut | | none | no disable workflow yet |
| ✓ | base +advperm-enable | shortcut | base_role_workflow_test.go::TestBase_RoleWorkflow | `--base-token` | |
| ✕ | base +base-copy | shortcut | | none | no copy workflow yet |
| ✓ | base +base-create | shortcut | base/helpers_test.go::createBaseWithRetry | `--name`; `--time-zone` | helper asserts created base token |
| ✓ | base +base-get | shortcut | base_basic_workflow_test.go::TestBase_BasicWorkflow/get base as bot | `--base-token` | |
| ✓ | base +base-block-create | shortcut | base_block_dryrun_test.go::TestBaseBlockDryRun/create | `--base-token`; `--type`; `--name`; `--parent-id`; dry-run only | request shape only |
| ✓ | base +base-block-delete | shortcut | base_block_dryrun_test.go::TestBaseBlockDryRun/delete | `--base-token`; `--block-id`; dry-run only | request shape only |
| ✓ | base +base-block-list | shortcut | base_block_dryrun_test.go::TestBaseBlockDryRun/list all,list folder | `--base-token`; optional `--parent-id`; optional `--type`; dry-run only | request shape only |
| ✓ | base +base-block-move | shortcut | base_block_dryrun_test.go::TestBaseBlockDryRun/move root,move after | `--base-token`; `--block-id`; optional `--parent-id`; `--after-id`; dry-run only | request shape only |
| ✓ | base +base-block-rename | shortcut | base_block_dryrun_test.go::TestBaseBlockDryRun/rename | `--base-token`; `--block-id`; `--name`; dry-run only | request shape only |
| ✕ | base +dashboard-arrange | shortcut | | none | dashboard workflows not covered |
| ✕ | base +dashboard-block-create | shortcut | | none | dashboard workflows not covered |
| ✕ | base +dashboard-block-delete | shortcut | | none | dashboard workflows not covered |
| ✕ | base +dashboard-block-get | shortcut | | none | dashboard workflows not covered |
| ✕ | base +dashboard-block-list | shortcut | | none | dashboard workflows not covered |
| ✕ | base +dashboard-block-update | shortcut | | none | dashboard workflows not covered |
| ✕ | base +dashboard-create | shortcut | | none | dashboard workflows not covered |
| ✕ | base +dashboard-delete | shortcut | | none | dashboard workflows not covered |
| ✕ | base +dashboard-get | shortcut | | none | dashboard workflows not covered |
| ✕ | base +dashboard-list | shortcut | | none | dashboard workflows not covered |
| ✕ | base +dashboard-update | shortcut | | none | dashboard workflows not covered |
| ✕ | base +data-query | shortcut | | none | no data-query assertions yet |
| ✓ | base +field-create | shortcut | base_field_dryrun_test.go::TestBaseFieldCreateDryRunArrayCompat | `--base-token`; `--table-id`; `--json`; dry-run only | request shape only |
| ✕ | base +field-delete | shortcut | | none | field workflows not covered |
| ✕ | base +field-get | shortcut | | none | field workflows not covered |
| ✕ | base +field-list | shortcut | | none | field workflows not covered |
| ✕ | base +field-search-options | shortcut | | none | field workflows not covered |
| ✕ | base +field-update | shortcut | | none | field workflows not covered |
| ✕ | base +form-create | shortcut | | none | form workflows not covered |
| ✕ | base +form-delete | shortcut | | none | form workflows not covered |
| ✕ | base +form-get | shortcut | | none | form workflows not covered |
| ✕ | base +form-list | shortcut | | none | form workflows not covered |
| ✕ | base +form-questions-create | shortcut | | none | form workflows not covered |
| ✕ | base +form-questions-delete | shortcut | | none | form workflows not covered |
| ✕ | base +form-questions-list | shortcut | | none | form workflows not covered |
| ✕ | base +form-questions-update | shortcut | | none | form workflows not covered |
| ✕ | base +form-update | shortcut | | none | form workflows not covered |
| ✕ | base +record-batch-create | shortcut | | none | record workflows not covered |
| ✕ | base +record-batch-update | shortcut | | none | record workflows not covered |
| ✕ | base +record-delete | shortcut | | none | record workflows not covered |
| ✕ | base +record-get | shortcut | | none | record workflows not covered |
| ✕ | base +record-history-list | shortcut | | none | record workflows not covered |
| ✕ | base +record-list | shortcut | | none | record workflows not covered |
| ✕ | base +record-search | shortcut | | none | record workflows not covered |
| ✓ | base +record-upload-attachment | shortcut | base_attachment_dryrun_test.go::TestBase_AttachmentDryRun/upload | dry-run only | request shape only |
| ✓ | base +record-download-attachment | shortcut | base_attachment_dryrun_test.go::TestBase_AttachmentDryRun/download | dry-run only | request shape only |
| ✓ | base +record-remove-attachment | shortcut | base_attachment_dryrun_test.go::TestBase_AttachmentDryRun/remove | dry-run only | request shape only |
| ✕ | base +record-upsert | shortcut | | none | record workflows not covered |
| ✓ | base +role-create | shortcut | base/helpers_test.go::createRole | `--base-token`; `--json` | helper asserts created role id |
| ✕ | base +role-delete | shortcut | | none | cleanup only |
| ✓ | base +role-get | shortcut | base_role_workflow_test.go::TestBase_RoleWorkflow/get as bot | `--base-token`; `--role-id` | |
| ✓ | base +role-list | shortcut | base_role_workflow_test.go::TestBase_RoleWorkflow/list as bot | `--base-token` | |
| ✓ | base +role-update | shortcut | base_role_workflow_test.go::TestBase_RoleWorkflow/update as bot | `--base-token`; `--role-id`; `--json` | |
| ✓ | base +table-create | shortcut | base/helpers_test.go::createTableWithRetry | `--base-token`; `--name`; optional `--fields`; optional `--view` | helper asserts table id |
| ✕ | base +table-delete | shortcut | | none | cleanup only |
| ✓ | base +table-get | shortcut | base_basic_workflow_test.go::TestBase_BasicWorkflow/get table as bot | `--base-token`; `--table-id` | |
| ✓ | base +table-list | shortcut | base_basic_workflow_test.go::TestBase_BasicWorkflow/list tables and find created table as bot | `--base-token` | |
| ✕ | base +table-update | shortcut | | none | no rename workflow yet |
| ✕ | base +view-create | shortcut | | none | view workflows not covered |
| ✕ | base +view-delete | shortcut | | none | view workflows not covered |
| ✕ | base +view-get | shortcut | | none | view workflows not covered |
| ✕ | base +view-get-card | shortcut | | none | view workflows not covered |
| ✕ | base +view-get-filter | shortcut | | none | view workflows not covered |
| ✕ | base +view-get-group | shortcut | | none | view workflows not covered |
| ✕ | base +view-get-sort | shortcut | | none | view workflows not covered |
| ✕ | base +view-get-timebar | shortcut | | none | view workflows not covered |
| ✕ | base +view-get-visible-fields | shortcut | | none | view workflows not covered |
| ✕ | base +view-list | shortcut | | none | view workflows not covered |
| ✕ | base +view-rename | shortcut | | none | view workflows not covered |
| ✕ | base +view-set-card | shortcut | | none | view workflows not covered |
| ✕ | base +view-set-filter | shortcut | | none | view workflows not covered |
| ✕ | base +view-set-group | shortcut | | none | view workflows not covered |
| ✕ | base +view-set-sort | shortcut | | none | view workflows not covered |
| ✕ | base +view-set-timebar | shortcut | | none | view workflows not covered |
| ✕ | base +view-set-visible-fields | shortcut | | none | view workflows not covered |
| ✕ | base +workflow-create | shortcut | | none | workflow CRUD not covered |
| ✕ | base +workflow-disable | shortcut | | none | workflow CRUD not covered |
| ✕ | base +workflow-enable | shortcut | | none | workflow CRUD not covered |
| ✕ | base +workflow-get | shortcut | | none | workflow CRUD not covered |
| ✕ | base +workflow-list | shortcut | | none | workflow CRUD not covered |
| ✕ | base +workflow-update | shortcut | | none | workflow CRUD not covered |
+219
View File
@@ -0,0 +1,219 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"context"
"strconv"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/larksuite/cli/tests/cli_e2e/drive"
"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()
const defaultAs = "bot"
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{"base", "+base-create", "--name", name, "--time-zone", "Asia/Shanghai"},
DefaultAs: defaultAs,
}, 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)
t.Cleanup(func() {
cleanupCtx, cancel := clie2e.CleanupContext()
defer cancel()
deleteResult, deleteErr := drive.DeleteDriveResourceAndVerify(cleanupCtx, baseToken, "bitable", defaultAs)
clie2e.ReportCleanupFailure(t, "delete base "+baseToken, deleteResult, deleteErr)
})
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()
}
func findBaseTableByID(t *testing.T, ctx context.Context, baseToken string, tableID string) gjson.Result {
t.Helper()
require.NotEmpty(t, baseToken, "base token is required")
require.NotEmpty(t, tableID, "table ID is required")
const pageLimit = 50
offset := 0
seenOffsets := map[int]struct{}{}
for {
if _, seen := seenOffsets[offset]; seen {
t.Fatalf("base table list pagination loop detected for base %q, repeated offset %d", baseToken, offset)
}
seenOffsets[offset] = struct{}{}
args := []string{
"base", "+table-list",
"--base-token", baseToken,
"--limit", strconv.Itoa(pageLimit),
"--offset", strconv.Itoa(offset),
}
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: args,
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
table := gjson.Get(result.Stdout, `data.tables.#(id=="`+tableID+`")`)
if table.Exists() {
return table
}
tables := gjson.Get(result.Stdout, "data.tables").Array()
if len(tables) == 0 || len(tables) < pageLimit {
t.Fatalf("table %q not found in listed pages, last stdout:\n%s", tableID, result.Stdout)
}
offset += len(tables)
}
}
@@ -0,0 +1,108 @@
// 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) {
parentT := 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
var deletedEvent bool
calendarID := getPrimaryCalendarID(t, ctx)
t.Run("create event with shortcut as bot", 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)
parentT.Cleanup(func() {
if eventID == "" || deletedEvent {
return
}
cleanupCtx, cancel := clie2e.CleanupContext()
defer cancel()
deleteResult, deleteErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{
Args: []string{"calendar", "events", "delete"},
DefaultAs: "bot",
Params: map[string]any{
"calendar_id": calendarID,
"event_id": eventID,
},
})
clie2e.ReportCleanupFailure(parentT, "delete event "+eventID, deleteResult, deleteErr)
})
})
t.Run("verify event created as bot", 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, true)
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 as bot", 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, true)
deletedEvent = true
})
}
@@ -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) {
parentT := 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
var deletedCalendar bool
t.Run("get primary calendar as bot", func(t *testing.T) {
primaryCalendarID := getPrimaryCalendarID(t, ctx)
require.NotEmpty(t, primaryCalendarID)
})
t.Run("create calendar as bot", 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, true)
createdCalendarID = gjson.Get(result.Stdout, "data.calendar.calendar_id").String()
require.NotEmpty(t, createdCalendarID)
parentT.Cleanup(func() {
if createdCalendarID == "" || deletedCalendar {
return
}
cleanupCtx, cancel := clie2e.CleanupContext()
defer cancel()
deleteResult, deleteErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{
Args: []string{"calendar", "calendars", "delete"},
DefaultAs: "bot",
Params: map[string]any{
"calendar_id": createdCalendarID,
},
Yes: true,
})
clie2e.ReportCleanupFailure(parentT, "delete calendar "+createdCalendarID, deleteResult, deleteErr)
})
})
t.Run("get created calendar as bot", 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, true)
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("update calendar as bot", 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, true)
})
t.Run("verify updated calendar as bot", 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, true)
assert.Equal(t, updatedCalendarSummary, gjson.Get(result.Stdout, "data.summary").String())
})
t.Run("delete calendar as bot", 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,
},
Yes: true,
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
deletedCalendar = true
})
}
@@ -0,0 +1,134 @@
// 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"
)
func TestCalendar_PersonalEventWorkflowAsUser(t *testing.T) {
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
clie2e.SkipWithoutUserToken(t)
suffix := clie2e.GenerateSuffix()
eventSummary := "lark-cli-e2e-personal-event-" + suffix
eventDescription := "created by calendar personal event workflow"
startAt := time.Now().UTC().Add(24 * time.Hour).Truncate(time.Minute)
endAt := startAt.Add(30 * time.Minute)
startTime := startAt.Format(time.RFC3339)
endTime := endAt.Format(time.RFC3339)
var calendarID string
var eventID string
t.Run("get primary calendar as user", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"calendar", "calendars", "primary"},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
calendarID = gjson.Get(result.Stdout, "data.calendars.0.calendar.calendar_id").String()
require.NotEmpty(t, calendarID, "stdout:\n%s", result.Stdout)
})
t.Run("create personal event with shortcut as user", func(t *testing.T) {
require.NotEmpty(t, calendarID, "calendar should be loaded before creating an event")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"calendar", "+create",
"--summary", eventSummary,
"--start", startTime,
"--end", endTime,
"--calendar-id", calendarID,
"--description", eventDescription,
},
DefaultAs: "user",
})
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, "stdout:\n%s", result.Stdout)
parentT.Cleanup(func() {
cleanupCtx, cancel := clie2e.CleanupContext()
defer cancel()
deleteResult, deleteErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{
Args: []string{"calendar", "events", "delete"},
DefaultAs: "user",
Params: map[string]any{
"calendar_id": calendarID,
"event_id": eventID,
},
})
clie2e.ReportCleanupFailure(parentT, "delete event "+eventID, deleteResult, deleteErr)
})
})
t.Run("get created event as user", func(t *testing.T) {
require.NotEmpty(t, calendarID, "calendar should be loaded before getting an event")
require.NotEmpty(t, eventID, "event should be created before reading it back")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"calendar", "events", "get"},
DefaultAs: "user",
Params: map[string]any{
"calendar_id": calendarID,
"event_id": eventID,
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
assert.Equal(t, eventID, gjson.Get(result.Stdout, "data.event.event_id").String())
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("find created event in agenda as user", func(t *testing.T) {
require.NotEmpty(t, eventID, "event should be created before checking agenda")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"calendar", "+agenda",
"--start", startTime,
"--end", endTime,
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
matchedEvent := gjson.Get(result.Stdout, `data.#(event_id=="`+eventID+`")`)
require.True(t, matchedEvent.Exists(), "stdout:\n%s", result.Stdout)
assert.Equal(t, eventSummary, matchedEvent.Get("summary").String())
agendaStart, parseErr := time.Parse(time.RFC3339, matchedEvent.Get("start_time.datetime").String())
require.NoError(t, parseErr, "stdout:\n%s", result.Stdout)
agendaEnd, parseErr := time.Parse(time.RFC3339, matchedEvent.Get("end_time.datetime").String())
require.NoError(t, parseErr, "stdout:\n%s", result.Stdout)
assert.True(t, agendaStart.Equal(startAt), "stdout:\n%s", result.Stdout)
assert.True(t, agendaEnd.Equal(endAt), "stdout:\n%s", result.Stdout)
})
}
@@ -0,0 +1,214 @@
// 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"
)
func requireFreebusyEntry(t *testing.T, stdout string, startAt time.Time, endAt time.Time, expectedRSVP string) {
t.Helper()
var matched gjson.Result
for _, item := range gjson.Parse(stdout).Get("data").Array() {
itemStart, err := time.Parse(time.RFC3339, item.Get("start_time").String())
require.NoError(t, err, "stdout:\n%s", stdout)
itemEnd, err := time.Parse(time.RFC3339, item.Get("end_time").String())
require.NoError(t, err, "stdout:\n%s", stdout)
if !itemStart.Equal(startAt) || !itemEnd.Equal(endAt) {
continue
}
if item.Get("rsvp_status").String() != expectedRSVP {
continue
}
matched = item
break
}
require.True(t, matched.Exists(), "expected freebusy entry start=%s end=%s rsvp=%s in stdout:\n%s", startAt.Format(time.RFC3339), endAt.Format(time.RFC3339), expectedRSVP, stdout)
assert.Equal(t, expectedRSVP, matched.Get("rsvp_status").String(), "stdout:\n%s", stdout)
}
func TestCalendar_RSVPWorkflowAsUser(t *testing.T) {
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
clie2e.SkipWithoutUserToken(t)
userOpenID := getCurrentUserOpenIDForCalendar(t, ctx)
calendarID := getPrimaryCalendarID(t, ctx)
startAt := time.Now().UTC().Add(2 * time.Hour).Truncate(time.Minute)
endAt := startAt.Add(30 * time.Minute)
startTime := startAt.Format(time.RFC3339)
endTime := endAt.Format(time.RFC3339)
var eventID string
t.Run("query freebusy as user", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"calendar", "+freebusy",
"--start", startTime,
"--end", endTime,
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
data := gjson.Get(result.Stdout, "data")
require.True(t, data.IsArray() || data.Type == gjson.Null, "stdout:\n%s", result.Stdout)
})
t.Run("create invite-only event as bot", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"calendar", "+create",
"--summary", "lark-cli-e2e-calendar-rsvp-" + clie2e.GenerateSuffix(),
"--start", startTime,
"--end", endTime,
"--calendar-id", calendarID,
"--attendee-ids", userOpenID,
},
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, "stdout:\n%s", result.Stdout)
parentT.Cleanup(func() {
cleanupCtx, cancel := clie2e.CleanupContext()
defer cancel()
deleteResult, deleteErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{
Args: []string{"calendar", "events", "delete"},
DefaultAs: "bot",
Params: map[string]any{
"calendar_id": calendarID,
"event_id": eventID,
},
})
clie2e.ReportCleanupFailure(parentT, "delete event "+eventID, deleteResult, deleteErr)
})
})
t.Run("reply tentative as user", func(t *testing.T) {
require.NotEmpty(t, eventID, "event should be created before RSVP")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"calendar", "+rsvp",
"--calendar-id", calendarID,
"--event-id", eventID,
"--rsvp-status", "tentative",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
require.Equal(t, calendarID, gjson.Get(result.Stdout, "data.calendar_id").String(), "stdout:\n%s", result.Stdout)
require.Equal(t, eventID, gjson.Get(result.Stdout, "data.event_id").String(), "stdout:\n%s", result.Stdout)
require.Equal(t, "tentative", gjson.Get(result.Stdout, "data.rsvp_status").String())
})
t.Run("verify tentative freebusy as user", func(t *testing.T) {
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{
"calendar", "+freebusy",
"--start", startTime,
"--end", endTime,
},
DefaultAs: "user",
}, clie2e.RetryOptions{
ShouldRetry: func(result *clie2e.Result) bool {
if result == nil || result.ExitCode != 0 || !gjson.Get(result.Stdout, "status").Bool() {
return true
}
for _, item := range gjson.Parse(result.Stdout).Get("data").Array() {
itemStart, err := time.Parse(time.RFC3339, item.Get("start_time").String())
if err != nil {
return true
}
itemEnd, err := time.Parse(time.RFC3339, item.Get("end_time").String())
if err != nil {
return true
}
if itemStart.Equal(startAt) && itemEnd.Equal(endAt) && item.Get("rsvp_status").String() == "tentative" {
return false
}
}
return true
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
requireFreebusyEntry(t, result.Stdout, startAt, endAt, "tentative")
})
t.Run("reply accept as user", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"calendar", "+rsvp",
"--calendar-id", calendarID,
"--event-id", eventID,
"--rsvp-status", "accept",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
require.Equal(t, calendarID, gjson.Get(result.Stdout, "data.calendar_id").String(), "stdout:\n%s", result.Stdout)
require.Equal(t, eventID, gjson.Get(result.Stdout, "data.event_id").String(), "stdout:\n%s", result.Stdout)
require.Equal(t, "accept", gjson.Get(result.Stdout, "data.rsvp_status").String())
})
t.Run("verify accepted freebusy as user", func(t *testing.T) {
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{
"calendar", "+freebusy",
"--start", startTime,
"--end", endTime,
},
DefaultAs: "user",
}, clie2e.RetryOptions{
ShouldRetry: func(result *clie2e.Result) bool {
if result == nil || result.ExitCode != 0 || !gjson.Get(result.Stdout, "status").Bool() {
return true
}
for _, item := range gjson.Parse(result.Stdout).Get("data").Array() {
itemStart, err := time.Parse(time.RFC3339, item.Get("start_time").String())
if err != nil {
return true
}
itemEnd, err := time.Parse(time.RFC3339, item.Get("end_time").String())
if err != nil {
return true
}
if itemStart.Equal(startAt) && itemEnd.Equal(endAt) && item.Get("rsvp_status").String() == "accept" {
return false
}
}
return true
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
requireFreebusyEntry(t, result.Stdout, startAt, endAt, "accept")
})
}
@@ -0,0 +1,59 @@
// 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/require"
"github.com/tidwall/gjson"
)
func TestCalendar_UpdateDryRun(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"calendar", "+update",
"--calendar-id", "cal_dry",
"--event-id", "evt_dry",
"--summary", "updated dry-run",
"--start", "2026-04-25T10:00:00+08:00",
"--end", "2026-04-25T11:00:00+08:00",
"--remove-attendee-ids", "ou_old,omm_oldroom",
"--add-attendee-ids", "ou_new,oc_group,omm_newroom",
"--notify=false",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
require.Equal(t, "PATCH", gjson.Get(out, "api.0.method").String(), "stdout:\n%s", out)
require.Equal(t, "/open-apis/calendar/v4/calendars/cal_dry/events/evt_dry", gjson.Get(out, "api.0.url").String(), "stdout:\n%s", out)
require.Equal(t, "updated dry-run", gjson.Get(out, "api.0.body.summary").String(), "stdout:\n%s", out)
require.False(t, gjson.Get(out, "api.0.body.need_notification").Bool(), "stdout:\n%s", out)
require.Equal(t, "POST", gjson.Get(out, "api.1.method").String(), "stdout:\n%s", out)
require.Equal(t, "/open-apis/calendar/v4/calendars/cal_dry/events/evt_dry/attendees/batch_delete", gjson.Get(out, "api.1.url").String(), "stdout:\n%s", out)
require.Equal(t, "ou_old", gjson.Get(out, `api.1.body.delete_ids.#(type=="user").user_id`).String(), "stdout:\n%s", out)
require.Equal(t, "omm_oldroom", gjson.Get(out, `api.1.body.delete_ids.#(type=="resource").room_id`).String(), "stdout:\n%s", out)
require.Equal(t, "POST", gjson.Get(out, "api.2.method").String(), "stdout:\n%s", out)
require.Equal(t, "/open-apis/calendar/v4/calendars/cal_dry/events/evt_dry/attendees", gjson.Get(out, "api.2.url").String(), "stdout:\n%s", out)
require.Equal(t, "ou_new", gjson.Get(out, `api.2.body.attendees.#(type=="user").user_id`).String(), "stdout:\n%s", out)
require.Equal(t, "oc_group", gjson.Get(out, `api.2.body.attendees.#(type=="chat").chat_id`).String(), "stdout:\n%s", out)
require.Equal(t, "omm_newroom", gjson.Get(out, `api.2.body.attendees.#(type=="resource").room_id`).String(), "stdout:\n%s", out)
}
@@ -0,0 +1,134 @@
// 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"
)
func TestCalendar_UpdateEventWorkflow(t *testing.T) {
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
clie2e.SkipWithoutUserToken(t)
suffix := clie2e.GenerateSuffix()
calendarID := getPrimaryCalendarID(t, ctx)
userOpenID := getCurrentUserOpenIDForCalendar(t, ctx)
startAt := time.Now().UTC().Add(2 * time.Hour).Truncate(time.Minute)
endAt := startAt.Add(30 * time.Minute)
updatedStartAt := startAt.Add(30 * time.Minute)
updatedEndAt := updatedStartAt.Add(45 * time.Minute)
createdSummary := "lark-cli-e2e-update-before-" + suffix
updatedSummary := "lark-cli-e2e-update-after-" + suffix
updatedDescription := "updated by calendar update workflow"
var eventID string
var deletedEvent bool
t.Run("create event as bot", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"calendar", "+create",
"--summary", createdSummary,
"--start", startAt.Format(time.RFC3339),
"--end", endAt.Format(time.RFC3339),
"--calendar-id", calendarID,
},
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, "stdout:\n%s", result.Stdout)
parentT.Cleanup(func() {
if eventID == "" || deletedEvent {
return
}
cleanupCtx, cleanupCancel := clie2e.CleanupContext()
defer cleanupCancel()
deleteResult, deleteErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{
Args: []string{"calendar", "events", "delete"},
DefaultAs: "bot",
Params: map[string]any{
"calendar_id": calendarID,
"event_id": eventID,
},
})
clie2e.ReportCleanupFailure(parentT, "delete event "+eventID, deleteResult, deleteErr)
})
})
t.Run("update event and add attendee as bot", func(t *testing.T) {
require.NotEmpty(t, eventID)
require.NotEmpty(t, userOpenID)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"calendar", "+update",
"--event-id", eventID,
"--calendar-id", calendarID,
"--summary", updatedSummary,
"--description", updatedDescription,
"--start", updatedStartAt.Format(time.RFC3339),
"--end", updatedEndAt.Format(time.RFC3339),
"--add-attendee-ids", userOpenID,
"--notify=false",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
assert.Equal(t, eventID, gjson.Get(result.Stdout, "data.event_id").String())
})
t.Run("verify updated event as bot", 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, true)
assert.Equal(t, updatedSummary, gjson.Get(result.Stdout, "data.event.summary").String())
assert.Equal(t, updatedDescription, gjson.Get(result.Stdout, "data.event.description").String())
assert.Equal(t, unixSecondsRFC3339(updatedStartAt), gjson.Get(result.Stdout, "data.event.start_time.timestamp").String())
assert.Equal(t, unixSecondsRFC3339(updatedEndAt), gjson.Get(result.Stdout, "data.event.end_time.timestamp").String())
})
t.Run("delete event as bot", 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, true)
deletedEvent = true
})
}
@@ -0,0 +1,57 @@
// 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"
)
func TestCalendar_ViewAgenda(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
clie2e.SkipWithoutUserToken(t)
calendarID := getCurrentUserPrimaryCalendarID(t, ctx)
t.Run("view today agenda as user", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"calendar", "+agenda", "--calendar-id", calendarID},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
assert.True(t, gjson.Get(result.Stdout, "data").IsArray(), "stdout:\n%s", result.Stdout)
})
t.Run("view agenda with date range as user", func(t *testing.T) {
startDate := time.Now().UTC().Format("2006-01-02")
endDate := time.Now().UTC().AddDate(0, 0, 7).Format("2006-01-02")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"calendar", "+agenda", "--calendar-id", calendarID, "--start", startDate, "--end", endDate},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
assert.True(t, gjson.Get(result.Stdout, "data").IsArray(), "stdout:\n%s", result.Stdout)
})
t.Run("view agenda with pretty format as user", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"calendar", "+agenda"},
DefaultAs: "user",
Format: "pretty",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
})
}
+43
View File
@@ -0,0 +1,43 @@
# Calendar CLI E2E Coverage
## Metrics
- Denominator: 23 leaf commands
- Covered: 11
- Coverage: 47.8%
## Summary
- TestCalendar_ViewAgenda: proves the user shortcut `calendar +agenda`; key `t.Run(...)` proof points are `view today agenda as user`, `view agenda with date range as user`, and `view agenda with pretty format as user`.
- TestCalendar_PersonalEventWorkflowAsUser: proves a self-contained user event workflow across `calendar calendars primary`, `calendar +create`, `calendar events get`, and `calendar +agenda`; key `t.Run(...)` proof points are `get primary calendar as user`, `create personal event with shortcut as user`, `get created event as user`, and `find created event in agenda as user`.
- TestCalendar_RSVPWorkflowAsUser: proves the user shortcuts `calendar +freebusy` and `calendar +rsvp`; key `t.Run(...)` proof points are `query freebusy as user`, `reply tentative as user`, `verify tentative freebusy as user`, `reply accept as user`, and `verify accepted freebusy as user`.
- TestCalendar_CreateEvent: proves `calendar +create`, `calendar events get`, and `calendar events delete`; key `t.Run(...)` proof points are `create event with shortcut as bot`, `verify event created as bot`, and `delete event as bot`.
- TestCalendar_ManageCalendar: proves `calendar calendars primary`, `calendar calendars create`, `calendar calendars get`, and `calendar calendars patch`; key `t.Run(...)` proof points are `get primary calendar as bot`, `create calendar as bot`, `get created calendar as bot`, and `update calendar as bot`.
- Cleanup note: `calendar calendars delete` is part of the calendar lifecycle workflow and is counted as covered because the workflow proves the full shared-calendar lifecycle.
- Blocked area: direct `event.attendees *` APIs, `calendar calendars search`, `calendar events create|instance_view|patch|search`, `calendar freebusys list`, and planning shortcuts `calendar +room-find` / `calendar +suggestion` still need deterministic workflows; the planning shortcuts currently depend on live tenant availability and room inventory, so they remain uncovered.
## Command Table
| Status | Cmd | Type | Testcase | Key parameter shapes | Notes / uncovered reason |
| --- | --- | --- | --- | --- | --- |
| ✓ | calendar +agenda | shortcut | calendar_view_agenda_test.go::TestCalendar_ViewAgenda; calendar_personal_event_workflow_test.go::TestCalendar_PersonalEventWorkflowAsUser/find created event in agenda as user | default today; `--start`; `--end`; `--format pretty` | user identity readback plus general agenda view |
| ✓ | calendar +create | shortcut | calendar_create_event_test.go::TestCalendar_CreateEvent/create event with shortcut as bot; calendar_personal_event_workflow_test.go::TestCalendar_PersonalEventWorkflowAsUser/create personal event with shortcut as user | `--summary`; `--start`; `--end`; `--calendar-id`; `--description` | bot and user workflow coverage |
| ✓ | calendar +freebusy | shortcut | calendar_rsvp_workflow_test.go::TestCalendar_RSVPWorkflowAsUser/query freebusy as user; calendar_rsvp_workflow_test.go::TestCalendar_RSVPWorkflowAsUser/verify tentative freebusy as user; calendar_rsvp_workflow_test.go::TestCalendar_RSVPWorkflowAsUser/verify accepted freebusy as user | default current user; `--start`; `--end` | user identity flow |
| ✕ | calendar +room-find | shortcut | | none | no deterministic self-contained workflow yet; output depends on live room inventory |
| ✓ | calendar +rsvp | shortcut | calendar_rsvp_workflow_test.go::TestCalendar_RSVPWorkflowAsUser/reply tentative as user; calendar_rsvp_workflow_test.go::TestCalendar_RSVPWorkflowAsUser/reply accept as user | `--calendar-id`; `--event-id`; `--rsvp-status` | user reply flow |
| ✕ | calendar +suggestion | shortcut | | none | no deterministic self-contained workflow yet; output depends on live availability suggestions |
| ✓ | calendar calendars create | api | calendar_manage_calendar_test.go::TestCalendar_ManageCalendar/create calendar as bot | `summary`; `description` in `--data` | |
| ✓ | calendar calendars delete | api | calendar_manage_calendar_test.go::TestCalendar_ManageCalendar/delete calendar as bot | `calendar_id` in `--params` | |
| ✓ | calendar calendars get | api | calendar_manage_calendar_test.go::TestCalendar_ManageCalendar/get created calendar as bot; calendar_manage_calendar_test.go::TestCalendar_ManageCalendar/verify updated calendar as bot | `calendar_id` in `--params` | |
| ✕ | calendar calendars list | api | | none | removed from the live workflow because tenant history made list latency non-deterministic |
| ✓ | calendar calendars patch | api | calendar_manage_calendar_test.go::TestCalendar_ManageCalendar/update calendar as bot | `calendar_id` in `--params`; `summary` in `--data` | |
| ✓ | calendar calendars primary | api | calendar_manage_calendar_test.go::TestCalendar_ManageCalendar/get primary calendar as bot; calendar_personal_event_workflow_test.go::TestCalendar_PersonalEventWorkflowAsUser/get primary calendar as user | none | bot and user primary calendar lookup |
| ✕ | calendar calendars search | api | | none | no search workflow yet |
| ✕ | calendar events create | api | | none | only covered indirectly through `calendar +create` |
| ✓ | calendar events delete | api | calendar_create_event_test.go::TestCalendar_CreateEvent/delete event as bot | `calendar_id`; `event_id` in `--params` | |
| ✓ | calendar events get | api | calendar_create_event_test.go::TestCalendar_CreateEvent/verify event created as bot; calendar_personal_event_workflow_test.go::TestCalendar_PersonalEventWorkflowAsUser/get created event as user | `calendar_id`; `event_id` in `--params` | bot and user read-after-write coverage |
| ✕ | calendar events instance_view | api | | none | `+agenda` is indirect orchestration, not direct API coverage |
| ✕ | calendar events patch | api | | none | no direct event-update workflow yet |
| ✕ | calendar events search | api | | none | no search workflow yet |
| ✕ | calendar freebusys list | api | | none | no direct freebusy API workflow yet |
| ✕ | calendar event.attendees batch_delete | api | | none | requires an isolated attendee lifecycle workflow |
| ✕ | calendar event.attendees create | api | | none | requires an isolated attendee lifecycle workflow |
| ✕ | calendar event.attendees list | api | | none | requires an isolated attendee lifecycle workflow |
+67
View File
@@ -0,0 +1,67 @@
// 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, true)
calendarID := gjson.Get(result.Stdout, "data.calendars.0.calendar.calendar_id").String()
require.NotEmpty(t, calendarID, "stdout:\n%s", result.Stdout)
return calendarID
}
func getCurrentUserPrimaryCalendarID(t *testing.T, ctx context.Context) string {
t.Helper()
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"calendar", "calendars", "primary"},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
calendarID := gjson.Get(result.Stdout, "data.calendars.0.calendar.calendar_id").String()
require.NotEmpty(t, calendarID, "stdout:\n%s", result.Stdout)
return calendarID
}
func getCurrentUserOpenIDForCalendar(t *testing.T, ctx context.Context) string {
t.Helper()
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"contact", "+get-user"},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
openID := gjson.Get(result.Stdout, "data.user.open_id").String()
require.NotEmpty(t, openID, "stdout:\n%s", result.Stdout)
return openID
}
func unixSecondsRFC3339(t time.Time) string {
return strconv.FormatInt(t.Unix(), 10)
}
@@ -0,0 +1,122 @@
---
name: cli-e2e-testcase-writer
description: Use when adding or updating Go CLI E2E coverage for one `tests/cli_e2e/{domain}` domain of the compiled `lark-cli`, especially when the work requires live `--help` or `schema` exploration, scenario-based `clie2e.RunCmd` workflows, and per-domain `coverage.md` maintenance.
metadata:
requires:
bins: ["lark-cli"]
---
# CLI E2E Testcase Writer
Work on one domain per run. Produce exactly two artifacts for that domain:
- workflow testcase files under `tests/cli_e2e/{domain}/`
- `tests/cli_e2e/{domain}/coverage.md`
Focus on domain testcase files. Do not change shared E2E support code such as `tests/cli_e2e/core.go` unless the user explicitly asks. Treat `tests/cli_e2e/demo/` as reference only.
## Core standard
- Make the testcase scenario-based and self-contained.
- Prove one workflow end to end: create plus follow-up read, or mutate plus teardown.
- Prefer one file per workflow or one closely related feature.
- For mutable flows, prove persisted state with read-after-write assertions, not just exit code.
- Leave prerequisite-heavy paths uncovered when they cannot be proven, and explain why in `coverage.md`.
## Workflow
### 1. Explore the live CLI before writing code
```bash
lark-cli --help
lark-cli <domain> --help
lark-cli <domain> +<shortcut> -h
lark-cli <domain> <group> --help
lark-cli <domain> <group> <method> -h
lark-cli schema <domain>.<group>.<method>
```
### 2. Count leaf commands for the denominator
- A leaf command is one that executes an action — it has no further subcommands.
- If `lark-cli <domain> <group> --help` lists no subcommands, `<group>` itself is the leaf.
- Count `task +create` as one leaf and `task tasks get` as one leaf.
- Do not count parameter combinations.
- Reuse coverage already present under `tests/cli_e2e/{domain}/`. Do not count `tests/cli_e2e/demo/`.
### 3. Choose the proof surface before editing
Identify the provable risks for the touched workflow: invalid input, missing prerequisite, identity or permission, state transition, output shape, cleanup safety. If only the happy path is testable, document the blocked risk areas in `coverage.md`.
### 4. Add or update the workflow testcase
- Use `clie2e.RunCmd(ctx, clie2e.Request{...})`.
- Put command path and plain flags in `Args`; put JSON in `Params` (URL/path parameters) and `Data` (request body).
- Prefer one top-level test per workflow with `t.Run` substeps.
- Register teardown on `parentT.Cleanup` so it survives subtest failures.
- When touching an existing command, verify the JSON response shape is stable: assert status type, field paths, and identifiers consumed by later steps before changing assertions.
### 5. Run and iterate
Run `go test ./tests/cli_e2e/{domain} -count=1` while iterating and before finishing. If command shape or behavior is unclear, re-check help or schema (step 1) before changing assertions.
### 6. Refresh the domain outputs
- Update the workflow testcase files.
- Update `coverage.md`: recompute the denominator from live help output, mark each command as `shortcut` or `api`, and keep one command table for the whole domain.
## Testcase rules
- Override `BinaryPath`, `DefaultAs`, or `Format` on `clie2e.Request` only when the testcase truly needs it.
- Use `require.NoError`, `result.AssertExitCode`, `result.AssertStdoutStatus`, `assert`, and `gjson`.
- Shortcut responses (`{ok: bool}`) assert `true`; API responses (`{code: int}`) assert `0`.
- Use `t.Helper()` only for setup or assertion helpers that are called from multiple tests.
- Use table-driven tests only when the scenario shape repeats across inputs.
- For expected failures, assert stderr content and exit code when the environment makes them deterministic.
- If identity or external fixtures cannot be proven, leave the command uncovered and document the prerequisite rather than faking confidence.
## coverage.md
Keep `coverage.md` brief and mechanical. Include:
- a domain-specific H1 title
- a metrics section with denominator, covered count, and coverage rate
- a summary section restating each `Test...` workflow, key `t.Run(...)` proof points, and main blockers
- one command table for all commands
Recommended structure:
```markdown
# <Domain> CLI E2E Coverage
## Metrics
- Denominator: N leaf commands
- Covered: N
- Coverage: N%
## Summary
- TestXxx: ... key `t.Run(...)` proof points ...
- Blocked area: ...
## Command Table
| Status | Cmd | Type | Testcase | Key parameter shapes | Notes / uncovered reason |
| --- | --- | --- | --- | --- | --- |
| ✓ | task +create | shortcut | task_status_workflow_test.go::TestTask_StatusWorkflow | basic create; create with due | |
| ✕ | task +assign | shortcut | | none | requires real user open_id |
```
- Mark each command `shortcut` or `api`.
- Write testcase entries in `go test -run` friendly form.
- Commands only exercised in `parentT.Cleanup` teardown are not counted as covered.
- Do not split covered and uncovered commands into separate sections.
## Guardrails
- Run as bot identity only; do not assume `--as user` works.
- Do not place new real coverage under `tests/cli_e2e/demo/`.
- Do not depend on preexisting remote data.
- Do not fabricate open_ids, chats, docs, or other remote fixtures.
- Prefer deterministic negative cases over tenant-dependent assertions.
- Do not guess `Params` or `Data` fields when help or schema can tell you the exact shape.
- Do not hardcode obvious defaults unless the command truly requires explicit flags.
- Do not put agent, model, or vendor brand names in visible remote test data; use neutral prefixes such as `lark-cli-e2e-` or `<domain>-e2e-`.
- A command is covered only when the testcase asserts returned fields or persisted state, not just exit code.
- Cleanup-only execution is not primary coverage, except `delete` in the same workflow that created the resource.
+454
View File
@@ -0,0 +1,454 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
import (
"context"
"os"
"path/filepath"
"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"
)
// clearAgentEnv removes every env var that DetectWorkspaceFromEnv treats as
// an Agent signal (OPENCLAW_* / HERMES_* / LARK_CHANNEL). Prefix-based so the
// helper stays correct when DetectWorkspaceFromEnv adds new signals; tests
// no longer drift silently. Mirrors cmd/config/bind_test.go's helper.
func clearAgentEnv(t *testing.T) {
t.Helper()
for _, kv := range os.Environ() {
idx := strings.IndexByte(kv, '=')
if idx < 0 {
continue
}
k := kv[:idx]
if strings.HasPrefix(k, "OPENCLAW_") ||
strings.HasPrefix(k, "HERMES_") ||
k == "LARK_CHANNEL" {
t.Setenv(k, "")
}
}
}
// setupTempConfig creates a temp config dir and sets LARKSUITE_CLI_CONFIG_DIR.
func setupTempConfig(t *testing.T) string {
t.Helper()
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
return dir
}
// writeHermesEnv creates a fake ~/.hermes/.env with feishu credentials.
func writeHermesEnv(t *testing.T, hermesHome, appID, appSecret, domain string) {
t.Helper()
require.NoError(t, os.MkdirAll(hermesHome, 0700))
content := "FEISHU_APP_ID=" + appID + "\nFEISHU_APP_SECRET=" + appSecret + "\n"
if domain != "" {
content += "FEISHU_DOMAIN=" + domain + "\n"
}
require.NoError(t, os.WriteFile(filepath.Join(hermesHome, ".env"), []byte(content), 0600))
}
// writeOpenClawConfig creates a fake openclaw.json with a single feishu account.
func writeOpenClawConfig(t *testing.T, openclawHome, appID, appSecret, brand string) {
t.Helper()
dir := filepath.Join(openclawHome, ".openclaw")
require.NoError(t, os.MkdirAll(dir, 0700))
content := `{"channels":{"feishu":{"appId":"` + appID + `","appSecret":"` + appSecret + `","domain":"` + brand + `"}}}`
require.NoError(t, os.WriteFile(filepath.Join(dir, "openclaw.json"), []byte(content), 0600))
}
// writeLarkChannelConfig creates a fake ~/.lark-channel/config.json under
// fakeHome (caller is responsible for setting HOME=fakeHome via t.Setenv).
func writeLarkChannelConfig(t *testing.T, fakeHome, appID, appSecret, tenant string) {
t.Helper()
dir := filepath.Join(fakeHome, ".lark-channel")
require.NoError(t, os.MkdirAll(dir, 0700))
content := `{"accounts":{"app":{"id":"` + appID + `","secret":"` + appSecret + `","tenant":"` + tenant + `"}}}`
require.NoError(t, os.WriteFile(filepath.Join(dir, "config.json"), []byte(content), 0600))
}
// assertStderrError verifies the structured error JSON envelope in stderr.
// Checks error.type and error.message exactly. hint is checked if non-empty.
func assertStderrError(t *testing.T, result *clie2e.Result, wantExitCode int, wantType, wantMessage, wantHint string) {
t.Helper()
assert.Equal(t, wantExitCode, result.ExitCode, "exit code mismatch\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
errJSON := gjson.Get(result.Stderr, "error")
require.True(t, errJSON.Exists(), "stderr missing 'error' JSON envelope\nstderr:\n%s", result.Stderr)
assert.Equal(t, wantType, errJSON.Get("type").String(),
"error.type mismatch\nstderr:\n%s", result.Stderr)
assert.Equal(t, wantMessage, errJSON.Get("message").String(),
"error.message mismatch\nstderr:\n%s", result.Stderr)
if wantHint != "" {
assert.Equal(t, wantHint, errJSON.Get("hint").String(),
"error.hint mismatch\nstderr:\n%s", result.Stderr)
}
}
func TestBind_InvalidSource(t *testing.T) {
setupTempConfig(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"config", "bind", "--source", "invalid"},
})
require.NoError(t, err)
assertStderrError(t, result, 2, "validation",
`invalid --source "invalid"; valid values: openclaw, hermes, lark-channel`, "")
}
func TestBind_MissingSource_NonTTY(t *testing.T) {
setupTempConfig(t)
// Clear Agent env so DetectWorkspaceFromEnv returns WorkspaceLocal and
// finalizeSource hits the "cannot determine Agent source" branch instead
// of silently auto-detecting whichever Agent the CI runner happens to
// inherit env from.
clearAgentEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"config", "bind"},
Stdin: []byte{}, // force non-TTY via explicit empty stdin
})
require.NoError(t, err)
// finalizeSource emits a CategoryValidation typed error
// (subtype=invalid_argument, param=--source); this is a distinct path
// from the typed config errors below.
assertStderrError(t, result, 2, "validation",
"cannot determine Agent source: no --source flag and no Agent environment detected",
"pass --source openclaw|hermes|lark-channel, or run this command inside the corresponding Agent context")
}
func TestBind_Hermes_Success(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
configDir := setupTempConfig(t)
hermesHome := filepath.Join(t.TempDir(), ".hermes-test")
t.Setenv("HERMES_HOME", hermesHome)
writeHermesEnv(t, hermesHome, "cli_e2e_test", "e2e_secret", "lark")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"config", "bind", "--source", "hermes"},
})
require.NoError(t, err)
if result.ExitCode == 0 {
// Success path: verify stdout JSON envelope exactly
stdout := result.Stdout
assert.True(t, gjson.Get(stdout, "ok").Bool(), "stdout:\n%s", stdout)
assert.Equal(t, "hermes", gjson.Get(stdout, "workspace").String(), "stdout:\n%s", stdout)
assert.Equal(t, "cli_e2e_test", gjson.Get(stdout, "app_id").String(), "stdout:\n%s", stdout)
expectedConfigPath := filepath.Join(configDir, "hermes", "config.json")
assert.Equal(t, expectedConfigPath, gjson.Get(stdout, "config_path").String(), "stdout:\n%s", stdout)
// Verify config file content exactly
data, readErr := os.ReadFile(expectedConfigPath)
require.NoError(t, readErr)
assert.Equal(t, "cli_e2e_test", gjson.GetBytes(data, "apps.0.appId").String())
assert.Equal(t, "lark", gjson.GetBytes(data, "apps.0.brand").String())
} else {
// Keychain failure is acceptable in CI — verify error type is keychain-related.
// Note: exact message depends on OS keychain error (platform-dependent), so we
// check the structured type field instead of message text.
errType := gjson.Get(result.Stderr, "error.type").String()
assert.Equal(t, "hermes", errType,
"non-zero exit should be from hermes bind path\nstderr:\n%s", result.Stderr)
}
}
func TestBind_Hermes_MissingEnvFile(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
setupTempConfig(t)
hermesHome := filepath.Join(t.TempDir(), "nonexistent")
t.Setenv("HERMES_HOME", hermesHome)
envPath := filepath.Join(hermesHome, ".env")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"config", "bind", "--source", "hermes"},
})
require.NoError(t, err)
// The hermes config error is constructed typed at its origin with
// subtype=not_configured; CategoryConfig → exit 3.
assertStderrError(t, result, 3, "config",
"failed to read Hermes config: open "+envPath+": no such file or directory",
"verify Hermes is installed and configured at "+envPath)
}
func TestBind_Hermes_MissingAppID(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
setupTempConfig(t)
hermesHome := filepath.Join(t.TempDir(), ".hermes-test")
t.Setenv("HERMES_HOME", hermesHome)
require.NoError(t, os.MkdirAll(hermesHome, 0700))
require.NoError(t, os.WriteFile(
filepath.Join(hermesHome, ".env"),
[]byte("FEISHU_APP_SECRET=secret_only\n"),
0600,
))
envPath := filepath.Join(hermesHome, ".env")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"config", "bind", "--source", "hermes"},
})
require.NoError(t, err)
// The hermes config error is constructed typed at its origin with
// subtype=not_configured; CategoryConfig → exit 3.
assertStderrError(t, result, 3, "config",
"FEISHU_APP_ID not found in "+envPath,
"run 'hermes setup' to configure Feishu credentials")
}
func TestBind_FlagMode_Overwrite(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
configDir := setupTempConfig(t)
hermesHome := filepath.Join(t.TempDir(), ".hermes-test")
t.Setenv("HERMES_HOME", hermesHome)
writeHermesEnv(t, hermesHome, "cli_e2e_new", "e2e_new_secret", "feishu")
// Pre-create config to simulate existing binding
hermesDir := filepath.Join(configDir, "hermes")
require.NoError(t, os.MkdirAll(hermesDir, 0700))
configPath := filepath.Join(hermesDir, "config.json")
require.NoError(t, os.WriteFile(configPath, []byte(`{"apps":[{"appId":"old_app"}]}`), 0600))
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"config", "bind", "--source", "hermes"},
})
require.NoError(t, err)
if result.ExitCode == 0 {
// Flag mode silently overwrites and flags replaced=true in stdout.
assert.True(t, gjson.Get(result.Stdout, "ok").Bool(), "stdout:\n%s", result.Stdout)
assert.True(t, gjson.Get(result.Stdout, "replaced").Bool(), "stdout:\n%s", result.Stdout)
assert.Equal(t, "cli_e2e_new", gjson.Get(result.Stdout, "app_id").String(), "stdout:\n%s", result.Stdout)
// Rebind is now signalled only by `replaced:true` in the stdout
// envelope (checked above). stderr only carries the standard
// success header; sanity-check its prefix is present.
assert.Contains(t, result.Stderr, "配置成功",
"stderr should carry the bind-success header\nstderr:\n%s", result.Stderr)
} else {
// Keychain failure acceptable in CI
errType := gjson.Get(result.Stderr, "error.type").String()
assert.Equal(t, "hermes", errType,
"non-zero exit should be from hermes bind path\nstderr:\n%s", result.Stderr)
}
}
func TestBind_ConfigShow_WorkspaceField(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
configDir := setupTempConfig(t)
// Test asserts workspace == "local"; clear Agent signals so an inherited
// LARK_CHANNEL=1 / OPENCLAW_* / HERMES_* doesn't reroute to a workspace
// where the local config we just wrote is invisible.
clearAgentEnv(t)
require.NoError(t, os.WriteFile(
filepath.Join(configDir, "config.json"),
[]byte(`{"apps":[{"appId":"cli_local","appSecret":"secret","brand":"feishu"}]}`),
0600,
))
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"config", "show"},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "local", gjson.Get(result.Stdout, "workspace").String())
assert.Equal(t, "cli_local", gjson.Get(result.Stdout, "appId").String())
}
func TestBind_ConfigShow_UnboundWorkspace(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
setupTempConfig(t)
t.Setenv("OPENCLAW_CLI", "1") // force openclaw workspace
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"config", "show"},
})
require.NoError(t, err)
// The openclaw config error is constructed typed at its origin with
// subtype=not_configured; CategoryConfig → exit 3.
assertStderrError(t, result, 3, "config",
"openclaw context detected but lark-cli is not bound to it",
"read `lark-cli config bind --help`, then ask the user to confirm intent and identity preset (bot-only or user-default); only after both are confirmed, run `lark-cli config bind`")
}
func TestBind_OpenClaw_MissingFile(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
setupTempConfig(t)
openclawHome := filepath.Join(t.TempDir(), "nonexistent")
t.Setenv("OPENCLAW_HOME", openclawHome)
t.Setenv("OPENCLAW_CONFIG_PATH", "")
t.Setenv("OPENCLAW_STATE_DIR", "")
configPath := filepath.Join(openclawHome, ".openclaw", "openclaw.json")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"config", "bind", "--source", "openclaw"},
})
require.NoError(t, err)
// The openclaw config error is constructed typed at its origin with
// subtype=not_configured; CategoryConfig → exit 3.
assertStderrError(t, result, 3, "config",
"cannot read "+configPath+": open "+configPath+": no such file or directory",
"verify OpenClaw is installed and configured")
}
func TestBind_OpenClaw_Success(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
configDir := setupTempConfig(t)
openclawHome := t.TempDir()
t.Setenv("OPENCLAW_HOME", openclawHome)
t.Setenv("OPENCLAW_CONFIG_PATH", "")
t.Setenv("OPENCLAW_STATE_DIR", "")
writeOpenClawConfig(t, openclawHome, "cli_oc_test", "oc_secret", "feishu")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"config", "bind", "--source", "openclaw"},
})
require.NoError(t, err)
if result.ExitCode == 0 {
// Success path: verify stdout JSON envelope exactly
stdout := result.Stdout
assert.True(t, gjson.Get(stdout, "ok").Bool(), "stdout:\n%s", stdout)
assert.Equal(t, "openclaw", gjson.Get(stdout, "workspace").String(), "stdout:\n%s", stdout)
assert.Equal(t, "cli_oc_test", gjson.Get(stdout, "app_id").String(), "stdout:\n%s", stdout)
expectedConfigPath := filepath.Join(configDir, "openclaw", "config.json")
assert.Equal(t, expectedConfigPath, gjson.Get(stdout, "config_path").String(), "stdout:\n%s", stdout)
// Verify config file content exactly
data, readErr := os.ReadFile(expectedConfigPath)
require.NoError(t, readErr)
assert.Equal(t, "cli_oc_test", gjson.GetBytes(data, "apps.0.appId").String())
assert.Equal(t, "feishu", gjson.GetBytes(data, "apps.0.brand").String())
} else {
// Keychain failure acceptable in CI
errType := gjson.Get(result.Stderr, "error.type").String()
assert.Equal(t, "openclaw", errType,
"non-zero exit should be from openclaw bind path\nstderr:\n%s", result.Stderr)
}
}
// TestBind_LarkChannel_Success exercises the full end-to-end happy path:
// fake bridge config under HOME → bind reads it → workspace config written
// to LARKSUITE_CLI_CONFIG_DIR/lark-channel/config.json with brand from tenant.
func TestBind_LarkChannel_Success(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
configDir := setupTempConfig(t)
fakeHome := t.TempDir()
t.Setenv("HOME", fakeHome)
writeLarkChannelConfig(t, fakeHome, "cli_lc_e2e", "lc_secret", "lark")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"config", "bind", "--source", "lark-channel"},
})
require.NoError(t, err)
if result.ExitCode == 0 {
stdout := result.Stdout
assert.True(t, gjson.Get(stdout, "ok").Bool(), "stdout:\n%s", stdout)
assert.Equal(t, "lark-channel", gjson.Get(stdout, "workspace").String(), "stdout:\n%s", stdout)
assert.Equal(t, "cli_lc_e2e", gjson.Get(stdout, "app_id").String(), "stdout:\n%s", stdout)
expectedConfigPath := filepath.Join(configDir, "lark-channel", "config.json")
assert.Equal(t, expectedConfigPath, gjson.Get(stdout, "config_path").String(), "stdout:\n%s", stdout)
data, readErr := os.ReadFile(expectedConfigPath)
require.NoError(t, readErr)
assert.Equal(t, "cli_lc_e2e", gjson.GetBytes(data, "apps.0.appId").String())
assert.Equal(t, "lark", gjson.GetBytes(data, "apps.0.brand").String())
} else {
// Keychain failure acceptable in CI; verify the error came from the
// lark-channel binder (i.e. routing was correct) rather than another path.
errType := gjson.Get(result.Stderr, "error.type").String()
assert.Equal(t, "lark-channel", errType,
"non-zero exit should be from lark-channel bind path\nstderr:\n%s", result.Stderr)
}
}
// TestBind_LarkChannel_MissingFile verifies the routed error path when the
// bridge has not been configured: hint must point at bridge setup, not at
// `config init` (which would silently create a parallel local app and waste
// the user's existing bridge credentials).
func TestBind_LarkChannel_MissingFile(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
setupTempConfig(t)
fakeHome := t.TempDir() // empty — no .lark-channel/config.json
t.Setenv("HOME", fakeHome)
configPath := filepath.Join(fakeHome, ".lark-channel", "config.json")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"config", "bind", "--source", "lark-channel"},
})
require.NoError(t, err)
// The lark-channel config error is constructed typed at its origin with
// subtype=not_configured; CategoryConfig → exit 3.
assertStderrError(t, result, 3, "config",
"cannot read "+configPath+": open "+configPath+": no such file or directory",
"verify lark-channel-bridge is installed and configured")
}
// TestBind_LarkChannel_AutoDetect verifies LARK_CHANNEL=1 alone routes the
// no-flag bind into the lark-channel workspace (matches the bridge's actual
// runtime — it sets the env, not --source).
func TestBind_LarkChannel_AutoDetect(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
setupTempConfig(t)
// Clear other agent env so OpenClaw/Hermes signals from the host shell
// don't preempt the lark-channel detection.
clearAgentEnv(t)
t.Setenv("LARK_CHANNEL", "1")
fakeHome := t.TempDir()
t.Setenv("HOME", fakeHome)
writeLarkChannelConfig(t, fakeHome, "cli_lc_auto", "auto_secret", "feishu")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"config", "bind"}, // no --source
})
require.NoError(t, err)
if result.ExitCode == 0 {
assert.Equal(t, "lark-channel", gjson.Get(result.Stdout, "workspace").String(),
"stdout:\n%s", result.Stdout)
} else {
errType := gjson.Get(result.Stderr, "error.type").String()
assert.Equal(t, "lark-channel", errType,
"non-zero exit should be from lark-channel bind path\nstderr:\n%s", result.Stderr)
}
}
@@ -0,0 +1,94 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package contact
import (
"context"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
)
func TestContact_LookupWorkflowAsUser(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
clie2e.SkipWithoutUserToken(t)
var selfOpenID string
t.Run("get self as user", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"contact", "+get-user"},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
selfOpenID = gjson.Get(result.Stdout, "data.user.open_id").String()
require.NotEmpty(t, selfOpenID, "stdout:\n%s", result.Stdout)
})
t.Run("get self by open id as user", func(t *testing.T) {
require.NotEmpty(t, selfOpenID, "self open_id should be populated before get-by-id")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"contact", "+get-user", "--user-id", selfOpenID},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
require.Equal(t, selfOpenID, gjson.Get(result.Stdout, "data.user.user_id").String(), "stdout:\n%s", result.Stdout)
})
}
func TestContact_LookupWorkflowAsBot(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
var targetOpenID string
t.Run("discover user via api as bot", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"api", "get", "/open-apis/contact/v3/users"},
DefaultAs: "bot",
})
require.NoError(t, err)
if result.ExitCode != 0 {
stderrLower := strings.ToLower(result.Stderr)
if strings.Contains(stderrLower, "permission denied") || strings.Contains(stderrLower, "99991679") {
t.Skipf("skip bot contact workflow due to missing bot contact permissions: %s", result.Stderr)
}
}
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
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 open id as bot", func(t *testing.T) {
if targetOpenID == "" {
t.Skip("skip bot get-user-by-id because discover-user-via-api did not provide targetOpenID")
}
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"contact", "+get-user", "--user-id", targetOpenID},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
require.Equal(t, targetOpenID, gjson.Get(result.Stdout, "data.user.open_id").String(), "stdout:\n%s", result.Stdout)
})
}
+18
View File
@@ -0,0 +1,18 @@
# Contact CLI E2E Coverage
## Metrics
- Denominator: 2 leaf commands
- Covered: 1
- Coverage: 50.0%
## Summary
- TestContact_LookupWorkflowAsUser: proves the user lookup workflow through `get self as user` and `get self by open id as user`; reads the current user first and round-trips the returned `open_id` back into `+get-user`.
- TestContact_LookupWorkflowAsBot: proves bot lookup through `discover user via api as bot` and `get user by open id as bot`; the raw API discovery step is fixture setup only and does not affect the domain denominator.
- Blocked area: `contact +search-user` did not reliably return the current user in UAT even when queried with self-derived identifiers, so it remains uncovered rather than being counted from a flaky tenant-dependent assertion.
## Command Table
| Status | Cmd | Type | Testcase | Key parameter shapes | Notes / uncovered reason |
| --- | --- | --- | --- | --- | --- |
| ✓ | contact +get-user | shortcut | contact_lookup_workflow_test.go::TestContact_LookupWorkflowAsUser/get self as user; contact_lookup_workflow_test.go::TestContact_LookupWorkflowAsUser/get self by open id as user; contact_lookup_workflow_test.go::TestContact_LookupWorkflowAsBot/get user by open id as bot | self lookup; `--user-id <open_id>` | |
| ✕ | contact +search-user | shortcut | | none | UAT did not reliably return the current user for self-derived queries, so stable write-after-read style proof is not available |
+590
View File
@@ -0,0 +1,590 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package clie2e contains end-to-end tests for lark-cli.
package clie2e
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/tidwall/gjson"
)
const EnvBinaryPath = "LARK_CLI_BIN"
const projectRootMarkerDir = "tests"
const cliBinaryName = "lark-cli"
const (
// CleanupTimeout is the outer teardown budget. Keep it above any
// per-resource wait so cleanup command retries still have room to run.
CleanupTimeout = 60 * time.Second
defaultRetryAttempts = 4
defaultRetryInitialDelay = time.Second
defaultRetryMaxDelay = 6 * time.Second
defaultRetryBackoffMultiple = 2
)
func SkipWithoutUserToken(t *testing.T) {
t.Helper()
if os.Getenv("LARKSUITE_CLI_USER_ACCESS_TOKEN") != "" {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
result, err := RunCmd(ctx, Request{
Args: []string{"auth", "status", "--verify"},
})
if err != nil {
t.Skipf("skipped: LARKSUITE_CLI_USER_ACCESS_TOKEN not set and failed to check local user login via `lark-cli auth status --verify`: %v", err)
}
if result.ExitCode != 0 {
t.Skipf("skipped: LARKSUITE_CLI_USER_ACCESS_TOKEN not set and local user login check failed: exit=%d stderr=%s", result.ExitCode, strings.TrimSpace(result.Stderr))
}
stdout := strings.TrimSpace(result.Stdout)
if stdout == "" {
t.Skip("skipped: LARKSUITE_CLI_USER_ACCESS_TOKEN not set and `lark-cli auth status --verify` returned empty stdout")
}
if !gjson.Valid(stdout) {
t.Skipf("skipped: LARKSUITE_CLI_USER_ACCESS_TOKEN not set and `lark-cli auth status --verify` returned non-JSON stdout: %s", stdout)
}
if identity := gjson.Get(stdout, "identity").String(); identity != "user" {
t.Skip("skipped: LARKSUITE_CLI_USER_ACCESS_TOKEN not set and local auth is not a verified user login")
}
if verified := gjson.Get(stdout, "verified"); verified.Exists() && !verified.Bool() {
verifyErr := gjson.Get(stdout, "verifyError").String()
if verifyErr != "" {
t.Skipf("skipped: LARKSUITE_CLI_USER_ACCESS_TOKEN not set and local user login verification failed: %s", verifyErr)
}
t.Skip("skipped: LARKSUITE_CLI_USER_ACCESS_TOKEN not set and local user login verification failed")
}
}
// Request describes one lark-cli invocation.
type Request struct {
// Args are required and exclude the lark-cli binary name.
Args []string
// Params is optional and becomes --params '<json>' when non-nil.
Params any
// Data is optional and becomes --data '<json>' when non-nil.
Data any
// Stdin is optional and becomes the child process stdin when non-nil.
// Use an empty slice to exercise empty-stdin behavior explicitly.
Stdin []byte
// BinaryPath is optional. Empty means: LARK_CLI_BIN, project-root ./lark-cli, then PATH.
BinaryPath string
// DefaultAs is optional and becomes --as <value> when non-empty.
DefaultAs string
// Format is optional and becomes --format <format> when non-empty.
Format string
// WorkDir is optional and becomes the child process working directory when non-empty.
WorkDir string
// Env adds or overrides environment variables for this one child process only.
Env map[string]string
// Yes confirms high-risk-write commands. When true, the runner appends
// --yes so the framework-level confirmation gate passes. Setting it on a
// non-high-risk command will fail with "unknown flag: --yes".
Yes bool
}
// Result captures process execution output.
type Result struct {
BinaryPath string
Args []string
ExitCode int
Stdout string
Stderr string
RunErr error
}
type cleanupWarningError struct {
err error
}
func (e *cleanupWarningError) Error() string {
return e.err.Error()
}
func (e *cleanupWarningError) Unwrap() error {
return e.err
}
// CleanupWarning marks a cleanup verification issue as non-fatal after the
// destructive cleanup command itself has already succeeded.
func CleanupWarning(err error) error {
if err == nil {
return nil
}
return &cleanupWarningError{err: err}
}
// IsCleanupWarning reports whether err should be logged without failing the
// parent test.
func IsCleanupWarning(err error) bool {
var warning *cleanupWarningError
return errors.As(err, &warning)
}
// 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
}
// WaitOptions configures a bounded poll loop for eventually consistent cleanup
// or verification checks.
type WaitOptions struct {
Timeout time.Duration
Interval time.Duration
TimeoutError func() error
}
// RunCmd executes lark-cli and captures stdout/stderr/exit code.
// Service errors that return {"error":{"retryable":true}} are retried with
// bounded exponential backoff so individual tests do not need to remember
// RunCmdWithRetry for normal transient server contention.
func RunCmd(ctx context.Context, req Request) (*Result, error) {
return RunCmdWithRetry(ctx, req, RetryOptions{
ShouldRetry: ResultHasRetryableError,
})
}
func runCmdOnce(ctx context.Context, req Request) (*Result, error) {
binaryPath, err := ResolveBinaryPath(req)
if err != nil {
return nil, err
}
args, err := BuildArgs(req)
if err != nil {
return nil, err
}
cmd := exec.CommandContext(ctx, binaryPath, args...)
if req.WorkDir != "" {
cmd.Dir = req.WorkDir
}
cmd.Env = buildCommandEnv(req)
var stdout bytes.Buffer
var stderr bytes.Buffer
if req.Stdin != nil {
cmd.Stdin = bytes.NewReader(req.Stdin)
}
cmd.Stdout = &stdout
cmd.Stderr = &stderr
runErr := cmd.Run()
result := &Result{
BinaryPath: binaryPath,
Args: args,
ExitCode: exitCode(runErr),
Stdout: stdout.String(),
Stderr: stderr.String(),
RunErr: runErr,
}
return result, nil
}
func buildCommandEnv(req Request) []string {
env := append([]string{}, os.Environ()...)
overrides := map[string]string{}
for k, v := range req.Env {
overrides[k] = v
}
// Keep user-token injection scoped to user-only test commands so bot
// commands continue to use config-init credentials in the same process.
if req.DefaultAs == "user" {
if appID := os.Getenv("TEST_BOT1_APP_ID"); appID != "" {
if token := os.Getenv("TEST_USER_ACCESS_TOKEN"); token != "" {
overrides["LARKSUITE_CLI_APP_ID"] = appID
overrides["LARKSUITE_CLI_USER_ACCESS_TOKEN"] = token
}
}
}
for k, v := range overrides {
prefix := k + "="
replaced := false
for i, item := range env {
if strings.HasPrefix(item, prefix) {
env[i] = prefix + v
replaced = true
break
}
}
if !replaced {
env = append(env, prefix+v)
}
}
return env
}
// 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 = defaultRetryAttempts
}
if opts.InitialDelay <= 0 {
opts.InitialDelay = defaultRetryInitialDelay
}
if opts.MaxDelay <= 0 {
opts.MaxDelay = defaultRetryMaxDelay
}
if opts.BackoffMultiple <= 1 {
opts.BackoffMultiple = defaultRetryBackoffMultiple
}
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 := runCmdOnce(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
}
// ResultHasRetryableError reports whether lark-cli returned a structured
// service error with error.retryable=true in either output stream.
func ResultHasRetryableError(result *Result) bool {
if result == nil {
return false
}
return rawHasRetryableError(result.Stdout) || rawHasRetryableError(result.Stderr)
}
func rawHasRetryableError(raw string) bool {
payload := extractJSONPayload(raw)
if payload == "" {
return false
}
return gjson.Get(payload, "error.retryable").Bool()
}
// WaitForCondition polls condition until it returns true, an error, the context
// is canceled, or the configured timeout expires.
func WaitForCondition(ctx context.Context, opts WaitOptions, condition func() (bool, error)) error {
if condition == nil {
return errors.New("wait condition is nil")
}
if opts.Timeout <= 0 {
opts.Timeout = CleanupTimeout
}
if opts.Interval <= 0 {
opts.Interval = time.Second
}
deadline := time.NewTimer(opts.Timeout)
defer deadline.Stop()
ticker := time.NewTicker(opts.Interval)
defer ticker.Stop()
for {
done, err := condition()
if err != nil {
return err
}
if done {
return nil
}
select {
case <-ctx.Done():
return ctx.Err()
case <-deadline.C:
if opts.TimeoutError != nil {
return opts.TimeoutError()
}
return fmt.Errorf("condition still false after %s", opts.Timeout)
case <-ticker.C:
}
}
}
// 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())
}
// CleanupContext returns a bounded context for teardown operations so cleanup
// cannot outlive the test indefinitely when the remote API stalls.
func CleanupContext() (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), CleanupTimeout)
}
// ReportCleanupFailure emits a uniform cleanup error with command output.
func ReportCleanupFailure(parentT *testing.T, prefix string, result *Result, err error) {
parentT.Helper()
if err != nil {
if IsCleanupWarning(err) {
parentT.Logf("%s: %v", prefix, err)
return
}
parentT.Errorf("%s: %v", prefix, err)
return
}
if result == nil {
parentT.Errorf("%s: nil result", prefix)
return
}
if isCleanupSuppressedResult(result) {
return
}
if result.ExitCode != 0 {
parentT.Errorf("%s failed: exit=%d stdout=%s stderr=%s", prefix, result.ExitCode, result.Stdout, result.Stderr)
}
}
func isCleanupSuppressedResult(result *Result) bool {
if result == nil {
return false
}
payload := extractJSONPayload(result.Stdout)
if payload == "" {
payload = extractJSONPayload(result.Stderr)
}
if payload == "" {
return false
}
errType := gjson.Get(payload, "error.type").String()
errMessage := strings.ToLower(gjson.Get(payload, "error.message").String())
errDetailType := gjson.Get(payload, "error.detail.type").String()
errCode := gjson.Get(payload, "error.code").Int()
if errDetailType == "not_found" || strings.Contains(errMessage, "not found") || strings.Contains(errMessage, "http 404") {
return true
}
return errType == "api_error" && (errCode == 800004135 || strings.Contains(errMessage, " limited"))
}
func extractJSONPayload(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
if gjson.Valid(raw) {
return raw
}
start := strings.LastIndex(raw, "\n{")
if start >= 0 {
start++
} else {
start = strings.Index(raw, "{")
}
if start < 0 {
return ""
}
payload := raw[start:]
if !gjson.Valid(payload) {
return ""
}
return payload
}
// ResolveBinaryPath finds the CLI binary path using request, env, then PATH.
func ResolveBinaryPath(req Request) (string, error) {
if req.BinaryPath != "" {
return normalizeBinaryPath(req.BinaryPath)
}
if envPath := strings.TrimSpace(os.Getenv(EnvBinaryPath)); envPath != "" {
return normalizeBinaryPath(envPath)
}
if rootDir, err := findProjectRootDir(); err == nil {
projectBinary := filepath.Join(rootDir, cliBinaryName)
if _, statErr := os.Stat(projectBinary); statErr == nil {
return normalizeBinaryPath(projectBinary)
}
}
path, err := exec.LookPath(cliBinaryName)
if err == nil {
return normalizeBinaryPath(path)
}
return "", fmt.Errorf("resolve lark-cli binary: not found via request.BinaryPath, %s, project-root ./%s, PATH:%s", EnvBinaryPath, cliBinaryName, cliBinaryName)
}
func normalizeBinaryPath(path string) (string, error) {
if strings.TrimSpace(path) == "" {
return "", errors.New("binary path is empty")
}
absPath, err := filepath.Abs(path)
if err != nil {
return "", fmt.Errorf("resolve absolute binary path %q: %w", path, err)
}
info, err := os.Stat(absPath)
if err != nil {
return "", fmt.Errorf("stat binary path %q: %w", absPath, err)
}
if info.IsDir() {
return "", fmt.Errorf("binary path %q is a directory", absPath)
}
if info.Mode()&0o111 == 0 {
return "", fmt.Errorf("binary path %q is not executable", absPath)
}
return absPath, nil
}
// BuildArgs converts a request into CLI arguments.
func BuildArgs(req Request) ([]string, error) {
args := append([]string{}, req.Args...)
if len(args) == 0 {
return nil, errors.New("request args are required")
}
if req.DefaultAs != "" {
args = append(args, "--as", req.DefaultAs)
}
if req.Format != "" {
args = append(args, "--format", req.Format)
}
if req.Yes {
args = append(args, "--yes")
}
if req.Params != nil {
paramsBytes, err := json.Marshal(req.Params)
if err != nil {
return nil, fmt.Errorf("marshal lark-cli params: %w", err)
}
args = append(args, "--params", string(paramsBytes))
}
if req.Data != nil {
dataBytes, err := json.Marshal(req.Data)
if err != nil {
return nil, fmt.Errorf("marshal lark-cli data: %w", err)
}
args = append(args, "--data", string(dataBytes))
}
return args, nil
}
func findProjectRootDir() (string, error) {
currentDir, err := os.Getwd()
if err != nil {
return "", fmt.Errorf("get working directory: %w", err)
}
for {
markerPath := filepath.Join(currentDir, projectRootMarkerDir)
fileInfo, statErr := os.Stat(markerPath)
if statErr == nil && fileInfo.IsDir() {
return currentDir, nil
}
parentDir := filepath.Dir(currentDir)
if parentDir == "" || parentDir == currentDir {
break
}
currentDir = parentDir
}
return "", fmt.Errorf("project root not found from cwd using marker %q", projectRootMarkerDir)
}
func exitCode(err error) int {
if err == nil {
return 0
}
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
return exitErr.ExitCode()
}
return -1
}
// StdoutJSON decodes stdout as JSON.
func (r *Result) StdoutJSON(t *testing.T) any {
t.Helper()
return mustParseJSON(t, "stdout", r.Stdout)
}
// StderrJSON decodes stderr as JSON.
func (r *Result) StderrJSON(t *testing.T) any {
t.Helper()
return mustParseJSON(t, "stderr", r.Stderr)
}
func mustParseJSON(t *testing.T, stream string, raw string) any {
t.Helper()
if strings.TrimSpace(raw) == "" {
t.Fatalf("%s is empty", stream)
}
var value any
if err := json.Unmarshal([]byte(raw), &value); err != nil {
t.Fatalf("parse %s as JSON: %v\n%s:\n%s", stream, err, stream, raw)
}
return value
}
// AssertExitCode asserts the exit code.
func (r *Result) AssertExitCode(t *testing.T, code int) {
t.Helper()
assert.Equal(t, code, r.ExitCode, "stdout:\n%s\nstderr:\n%s", r.Stdout, r.Stderr)
}
// AssertStdoutStatus asserts stdout JSON status using either {"ok": ...} or {"code": ...}.
// This intentionally keeps one shared assertion entrypoint for CLI E2E call sites,
// so tests can stay uniform across shortcut-style {"ok": ...} responses and
// service-style {"code": ...} responses without branching on response shape.
func (r *Result) AssertStdoutStatus(t *testing.T, expected any) {
t.Helper()
if okResult := gjson.Get(r.Stdout, "ok"); okResult.Exists() {
assert.Equal(t, expected, okResult.Bool(), "stdout:\n%s", r.Stdout)
return
}
if codeResult := gjson.Get(r.Stdout, "code"); codeResult.Exists() {
assert.Equal(t, expected, int(codeResult.Int()), "stdout:\n%s", r.Stdout)
return
}
assert.Fail(t, "stdout status key not found; expected ok or code", "stdout:\n%s", r.Stdout)
}
+422
View File
@@ -0,0 +1,422 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package clie2e
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestResolveBinaryPath(t *testing.T) {
t.Run("request binary path wins", func(t *testing.T) {
tmpDir := t.TempDir()
reqBin := mustWriteExecutable(t, filepath.Join(tmpDir, "req-bin"))
envBin := mustWriteExecutable(t, filepath.Join(tmpDir, "env-bin"))
t.Setenv(EnvBinaryPath, envBin)
got, err := ResolveBinaryPath(Request{BinaryPath: reqBin})
require.NoError(t, err)
assert.Equal(t, reqBin, got)
})
t.Run("uses env binary path", func(t *testing.T) {
tmpDir := t.TempDir()
envBin := mustWriteExecutable(t, filepath.Join(tmpDir, "env-bin"))
t.Setenv(EnvBinaryPath, envBin)
got, err := ResolveBinaryPath(Request{})
require.NoError(t, err)
assert.Equal(t, envBin, got)
})
t.Run("uses project root binary", func(t *testing.T) {
tmpDir := t.TempDir()
testsDir := filepath.Join(tmpDir, projectRootMarkerDir)
require.NoError(t, os.MkdirAll(testsDir, 0o755))
projectBin := mustWriteExecutable(t, filepath.Join(tmpDir, cliBinaryName))
oldWD, err := os.Getwd()
require.NoError(t, err)
require.NoError(t, os.Chdir(testsDir))
defer func() {
require.NoError(t, os.Chdir(oldWD))
}()
t.Setenv(EnvBinaryPath, "")
got, err := ResolveBinaryPath(Request{})
require.NoError(t, err)
assertSamePath(t, projectBin, got)
})
t.Run("rejects non-executable path", func(t *testing.T) {
tmpDir := t.TempDir()
file := filepath.Join(tmpDir, "not-exec")
require.NoError(t, os.WriteFile(file, []byte("plain"), 0o644))
_, err := ResolveBinaryPath(Request{BinaryPath: file})
require.Error(t, err)
assert.Contains(t, err.Error(), "not executable")
})
}
func TestBuildArgs(t *testing.T) {
t.Run("encodes json payloads", func(t *testing.T) {
args, err := BuildArgs(Request{
Args: []string{"task", "+create"},
Params: map[string]any{"task_guid": "abc"},
Data: map[string]any{"summary": "hello"},
})
require.NoError(t, err)
assert.Equal(t, []string{
"task", "+create",
"--params", `{"task_guid":"abc"}`,
"--data", `{"summary":"hello"}`,
}, args)
})
t.Run("adds default-as and format when set", func(t *testing.T) {
args, err := BuildArgs(Request{
Args: []string{"task", "+update"},
DefaultAs: "user",
Format: "pretty",
})
require.NoError(t, err)
assert.Equal(t, []string{"task", "+update", "--as", "user", "--format", "pretty"}, args)
})
t.Run("requires args", func(t *testing.T) {
_, err := BuildArgs(Request{})
require.Error(t, err)
assert.Contains(t, err.Error(), "args are required")
})
}
func TestSkipWithoutUserToken(t *testing.T) {
t.Run("returns immediately when env user access token exists", func(t *testing.T) {
t.Setenv("LARKSUITE_CLI_USER_ACCESS_TOKEN", "uat-from-env")
ran := false
ok := t.Run("inner", func(t *testing.T) {
SkipWithoutUserToken(t)
ran = true
})
require.True(t, ok)
assert.True(t, ran)
})
t.Run("accepts verified local auth status", func(t *testing.T) {
fake := newFakeCLI(t)
t.Setenv("LARKSUITE_CLI_USER_ACCESS_TOKEN", "")
t.Setenv(EnvBinaryPath, fake.BinaryPath)
t.Setenv("FAKE_AUTH_STATUS_STDOUT", `{"identity":"user","verified":true}`)
t.Setenv("FAKE_AUTH_STATUS_EXIT_CODE", "0")
ran := false
ok := t.Run("inner", func(t *testing.T) {
SkipWithoutUserToken(t)
ran = true
})
require.True(t, ok)
assert.True(t, ran)
})
t.Run("skips when local auth is not user", func(t *testing.T) {
fake := newFakeCLI(t)
t.Setenv("LARKSUITE_CLI_USER_ACCESS_TOKEN", "")
t.Setenv(EnvBinaryPath, fake.BinaryPath)
t.Setenv("FAKE_AUTH_STATUS_STDOUT", `{"identity":"bot","verified":false}`)
t.Setenv("FAKE_AUTH_STATUS_EXIT_CODE", "0")
ran := false
ok := t.Run("inner", func(t *testing.T) {
SkipWithoutUserToken(t)
ran = true
})
require.True(t, ok)
assert.False(t, ran)
})
}
func TestRunCmd(t *testing.T) {
t.Run("returns stdout json on success", func(t *testing.T) {
fake := newFakeCLI(t)
result, err := RunCmd(context.Background(), Request{
BinaryPath: fake.BinaryPath,
Args: []string{"--stdout-json", `{"ok":true}`},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
outMap, ok := result.StdoutJSON(t).(map[string]any)
require.True(t, ok)
assert.Equal(t, true, outMap["ok"])
})
t.Run("captures stderr and exit code on failure", func(t *testing.T) {
fake := newFakeCLI(t)
result, err := RunCmd(context.Background(), Request{
BinaryPath: fake.BinaryPath,
Args: []string{"--stderr-json", `{"ok":false}`, "--exit", "3"},
})
require.NoError(t, err)
result.AssertExitCode(t, 3)
assert.Error(t, result.RunErr)
errMap, ok := result.StderrJSON(t).(map[string]any)
require.True(t, ok)
assert.Equal(t, false, errMap["ok"])
})
t.Run("passes explicit default-as as flag", func(t *testing.T) {
fake := newFakeCLI(t)
result, err := RunCmd(context.Background(), Request{
BinaryPath: fake.BinaryPath,
Args: []string{"emit-arg", "--as"},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "user", strings.TrimSpace(result.Stdout))
})
t.Run("asserts stdout code payloads", func(t *testing.T) {
fake := newFakeCLI(t)
result, err := RunCmd(context.Background(), Request{
BinaryPath: fake.BinaryPath,
Args: []string{"--stdout-json", `{"code":0,"data":{"id":"x"}}`},
Format: "json",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
})
t.Run("passes stdin to process", func(t *testing.T) {
fake := newFakeCLI(t)
result, err := RunCmd(context.Background(), Request{
BinaryPath: fake.BinaryPath,
Args: []string{"emit-stdin"},
Stdin: []byte("hello from stdin\n"),
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "hello from stdin\n", result.Stdout)
})
t.Run("injects user token env only for user commands", func(t *testing.T) {
t.Setenv("TEST_BOT1_APP_ID", "cli_app_test")
t.Setenv("TEST_USER_ACCESS_TOKEN", "uat_test")
env := buildCommandEnv(Request{DefaultAs: "user"})
assert.Contains(t, env, "LARKSUITE_CLI_APP_ID=cli_app_test")
assert.Contains(t, env, "LARKSUITE_CLI_USER_ACCESS_TOKEN=uat_test")
env = buildCommandEnv(Request{DefaultAs: "bot"})
assert.NotContains(t, env, "LARKSUITE_CLI_APP_ID=cli_app_test")
assert.NotContains(t, env, "LARKSUITE_CLI_USER_ACCESS_TOKEN=uat_test")
})
t.Run("retries structured retryable service errors by default", func(t *testing.T) {
fake := newFakeCLI(t)
statePath := filepath.Join(t.TempDir(), "retry-count")
result, err := RunCmd(context.Background(), Request{
BinaryPath: fake.BinaryPath,
Args: []string{"fail-once-retryable", statePath},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
countBytes, err := os.ReadFile(statePath)
require.NoError(t, err)
assert.Equal(t, "2\n", string(countBytes))
})
t.Run("does not retry non-retryable service errors by default", func(t *testing.T) {
fake := newFakeCLI(t)
statePath := filepath.Join(t.TempDir(), "retry-count")
result, err := RunCmd(context.Background(), Request{
BinaryPath: fake.BinaryPath,
Args: []string{"always-non-retryable", statePath},
})
require.NoError(t, err)
result.AssertExitCode(t, 1)
countBytes, err := os.ReadFile(statePath)
require.NoError(t, err)
assert.Equal(t, "1\n", string(countBytes))
})
}
func TestRunCmdWithRetry(t *testing.T) {
t.Run("does not include RunCmd default retry as a nested retry", func(t *testing.T) {
fake := newFakeCLI(t)
statePath := filepath.Join(t.TempDir(), "retry-count")
result, err := RunCmdWithRetry(context.Background(), Request{
BinaryPath: fake.BinaryPath,
Args: []string{"fail-once-retryable", statePath},
}, RetryOptions{
Attempts: 1,
InitialDelay: time.Millisecond,
MaxDelay: time.Millisecond,
ShouldRetry: ResultHasRetryableError,
})
require.NoError(t, err)
result.AssertExitCode(t, 1)
countBytes, err := os.ReadFile(statePath)
require.NoError(t, err)
assert.Equal(t, "1\n", string(countBytes))
})
}
func TestWaitForCondition(t *testing.T) {
t.Run("polls until condition succeeds", func(t *testing.T) {
attempts := 0
err := WaitForCondition(context.Background(), WaitOptions{
Timeout: 50 * time.Millisecond,
Interval: time.Millisecond,
}, func() (bool, error) {
attempts++
return attempts == 2, nil
})
require.NoError(t, err)
assert.Equal(t, 2, attempts)
})
t.Run("returns custom timeout error", func(t *testing.T) {
wantErr := errors.New("still visible")
err := WaitForCondition(context.Background(), WaitOptions{
Timeout: time.Millisecond,
Interval: time.Millisecond,
TimeoutError: func() error { return wantErr },
}, func() (bool, error) {
return false, nil
})
assert.ErrorIs(t, err, wantErr)
})
}
type fakeCLI struct {
BinaryPath string
}
func newFakeCLI(t *testing.T) fakeCLI {
t.Helper()
tmpDir := t.TempDir()
script := `#!/bin/sh
if [ "$1" = "auth" ] && [ "$2" = "status" ] && [ "$3" = "--verify" ]; then
if [ -n "$FAKE_AUTH_STATUS_STDOUT" ]; then
echo "$FAKE_AUTH_STATUS_STDOUT"
fi
exit "${FAKE_AUTH_STATUS_EXIT_CODE:-0}"
fi
if [ "$1" = "emit-arg" ]; then
key="$2"
shift 2
while [ "$#" -gt 1 ]; do
if [ "$1" = "$key" ]; then
echo "$2"
exit 0
fi
shift
done
exit 1
fi
if [ "$1" = "emit-stdin" ]; then
cat
exit 0
fi
if [ "$1" = "fail-once-retryable" ]; then
state="$2"
count=0
if [ -f "$state" ]; then
count="$(cat "$state")"
fi
count=$((count + 1))
echo "$count" > "$state"
if [ "$count" -eq 1 ]; then
echo "Deleting folder fake..." >&2
echo '{"ok":false,"error":{"type":"api","code":1061045,"message":"resource contention occurred, please retry.","retryable":true}}' >&2
exit 1
fi
echo '{"ok":true}'
exit 0
fi
if [ "$1" = "always-non-retryable" ]; then
state="$2"
count=0
if [ -f "$state" ]; then
count="$(cat "$state")"
fi
count=$((count + 1))
echo "$count" > "$state"
echo '{"ok":false,"error":{"type":"api","code":123,"message":"validation failed","retryable":false}}' >&2
exit 1
fi
exit_code=0
while [ "$#" -gt 0 ]; do
case "$1" in
--stdout-json)
echo "$2"
shift 2
;;
--stderr-json)
echo "$2" >&2
shift 2
;;
--exit)
exit_code="$2"
shift 2
;;
*)
shift
;;
esac
done
exit "$exit_code"
`
binaryPath := filepath.Join(tmpDir, "fake-"+cliBinaryName)
require.NoError(t, os.WriteFile(binaryPath, []byte(script), 0o755))
return fakeCLI{
BinaryPath: binaryPath,
}
}
func assertSamePath(t *testing.T, want string, got string) {
t.Helper()
gotReal, err := filepath.EvalSymlinks(got)
require.NoError(t, err)
wantReal, err := filepath.EvalSymlinks(want)
require.NoError(t, err)
assert.Equal(t, wantReal, gotReal)
}
func mustWriteExecutable(t *testing.T, path string) string {
t.Helper()
require.NoError(t, os.WriteFile(path, []byte("#!/bin/sh\nexit 0\n"), 0o755))
absPath, err := filepath.Abs(path)
require.NoError(t, err)
return absPath
}
+42
View File
@@ -0,0 +1,42 @@
# Demo Coverage Template
> This file is a demo template only.
> It shows the expected `coverage.md` shape for real domains under `tests/cli_e2e/{domain}`.
> The numbers, command list, and coverage status below are illustrative, not authoritative.
> `tests/cli_e2e/demo/` is reference material and is not part of formal CLI E2E coverage accounting.
> `lark-cli demo --help` does not exist, so this file cannot be recomputed from live domain help output.
## Metrics
- Denominator: 8 leaf commands
- Covered: 3
- Coverage: 37.5%
## Summary
- Purpose: show humans and AI agents how to maintain a per-domain coverage file even when the directory is documentation-only and not backed by a real `lark-cli demo` command tree.
- TestDemo_TaskLifecycle: demonstrates one minimal task lifecycle workflow for documentation purposes.
- TestDemo_TaskLifecycle/create: runs `task +create` with `summary` and `description`, captures the returned `taskGUID`, and registers parent cleanup for later teardown.
- TestDemo_TaskLifecycle/update: runs `task +update --task-id <guid>` and mutates both `summary` and `description` on the created task.
- TestDemo_TaskLifecycle/get: runs `task tasks get` for the same task and asserts the persisted `guid`, updated `summary`, and updated `description`.
- Cleanup note: `task tasks delete` is executed in `parentT.Cleanup`, but this template intentionally keeps cleanup-only execution marked uncovered so workflow assertions remain distinct from teardown mechanics.
- Demo-only gap note: `task +complete`, `task +reopen`, `task +assign`, and `task +get-my-tasks` are intentionally left as uncovered examples for a minimal template.
## Command Table
| Status | Cmd | Type | Testcase | Key parameter shapes | Notes / uncovered reason |
| --- | --- | --- | --- | --- | --- |
| ✓ | task +create | shortcut | task_lifecycle_test.go::TestDemo_TaskLifecycle/create | basic create; summary; description | demo example |
| ✓ | task +update | shortcut | task_lifecycle_test.go::TestDemo_TaskLifecycle/update | --task-id; update summary; update description | demo example |
| ✓ | task tasks get | api | task_lifecycle_test.go::TestDemo_TaskLifecycle/get | task_guid in --params | demo example |
| ✕ | task tasks delete | api | | none | cleanup exists in parentT.Cleanup, but demo coverage intentionally treats cleanup-only execution as uncovered |
| ✕ | task +complete | shortcut | | none | not shown in this minimal lifecycle example |
| ✕ | task +reopen | shortcut | | none | not shown in this minimal lifecycle example |
| ✕ | task +assign | shortcut | | none | example of a user-identity-sensitive command; requires real user fixtures |
| ✕ | task +get-my-tasks | shortcut | | none | example of a current-user-dependent command; often unavailable in bot-only environments |
## Notes
- In a real domain, recompute the denominator from live `lark-cli --help` exploration instead of copying this file.
- Replace demo rows with real command inventory for that domain.
- Keep skipped commands unchecked; reuse the `t.Skip(...)` reason as the uncovered reason.
+90
View File
@@ -0,0 +1,90 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package demo
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 TestDemo_TaskLifecycle(t *testing.T) {
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
suffix := time.Now().UTC().Format("20060102-150405")
createdSummary := "lark-cli-e2e-create-" + suffix
updatedSummary := "lark-cli-e2e-update-" + suffix
createdDescription := "created by tests/cli_e2e/demo"
updatedDescription := "updated by tests/cli_e2e/demo"
var taskGUID string
t.Run("create as bot", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"task", "+create"},
DefaultAs: "bot",
Data: map[string]any{
"summary": createdSummary,
"description": createdDescription,
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
taskGUID = gjson.Get(result.Stdout, "data.guid").String()
require.NotEmpty(t, taskGUID, "stdout:\n%s", result.Stdout)
parentT.Cleanup(func() {
cleanupCtx, cancel := clie2e.CleanupContext()
defer cancel()
deleteResult, deleteErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{
Args: []string{"task", "tasks", "delete"},
DefaultAs: "bot",
Params: map[string]any{"task_guid": taskGUID},
Yes: true,
})
clie2e.ReportCleanupFailure(parentT, "delete task "+taskGUID, deleteResult, deleteErr)
})
})
t.Run("update as bot", func(t *testing.T) {
require.NotEmpty(t, taskGUID, "task GUID should be created before update")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"task", "+update", "--task-id", taskGUID},
DefaultAs: "bot",
Data: map[string]any{
"summary": updatedSummary,
"description": updatedDescription,
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
})
t.Run("get as bot", func(t *testing.T) {
require.NotEmpty(t, taskGUID, "task GUID should be created before get")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"task", "tasks", "get"},
DefaultAs: "bot",
Params: map[string]any{"task_guid": taskGUID},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
assert.Equal(t, taskGUID, gjson.Get(result.Stdout, "data.task.guid").String())
assert.Equal(t, updatedSummary, gjson.Get(result.Stdout, "data.task.summary").String())
assert.Equal(t, updatedDescription, gjson.Get(result.Stdout, "data.task.description").String())
})
}
+33
View File
@@ -0,0 +1,33 @@
# Docs CLI E2E Coverage
## Metrics
- Denominator: 11 leaf commands
- Covered: 6
- Coverage: 54.5%
## Summary
- TestDocs_CreateAndFetchWorkflow: proves `docs +create` and `docs +fetch`; key `t.Run(...)` proof points are `create as bot` and `fetch as bot`.
- TestDocs_CreateAndFetchWorkflowAsUser: proves the same shortcut pair with UAT injection via `create as user` and `fetch as user`; creates its own Drive folder fixture first, then reads back the created doc by token.
- TestDocs_UpdateWorkflow: proves `docs +update` via `update-title-and-content as bot`, then re-fetches the same doc in `verify as bot` to assert persisted title/content changes.
- TestDocs_DryRunDefaultsToV2OpenAPI: proves `docs +create`, `docs +fetch`, and `docs +update` dry-run all emit `/open-apis/docs_ai/v1/...` requests without MCP or `--api-version` guidance; its fetch case asserts fetch sends the default `extra_param`, and its update case asserts `--reference-map` is sent as request body `reference_map`.
- TestDocs_CreateTitleDryRunPrependsContent: proves `docs +create --title` dry-run prepends an escaped `<title>...</title>` tag to request body `content`.
- TestDocs_DryRunDefaultsToV2OpenAPI also proves `docs +history-list`, `docs +history-revert`, and `docs +history-revert-status` dry-run endpoint and query/body shapes.
- TestDocs_HistoryWorkflow proves the guarded live history flow (`LARK_DOC_HISTORY_E2E=1`): create, update, list prior revisions, revert, poll status when needed, and fetch to verify reverted content.
- Setup note: docs workflows create a Drive folder through `drive files create_folder` in `helpers_test.go`; that helper is external to the docs domain and is not counted here.
- Blocked area: media and search shortcuts still need deterministic fixtures and local file orchestration.
## Command Table
| Status | Cmd | Type | Testcase | Key parameter shapes | Notes / uncovered reason |
| --- | --- | --- | --- | --- | --- |
| ✓ | docs +create | shortcut | docs/helpers_test.go::createDocWithRetry; docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflowAsUser/create as user; docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/create; docs_update_dryrun_test.go::TestDocs_CreateTitleDryRunPrependsContent | `--parent-token`; `--doc-format markdown`; `--content`; `--title` | helper asserts returned doc id from `data.document.document_id`; dry-run asserts title is prepended into request body content |
| ✓ | docs +fetch | shortcut | docs_fetch_dryrun_test.go::TestDocsFetchDryRunIgnoresAPIVersionCompatFlag; docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflow/fetch as bot; docs_update_test.go::TestDocs_UpdateWorkflow/verify as bot; docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflowAsUser/fetch as user; docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/fetch | `--doc <docToken>`; `--doc-format markdown`; default `extra_param.enable_user_cite_reference_map=true`; `--api-version v1` compatibility flag still dry-runs the v2 fetch endpoint | |
| ✓ | docs +history-list | shortcut | docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/history list; docs_history_workflow_test.go::TestDocs_HistoryWorkflow | `--doc`; `--page-size`; `--page-token` | live workflow gated by `LARK_DOC_HISTORY_E2E=1` |
| ✓ | docs +history-revert | shortcut | docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/history revert; docs_history_workflow_test.go::TestDocs_HistoryWorkflow | `--doc`; `--history-version-id`; `--wait-timeout-ms` | live workflow gated by `LARK_DOC_HISTORY_E2E=1` |
| ✓ | docs +history-revert-status | shortcut | docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/history revert status; docs_history_workflow_test.go::TestDocs_HistoryWorkflow | `--doc`; `--task-id` | live workflow polls only when revert returns `running` |
| ✕ | docs +media-download | shortcut | | none | no media fixture workflow yet |
| ✕ | docs +media-insert | shortcut | | none | requires deterministic upload fixture and rollback assertions |
| ✕ | docs +media-preview | shortcut | | none | requires deterministic media fixture |
| ✕ | docs +search | shortcut | | none | search results are ambient and not yet stabilized for E2E |
| ✓ | docs +update | shortcut | docs_update_test.go::TestDocs_UpdateWorkflow/update-title-and-content as bot; docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/update | `--doc`; `--command overwrite`; `--doc-format markdown`; `--content`; optional `--reference-map` -> body `reference_map` | |
| ✕ | docs +whiteboard-update | shortcut | | none | requires whiteboard fixture and DSL-specific assertions |
@@ -0,0 +1,89 @@
// 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/larksuite/cli/tests/cli_e2e/drive"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestDocs_CreateAndFetchWorkflow tests the create and fetch lifecycle.
func TestDocs_CreateAndFetchWorkflowAsBot(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
parentT := t
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."
const defaultAs = "bot"
folderToken := drive.CreateDriveFolder(t, parentT, ctx, folderName, defaultAs, "")
var docToken string
t.Run("create", func(t *testing.T) {
docToken = createDocWithRetry(t, parentT, ctx, folderToken, docTitle, docContent, defaultAs)
})
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,
"--doc-format", "markdown",
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
content := gjson.Get(result.Stdout, "data.document.content").String()
assert.Contains(t, content, docTitle)
assert.Contains(t, content, "This document was created by lark-cli e2e test.")
})
}
func TestDocs_CreateAndFetchWorkflowAsUser(t *testing.T) {
clie2e.SkipWithoutUserToken(t)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
parentT := t
suffix := clie2e.GenerateSuffix()
folderName := "lark-cli-e2e-user-docs-folder-" + suffix
docTitle := "lark-cli-e2e-user-docs-" + suffix
docContent := "# User Test Document\n\nCreated with user access token."
var docToken string
const defaultAs = "user"
folderToken := drive.CreateDriveFolder(t, parentT, ctx, folderName, defaultAs, "")
t.Run("create as user", func(t *testing.T) {
docToken = createDocWithRetry(t, parentT, ctx, folderToken, docTitle, docContent, defaultAs)
})
t.Run("fetch as user", 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, "--doc-format", "markdown"},
DefaultAs: defaultAs,
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
content := gjson.Get(result.Stdout, "data.document.content").String()
assert.Contains(t, content, docTitle)
assert.Contains(t, content, "Created with user access token.")
})
}
@@ -0,0 +1,104 @@
// 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/require"
"github.com/tidwall/gjson"
)
func TestDocsFetchDryRunIgnoresAPIVersionCompatFlag(t *testing.T) {
setDocsDryRunEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"docs", "+fetch",
"--doc", "doxcnDryRunCompat",
"--api-version", "v1",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "api.0.method").String(); got != "POST" {
t.Fatalf("method=%q, want POST\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/docs_ai/v1/documents/doxcnDryRunCompat/fetch" {
t.Fatalf("url=%q, want docs fetch endpoint\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.body.format").String(); got != "xml" {
t.Fatalf("format=%q, want xml\nstdout:\n%s", got, out)
}
}
func TestDocsFetchDryRunSelectionAnchorFragmentBecomesRangeStart(t *testing.T) {
setDocsDryRunEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"docs", "+fetch",
"--doc", "https://example.larksuite.com/wiki/wikcnDryRun#share-CUE3d6Ykno2fkexEvt8cGF8Wnse",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/docs_ai/v1/documents/wikcnDryRun/fetch" {
t.Fatalf("url=%q, want docs fetch endpoint\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.body.read_option.read_mode").String(); got != "range" {
t.Fatalf("read_mode=%q, want range\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.body.read_option.start_block_id").String(); got != "share-CUE3d6Ykno2fkexEvt8cGF8Wnse" {
t.Fatalf("start_block_id=%q, want selection anchor\nstdout:\n%s", got, out)
}
}
func TestDocsFetchDryRunUnsupportedSelectionAnchorFragmentStaysFull(t *testing.T) {
setDocsDryRunEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"docs", "+fetch",
"--doc", "https://example.larksuite.com/wiki/wikcnDryRun#part-CUE3d6Ykno2fkexEvt8cGF8Wnse",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "api.0.body.read_option").Raw; got != "" {
t.Fatalf("read_option=%s, want omitted for unsupported selection anchor\nstdout:\n%s", got, out)
}
}
func setDocsDryRunEnv(t *testing.T) {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "docs_dryrun_test")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "docs_dryrun_secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
}
@@ -0,0 +1,137 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package docs
import (
"context"
"os"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/larksuite/cli/tests/cli_e2e/drive"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestDocs_HistoryWorkflow(t *testing.T) {
if os.Getenv("LARK_DOC_HISTORY_E2E") != "1" {
t.Skip("set LARK_DOC_HISTORY_E2E=1 to run docs history live workflow")
}
clie2e.SkipWithoutUserToken(t)
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
t.Cleanup(cancel)
suffix := clie2e.GenerateSuffix()
folderName := "lark-cli-e2e-docs-history-folder-" + suffix
docTitle := "lark-cli-e2e-docs-history-" + suffix
originalMarker := "original history marker " + suffix
updatedMarker := "updated history marker " + suffix
const defaultAs = "user"
folderToken := drive.CreateDriveFolder(t, parentT, ctx, folderName, defaultAs, "")
docToken := createDocWithRetry(t, parentT, ctx, folderToken, docTitle, originalMarker, defaultAs)
updateResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"docs", "+update",
"--doc", docToken,
"--command", "overwrite",
"--doc-format", "markdown",
"--content", "# " + docTitle + "\n\n" + updatedMarker,
},
DefaultAs: defaultAs,
})
require.NoError(t, err)
updateResult.AssertExitCode(t, 0)
updateResult.AssertStdoutStatus(t, true)
fetchUpdated, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"docs", "+fetch", "--doc", docToken, "--doc-format", "markdown"},
DefaultAs: defaultAs,
})
require.NoError(t, err)
fetchUpdated.AssertExitCode(t, 0)
fetchUpdated.AssertStdoutStatus(t, true)
updatedContent := gjson.Get(fetchUpdated.Stdout, "data.document.content").String()
assert.Contains(t, updatedContent, updatedMarker)
currentRevision := gjson.Get(fetchUpdated.Stdout, "data.document.revision_id").Int()
require.Greater(t, currentRevision, int64(0), "stdout:\n%s", fetchUpdated.Stdout)
var revertHistoryVersionID string
require.Eventually(t, func() bool {
listResult, listErr := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"docs", "+history-list",
"--doc", docToken,
"--page-size", "20",
},
DefaultAs: defaultAs,
})
require.NoError(t, listErr)
listResult.AssertExitCode(t, 0)
listResult.AssertStdoutStatus(t, true)
for _, entry := range gjson.Get(listResult.Stdout, "data.entries").Array() {
revisionID := entry.Get("revision_id").Int()
historyVersionID := entry.Get("history_version_id").String()
if revisionID > 0 && revisionID < currentRevision && historyVersionID != "" {
revertHistoryVersionID = historyVersionID
return true
}
}
return false
}, 45*time.Second, 3*time.Second, "history list did not expose a prior revision")
require.NotEmpty(t, revertHistoryVersionID)
revertResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"docs", "+history-revert",
"--doc", docToken,
"--history-version-id", revertHistoryVersionID,
"--wait-timeout-ms", "30000",
},
DefaultAs: defaultAs,
})
require.NoError(t, err)
revertResult.AssertExitCode(t, 0)
revertResult.AssertStdoutStatus(t, true)
status := gjson.Get(revertResult.Stdout, "data.status").String()
taskID := gjson.Get(revertResult.Stdout, "data.task_id").String()
statusStdout := revertResult.Stdout
if status == "running" {
require.NotEmpty(t, taskID, "stdout:\n%s", revertResult.Stdout)
require.Eventually(t, func() bool {
statusResult, statusErr := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"docs", "+history-revert-status",
"--doc", docToken,
"--task-id", taskID,
},
DefaultAs: defaultAs,
})
require.NoError(t, statusErr)
statusResult.AssertExitCode(t, 0)
statusResult.AssertStdoutStatus(t, true)
statusStdout = statusResult.Stdout
status = gjson.Get(statusResult.Stdout, "data.status").String()
return status != "" && status != "running"
}, 60*time.Second, 5*time.Second, "history revert task did not finish")
}
require.Equal(t, "done", status, "status stdout:\n%s", statusStdout)
fetchReverted, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"docs", "+fetch", "--doc", docToken, "--doc-format", "markdown"},
DefaultAs: defaultAs,
})
require.NoError(t, err)
fetchReverted.AssertExitCode(t, 0)
fetchReverted.AssertStdoutStatus(t, true)
revertedContent := gjson.Get(fetchReverted.Stdout, "data.document.content").String()
assert.Contains(t, revertedContent, originalMarker)
assert.NotContains(t, revertedContent, updatedMarker)
}
@@ -0,0 +1,228 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package docs
import (
"context"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestDocs_DryRunDefaultsToV2OpenAPI(t *testing.T) {
// Fake creds are enough — dry-run short-circuits before any real API call.
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
tests := []struct {
name string
args []string
wantContains []string
wantURL string
wantParams map[string]any
wantBody map[string]any
wantExtraParam string
wantRefLabel string
}{
{
name: "create",
args: []string{
"docs", "+create",
"--content", "<title>Dry Run</title><p>hello</p>",
"--dry-run",
},
wantContains: []string{"/open-apis/docs_ai/v1/documents"},
},
{
name: "create api-version v1 compatibility",
args: []string{
"docs", "+create",
"--api-version", "v1",
"--content", "<title>Dry Run</title><p>hello</p>",
"--dry-run",
},
wantContains: []string{"/open-apis/docs_ai/v1/documents"},
},
{
name: "fetch",
args: []string{
"docs", "+fetch",
"--doc", "doxcnDryRunE2E",
"--dry-run",
},
wantContains: []string{"/open-apis/docs_ai/v1/documents/doxcnDryRunE2E/fetch"},
wantExtraParam: `{"enable_user_cite_reference_map":true,"return_html5_block_data":true}`,
},
{
name: "update",
args: []string{
"docs", "+update",
"--doc", "doxcnDryRunE2E",
"--command", "append",
"--content", "<p>hello</p>",
"--dry-run",
},
wantContains: []string{"/open-apis/docs_ai/v1/documents/doxcnDryRunE2E"},
},
{
name: "update reference-map",
args: []string{
"docs", "+update",
"--doc", "doxcnDryRunE2E",
"--command", "append",
"--content", `<p><widget data-ref="r1"></widget></p>`,
"--reference-map", `{"widget":{"r1":{"label":"widget-ref-value"}}}`,
"--dry-run",
},
wantContains: []string{"/open-apis/docs_ai/v1/documents/doxcnDryRunE2E"},
wantRefLabel: "widget-ref-value",
},
{
name: "block_delete batch",
args: []string{
"docs", "+update",
"--doc", "doxcnDryRunE2E",
"--command", "block_delete",
"--block-id", "blkA,blkB,blkC",
"--dry-run",
},
wantContains: []string{"/open-apis/docs_ai/v1/documents/doxcnDryRunE2E"},
},
{
name: "history list",
args: []string{
"docs", "+history-list",
"--doc", "doxcnDryRunE2E",
"--page-size", "5",
"--page-token", "page_token_1",
"--dry-run",
},
wantURL: "/open-apis/docs_ai/v1/documents/doxcnDryRunE2E/histories",
wantParams: map[string]any{
"page_size": 5,
"page_token": "page_token_1",
},
},
{
name: "history revert",
args: []string{
"docs", "+history-revert",
"--doc", "doxcnDryRunE2E",
"--history-version-id", "42",
"--wait-timeout-ms", "0",
"--dry-run",
},
wantURL: "/open-apis/docs_ai/v1/documents/doxcnDryRunE2E/history/revert",
wantBody: map[string]any{
"history_version_id": "42",
"wait_timeout_ms": 0,
},
},
{
name: "history revert status",
args: []string{
"docs", "+history-revert-status",
"--doc", "doxcnDryRunE2E",
"--task-id", "task_1",
"--dry-run",
},
wantURL: "/open-apis/docs_ai/v1/documents/doxcnDryRunE2E/history/revert_status",
wantParams: map[string]any{
"task_id": "task_1",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: tt.args,
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
combined := result.Stdout + "\n" + result.Stderr
for _, want := range append(tt.wantContains, "docs_ai/v1") {
if !strings.Contains(combined, want) {
t.Fatalf("dry-run output missing %q\nstdout:\n%s\nstderr:\n%s", want, result.Stdout, result.Stderr)
}
}
if strings.Contains(combined, "/mcp") || strings.Contains(combined, "MCP tool") {
t.Fatalf("dry-run output should not use MCP\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
}
if strings.Contains(combined, "--api-version") {
t.Fatalf("dry-run output should not ask for --api-version\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
}
if tt.wantURL != "" {
require.Equal(t, tt.wantURL, gjson.Get(result.Stdout, "api.0.url").String(), "stdout:\n%s", result.Stdout)
}
for key, want := range tt.wantParams {
assertDryRunField(t, result.Stdout, "api.0.params."+key, want)
}
for key, want := range tt.wantBody {
assertDryRunField(t, result.Stdout, "api.0.body."+key, want)
}
if tt.wantExtraParam != "" {
extraParam := gjson.Get(result.Stdout, "api.0.body.extra_param").String()
require.JSONEq(t, tt.wantExtraParam, extraParam, "stdout:\n%s", result.Stdout)
}
if tt.wantRefLabel != "" {
got := gjson.Get(result.Stdout, "api.0.body.reference_map.widget.r1.label").String()
require.Equal(t, tt.wantRefLabel, got, "stdout:\n%s", result.Stdout)
}
})
}
}
func assertDryRunField(t *testing.T, stdout, path string, want any) {
t.Helper()
got := gjson.Get(stdout, path)
require.True(t, got.Exists(), "%s missing in stdout:\n%s", path, stdout)
switch want := want.(type) {
case int:
require.Equal(t, int64(want), got.Int(), "%s in stdout:\n%s", path, stdout)
case string:
require.Equal(t, want, got.String(), "%s in stdout:\n%s", path, stdout)
default:
t.Fatalf("unsupported dry-run assertion type %T for %s", want, path)
}
}
func TestDocs_CreateTitleDryRunPrependsContent(t *testing.T) {
// Fake creds are enough — dry-run short-circuits before any real API call.
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"docs", "+create",
"--title", "Dry Run & Title",
"--doc-format", "markdown",
"--content", "## Body",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
require.Equal(t, "/open-apis/docs_ai/v1/documents", gjson.Get(out, "api.0.url").String(), "stdout:\n%s", out)
require.Equal(t, "markdown", gjson.Get(out, "api.0.body.format").String(), "stdout:\n%s", out)
require.Equal(t, "<title>Dry Run &amp; Title</title>\n## Body", gjson.Get(out, "api.0.body.content").String(), "stdout:\n%s", out)
}
+75
View File
@@ -0,0 +1,75 @@
// 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/larksuite/cli/tests/cli_e2e/drive"
"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) {
parentT := 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."
const defaultAs = "bot"
folderToken := drive.CreateDriveFolder(t, parentT, ctx, folderName, defaultAs, "")
var docToken string
t.Run("create as bot", func(t *testing.T) {
docToken = createDocWithRetry(t, parentT, ctx, folderToken, originalTitle, originalContent, defaultAs)
})
t.Run("update-title-and-content as bot", 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,
"--command", "overwrite",
"--doc-format", "markdown",
"--content", "# " + updatedTitle + "\n\n" + updatedContent,
},
DefaultAs: defaultAs,
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
})
t.Run("verify as bot", 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,
"--doc-format", "markdown",
},
DefaultAs: defaultAs,
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
content := gjson.Get(result.Stdout, "data.document.content").String()
assert.Contains(t, content, updatedTitle)
assert.Contains(t, content, "This is the updated content.")
})
}
+46
View File
@@ -0,0 +1,46 @@
// 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/larksuite/cli/tests/cli_e2e/drive"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func createDocWithRetry(t *testing.T, parentT *testing.T, ctx context.Context, folderToken string, title string, markdown string, defaultAs string) string {
t.Helper()
require.NotEmpty(t, folderToken, "folder token is required")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"docs", "+create",
"--parent-token", folderToken,
"--doc-format", "markdown",
"--content", "# " + title + "\n\n" + markdown,
},
DefaultAs: defaultAs,
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
docToken := gjson.Get(result.Stdout, "data.document.document_id").String()
require.NotEmpty(t, docToken, "stdout:\n%s", result.Stdout)
parentT.Cleanup(func() {
cleanupCtx, cancel := clie2e.CleanupContext()
defer cancel()
deleteResult, deleteErr := drive.DeleteDriveResourceAndVerify(cleanupCtx, docToken, "docx", defaultAs)
clie2e.ReportCleanupFailure(parentT, "delete doc "+docToken, deleteResult, deleteErr)
})
return docToken
}
+62
View File
@@ -0,0 +1,62 @@
# Drive CLI E2E Coverage
## Metrics
- Denominator: 31 leaf commands
- Covered: 10
- Coverage: 32.3%
## Summary
- TestDrive_FilesCreateFolderWorkflow: proves `drive files create_folder` in `create_folder as bot`; helper asserts the returned folder token and registers best-effort cleanup via `drive files delete`.
- TestDrive_StatusWorkflow: proves `drive +status` against a real Drive folder. Seeds the remote side via `drive +upload` (`unchanged.txt`, `modified.txt`, `remote-only.txt`), seeds local files with the matching/diverging contents, and asserts every output bucket (`unchanged`, `modified`, `new_local`, `new_remote`) holds exactly the expected `rel_path` and `file_token`. Cleans up uploaded files and the parent folder via best-effort cleanup hooks.
- TestDrive_UploadWorkflow: proves `drive +upload` against the real backend in both create and overwrite modes. First uploads a fresh file into a temporary Drive folder, then re-uploads new bytes with `--file-token` against the returned token, asserts the overwrite keeps the token stable, and finally downloads the file to confirm the remote content changed.
- TestDrive_DuplicateRemoteWorkflow: proves the duplicate-remote workflows against the real backend. One subtest uploads two same-name files into the same Drive folder and asserts `drive +status` and default `drive +pull` both fail with `duplicate_remote_path`, while `drive +pull --on-duplicate-remote=rename` succeeds, downloads both files, and writes a hashed renamed sibling locally. The other subtest uploads duplicate remote files, runs `drive +push --on-duplicate-remote=newest --if-exists=overwrite --delete-remote --yes`, and then re-runs `drive +status` to prove the mirror converged to a single unchanged `dup.txt`.
- TestDrive_ApplyPermissionDryRun / TestDrive_ApplyPermissionDryRunRejectsFullAccess: dry-run coverage for `drive +apply-permission`; asserts URL→type inference for docx/sheet/slides, explicit `--type` overriding URL inference when both a recognized URL and `--type` are supplied, bare-token + explicit `--type` path, request method/URL/type-query/perm/remark body shape, optional `remark` omission when unset, and client-side rejection of `--perm full_access`. Runs without hitting the live API.
- TestDriveAddCommentDryRun_File / TestDriveAddCommentDryRun_Base: dry-run coverage for `drive +add-comment` on supported Drive file and Base targets; pins the `metas.batch_query -> files/:token/new_comments` file chain, Base `file_type=bitable`, and Base anchor fields.
- TestDriveAddCommentMarkdownFileWorkflow: opt-in live workflow skeleton for the same path, gated by `LARK_DRIVE_MD_COMMENT_E2E=1`.
- TestDrive_SecureLabelDryRun: dry-run coverage for `drive +secure-label-list` and `drive +secure-label-update`; asserts label-list query params and update URL→type inference, request method/URL/type query, and `label-id` body shape. Runs without hitting live APIs because update can trigger document-level security approval flows.
- TestDriveExportDryRun_FileNameMetadata / TestDriveExportDryRun_MarkdownFetchAPI / TestDriveExportDryRun_BitableBaseOnlySchema: dry-run coverage for `drive +export`; asserts export task request shape, markdown fetch request shape without docs fetch `extra_param`, local `--file-name` / `--output-dir` metadata, and `bitable` `.base` `only_schema` request body without calling live APIs.
- TestDrive_PullDryRun / TestDrive_PullDryRunAcceptsDuplicateRemoteStrategies: dry-run coverage for `drive +pull`; asserts the list-files request shape, Validate-stage safety guards, and acceptance of `--on-duplicate-remote=rename|newest|oldest` by the real CLI binary.
- TestDrive_PushDryRun / TestDrive_PushDryRunAcceptsDuplicateRemoteStrategies: dry-run coverage for `drive +push`; asserts the list-files request shape, Validate-stage safety guards, conditional delete preflight, and acceptance of `--on-duplicate-remote=newest|oldest` by the real CLI binary.
- Cleanup note: `drive files delete` is only exercised in cleanup and is intentionally left uncovered.
- Blocked area: live export, permission, subscription, reply, and file comment API flows still need deterministic remote fixtures and filesystem setup.
- Dry-run note: `drive_upload_dryrun_test.go::TestDriveUploadDryRun_WikiTarget` and `TestDriveUploadDryRun_WithFileToken` cover the wiki-target and overwrite request shapes for `drive +upload`; live upload/status/duplicate workflows also use real `+upload` against the backend.
## Command Table
| Status | Cmd | Type | Testcase | Key parameter shapes | Notes / uncovered reason |
| --- | --- | --- | --- | --- | --- |
| ✓ | drive +add-comment | shortcut | drive_add_comment_dryrun_test.go::TestDriveAddCommentDryRun_File; drive_add_comment_dryrun_test.go::TestDriveAddCommentDryRun_Base | `--doc` file URL vs bare token + `--type file`; supported-extension metadata gate; placeholder `anchor.block_id`; Base URL with `--block-id <table-id>!<record-id>!<view-id>` | dry-run coverage in place; opt-in live file workflow exists behind `LARK_DRIVE_MD_COMMENT_E2E=1` |
| ✓ | drive +apply-permission | shortcut | drive_apply_permission_dryrun_test.go::TestDrive_ApplyPermissionDryRun | `--token` URL vs bare; `--type` (enum) with URL inference; `--perm view\|edit`; `--remark` optional | dry-run only; no live-apply E2E because a real request pushes a card to the owner |
| ✕ | drive +delete | shortcut | | none | no primary delete workflow yet |
| ✕ | drive +download | shortcut | | none | no file fixture workflow yet |
| ✓ | drive +export | shortcut | drive_export_dryrun_test.go::TestDriveExportDryRun_FileNameMetadata + TestDriveExportDryRun_MarkdownFetchAPI + TestDriveExportDryRun_BitableBaseOnlySchema | `--token`; `--doc-type`; `--file-extension`; `--file-name`; `--output-dir`; `--only-schema`; markdown fetch omits docs fetch `extra_param` | dry-run only; no live export workflow yet |
| ✕ | drive +export-download | shortcut | | none | no export-download workflow yet |
| ✕ | drive +import | shortcut | | none | no import workflow yet |
| ✕ | drive +move | shortcut | | none | no move workflow yet |
| ✓ | drive +pull | shortcut | drive_pull_dryrun_test.go::TestDrive_PullDryRun + drive_duplicate_sync_workflow_test.go::TestDrive_DuplicateRemoteWorkflow | `--local-dir`; `--folder-token`; `--on-duplicate-remote=rename\|newest\|oldest`; `--delete-local --yes` guard | dry-run locks flag/validate shape; live workflow proves duplicate fail-fast and rename recovery |
| ✓ | drive +push | shortcut | drive_push_dryrun_test.go::TestDrive_PushDryRun + drive_duplicate_sync_workflow_test.go::TestDrive_DuplicateRemoteWorkflow | `--local-dir`; `--folder-token`; `--if-exists`; `--on-duplicate-remote=newest\|oldest`; `--delete-remote --yes` | dry-run locks flag/validate shape; live workflow proves overwrite + duplicate cleanup converges status |
| ✓ | drive +secure-label-list | shortcut | drive_secure_label_dryrun_test.go::TestDrive_SecureLabelDryRun | `--page-size`; `--page-token`; `--lang` | dry-run only; live label availability depends on tenant security-label configuration |
| ✓ | drive +secure-label-update | shortcut | drive_secure_label_dryrun_test.go::TestDrive_SecureLabelDryRun | `--token` URL inference; `--type`; `--label-id` body | dry-run only; live update can require document-level approval or mutate a fixture document's security level |
| ✓ | drive +status | shortcut | drive_status_workflow_test.go::TestDrive_StatusWorkflow + drive_status_dryrun_test.go::TestDrive_StatusDryRun + drive_duplicate_sync_workflow_test.go::TestDrive_DuplicateRemoteWorkflow | `--local-dir`; `--folder-token`; bucketed `new_local` / `new_remote` / `modified` / `unchanged` outputs | dry-run pins request shape; live workflows cover both normal hashing buckets and duplicate-remote failure |
| ✓ | drive +sync | shortcut | drive_sync_dryrun_test.go::TestDrive_SyncDryRun + drive_sync_workflow_test.go::TestDrive_SyncWorkflow + drive_sync_workflow_test.go::TestDrive_SyncEmptyDirWorkflow | `--local-dir`; `--folder-token`; `--on-conflict=remote-wins\|local-wins\|keep-both\|ask`; `--on-duplicate-remote=fail\|newest\|oldest`; `--quick` | dry-run validates request shape, flag acceptance, and path safety guards; live workflow proves new_remote→pull, new_local→push, remote-wins/local-wins/keep-both conflict resolution, empty directory creation, and post-sync convergence |
| ✕ | drive +task_result | shortcut | | none | no async task-result workflow yet |
| ✓ | drive +upload | shortcut | drive_upload_dryrun_test.go::TestDriveUploadDryRun_WikiTarget + drive_upload_dryrun_test.go::TestDriveUploadDryRun_WithFileToken + drive_upload_workflow_test.go::TestDrive_UploadWorkflow + drive_status_workflow_test.go::TestDrive_StatusWorkflow + drive_duplicate_sync_workflow_test.go::TestDrive_DuplicateRemoteWorkflow | `--wiki-token`; `--file-token`; `parent_type=wiki`; `parent_node`; named uploads into Drive folders; in-place overwrite uploads | dry-run covers wiki-target and overwrite request shapes; live workflows assert returned file tokens, token-stable overwrite behavior, and that uploaded fixtures are consumable by downstream commands |
| ✕ | drive file.comment.replys create | api | | none | no reply workflow yet |
| ✕ | drive file.comment.replys delete | api | | none | no reply workflow yet |
| ✕ | drive file.comment.replys list | api | | none | no reply workflow yet |
| ✕ | drive file.comment.replys update | api | | none | no reply workflow yet |
| ✕ | drive file.comments create_v2 | api | | none | no file comment workflow yet |
| ✕ | drive file.comments list | api | | none | no file comment workflow yet |
| ✕ | drive file.comments patch | api | | none | no file comment workflow yet |
| ✕ | drive file.statistics get | api | | none | no statistics workflow yet |
| ✕ | drive file.view_records list | api | | none | no view-record workflow yet |
| ✕ | drive files copy | api | | none | no file copy workflow yet |
| ✓ | drive files create_folder | api | drive_files_workflow_test.go::TestDrive_FilesCreateFolderWorkflow/create_folder as bot | `name`; empty `folder_token` in `--data` | |
| ✕ | drive files list | api | | none | no list workflow yet |
| ✕ | drive metas batch_query | api | | none | no metadata workflow yet |
| ✕ | drive permission.members auth | api | | none | permission workflows not covered |
| ✕ | drive permission.members create | api | | none | permission workflows not covered |
| ✕ | drive permission.members transfer_owner | api | | none | permission workflows not covered |
| ✕ | drive user remove_subscription | api | | none | subscription workflows not covered |
| ✕ | drive user subscription | api | | none | subscription workflows not covered |
| ✕ | drive user subscription_status | api | | none | subscription workflows not covered |
@@ -0,0 +1,90 @@
// 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"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestDriveAddCommentDryRun_File(t *testing.T) {
setDriveDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+add-comment",
"--doc", "https://example.larksuite.com/file/fileDryRunComment",
"--content", `[{"type":"text","text":"please update README"}]`,
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/drive/v1/metas/batch_query" {
t.Fatalf("api.0.url=%q, want metas/batch_query\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.body.request_docs.0.doc_type").String(); got != "file" {
t.Fatalf("api.0.body.request_docs.0.doc_type=%q, want file\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.1.url").String(); got != "/open-apis/drive/v1/files/fileDryRunComment/new_comments" {
t.Fatalf("api.1.url=%q, want new_comments\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.1.body.file_type").String(); got != "file" {
t.Fatalf("api.1.body.file_type=%q, want file\nstdout:\n%s", got, out)
}
if !gjson.Get(out, "api.1.body.anchor.block_id").Exists() {
t.Fatalf("api.1.body.anchor.block_id should exist for file comment\nstdout:\n%s", out)
}
if got := gjson.Get(out, "api.1.body.anchor.block_id").String(); got != "test" {
t.Fatalf("api.1.body.anchor.block_id=%q, want test\nstdout:\n%s", got, out)
}
}
func TestDriveAddCommentDryRun_Base(t *testing.T) {
setDriveDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+add-comment",
"--doc", "https://example.larksuite.com/base/baseDryRunComment",
"--content", `[{"type":"text","text":"please check this record"}]`,
"--block-id", "tbl9mp6fj9kDKHQV!recBIBgGmb!vewc46MG1R",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/drive/v1/files/baseDryRunComment/new_comments" {
t.Fatalf("api.0.url=%q, want new_comments\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.body.file_type").String(); got != "bitable" {
t.Fatalf("api.0.body.file_type=%q, want bitable\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.body.anchor.block_id").String(); got != "tbl9mp6fj9kDKHQV" {
t.Fatalf("api.0.body.anchor.block_id=%q, want tbl9mp6fj9kDKHQV\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.body.anchor.base_record_id").String(); got != "recBIBgGmb" {
t.Fatalf("api.0.body.anchor.base_record_id=%q, want recBIBgGmb\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.body.anchor.base_view_id").String(); got != "vewc46MG1R" {
t.Fatalf("api.0.body.anchor.base_view_id=%q, want vewc46MG1R\nstdout:\n%s", got, out)
}
}
@@ -0,0 +1,84 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"os"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestDriveAddCommentMarkdownFileWorkflow(t *testing.T) {
if os.Getenv("LARK_DRIVE_MD_COMMENT_E2E") == "" {
t.Skip("set LARK_DRIVE_MD_COMMENT_E2E=1 to run the supported file comment workflow")
}
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
suffix := clie2e.GenerateSuffix()
fileName := "lark-cli-e2e-drive-comment-" + suffix + ".md"
createResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"markdown", "+create",
"--name", fileName,
"--content", "# Comment target\n\nbody\n",
},
DefaultAs: "bot",
})
require.NoError(t, err)
createResult.AssertExitCode(t, 0)
createResult.AssertStdoutStatus(t, true)
fileToken := gjson.Get(createResult.Stdout, "data.file_token").String()
require.NotEmpty(t, fileToken, "stdout:\n%s", createResult.Stdout)
parentT.Cleanup(func() {
cleanupCtx, cleanupCancel := clie2e.CleanupContext()
defer cleanupCancel()
deleteResult, deleteErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{
Args: []string{
"drive", "+delete",
"--file-token", fileToken,
"--type", "file",
"--yes",
},
DefaultAs: "bot",
})
clie2e.ReportCleanupFailure(parentT, "delete file comment target "+fileToken, deleteResult, deleteErr)
})
commentResult, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{
"drive", "+add-comment",
"--doc", fileToken,
"--type", "file",
"--content", `[{"type":"text","text":"please update README"}]`,
},
DefaultAs: "bot",
}, clie2e.RetryOptions{})
require.NoError(t, err)
commentResult.AssertExitCode(t, 0)
commentResult.AssertStdoutStatus(t, true)
commentID := gjson.Get(commentResult.Stdout, "data.comment_id").String()
require.NotEmpty(t, commentID, "stdout:\n%s", commentResult.Stdout)
if got := gjson.Get(commentResult.Stdout, "data.file_type").String(); got != "file" {
t.Fatalf("data.file_type=%q, want file\nstdout:\n%s", got, commentResult.Stdout)
}
if got := gjson.Get(commentResult.Stdout, "data.file_name").String(); got != fileName {
t.Fatalf("data.file_name=%q, want %q\nstdout:\n%s", got, fileName, commentResult.Stdout)
}
if got := gjson.Get(commentResult.Stdout, "data.file_extension").String(); got != ".md" {
t.Fatalf("data.file_extension=%q, want .md\nstdout:\n%s", got, commentResult.Stdout)
}
}
@@ -0,0 +1,193 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestDrive_ApplyPermissionDryRun locks in the request shape the shortcut
// emits under --dry-run: the real CLI binary is invoked end-to-end (so the
// full flag-parsing, validation, and dry-run renderers all execute), and the
// printed request is inspected to confirm
// - HTTP method, URL template, and the token path segment,
// - type query parameter (auto-inferred from a URL input, explicit for a
// bare token input),
// - perm / remark body fields.
//
// Fake credentials are sufficient because --dry-run short-circuits before
// any network call.
func TestDrive_ApplyPermissionDryRun(t *testing.T) {
// Isolate from any local CLI state: the subprocess inherits the parent
// test environment, and without an explicit config dir it could read a
// developer's real credentials/profile instead of the fake ones below.
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
tests := []struct {
name string
args []string
wantURL string
wantType string
wantPerm string
wantBody map[string]string // optional substrings (key=rendered token) to require
}{
{
name: "URL input auto-infers docx type",
args: []string{
"drive", "+apply-permission",
"--token", "https://example.feishu.cn/docx/doxcnE2E001?from=share",
"--perm", "view",
"--remark", "e2e note",
"--dry-run",
},
wantURL: "/open-apis/drive/v1/permissions/doxcnE2E001/members/apply",
wantType: "docx",
wantPerm: "view",
wantBody: map[string]string{"remark": "e2e note"},
},
{
name: "URL input auto-infers sheet type",
args: []string{
"drive", "+apply-permission",
"--token", "https://example.feishu.cn/sheets/shtcnE2E002?sheet=abc",
"--perm", "edit",
"--dry-run",
},
wantURL: "/open-apis/drive/v1/permissions/shtcnE2E002/members/apply",
wantType: "sheet",
wantPerm: "edit",
},
{
// Explicit --type must override URL inference: the /docx/ marker
// would infer type=docx, but the caller asked for type=wiki (e.g.
// to apply against the underlying wiki node rather than its docx
// target). The URL token itself is still used as the path token.
name: "explicit --type overrides URL inference",
args: []string{
"drive", "+apply-permission",
"--token", "https://example.feishu.cn/docx/doxcnE2E003",
"--type", "wiki",
"--perm", "view",
"--dry-run",
},
wantURL: "/open-apis/drive/v1/permissions/doxcnE2E003/members/apply",
wantType: "wiki",
wantPerm: "view",
},
{
name: "bare token with explicit type",
args: []string{
"drive", "+apply-permission",
"--token", "bscE2E004",
"--type", "bitable",
"--perm", "view",
"--dry-run",
},
wantURL: "/open-apis/drive/v1/permissions/bscE2E004/members/apply",
wantType: "bitable",
wantPerm: "view",
},
{
name: "slides URL inference",
args: []string{
"drive", "+apply-permission",
"--token", "https://example.feishu.cn/slides/slE2E004",
"--perm", "view",
"--dry-run",
},
wantURL: "/open-apis/drive/v1/permissions/slE2E004/members/apply",
wantType: "slides",
wantPerm: "view",
},
}
for _, temp := range tests {
tt := temp
t.Run(tt.name, func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: tt.args,
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
// Dry-run output is the JSON envelope; gjson walks into api[0].
if got := gjson.Get(out, "api.0.method").String(); got != "POST" {
t.Fatalf("method = %q, want POST\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.url").String(); got != tt.wantURL {
t.Fatalf("url = %q, want %q\nstdout:\n%s", got, tt.wantURL, out)
}
if got := gjson.Get(out, "api.0.params.type").String(); got != tt.wantType {
t.Fatalf("params.type = %q, want %q\nstdout:\n%s", got, tt.wantType, out)
}
if got := gjson.Get(out, "api.0.body.perm").String(); got != tt.wantPerm {
t.Fatalf("body.perm = %q, want %q\nstdout:\n%s", got, tt.wantPerm, out)
}
for k, v := range tt.wantBody {
if got := gjson.Get(out, "api.0.body."+k).String(); got != v {
t.Fatalf("body.%s = %q, want %q\nstdout:\n%s", k, got, v, out)
}
}
// When no --remark is passed, the body must NOT carry an empty
// remark field (the owner's request card would otherwise render
// a blank note).
if _, wantsRemark := tt.wantBody["remark"]; !wantsRemark {
if gjson.Get(out, "api.0.body.remark").Exists() {
t.Fatalf("body.remark should be omitted when --remark is empty, stdout:\n%s", out)
}
}
})
}
}
// TestDrive_ApplyPermissionDryRunRejectsFullAccess locks in the client-side
// enum guard: the spec rejects perm=full_access, so the shortcut must refuse
// it before the request ever reaches the server. Exercised end-to-end to
// guarantee the enum validator is wired into the mount path.
func TestDrive_ApplyPermissionDryRunRejectsFullAccess(t *testing.T) {
// Isolate from any local CLI state: the subprocess inherits the parent
// test environment, and without an explicit config dir it could read a
// developer's real credentials/profile instead of the fake ones below.
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+apply-permission",
"--token", "doxcnE2E999",
"--type", "docx",
"--perm", "full_access",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
if result.ExitCode == 0 {
t.Fatalf("full_access must be rejected, got exit=0\nstdout:\n%s", result.Stdout)
}
combined := result.Stdout + "\n" + result.Stderr
if !strings.Contains(combined, "perm") {
t.Fatalf("expected perm-related error, got:\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
}
}
@@ -0,0 +1,277 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestDrive_DuplicateRemoteWorkflow(t *testing.T) {
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute)
t.Cleanup(cancel)
uploadNamedFile := func(t *testing.T, workDir, folderToken, stageName, remoteName, content string) string {
t.Helper()
stagePath := filepath.Join(workDir, stageName)
if err := os.WriteFile(stagePath, []byte(content), 0o644); err != nil {
t.Fatalf("write stage file %s: %v", stageName, err)
}
t.Cleanup(func() { _ = os.Remove(stagePath) })
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+upload",
"--file", stageName,
"--folder-token", folderToken,
"--name", remoteName,
},
WorkDir: workDir,
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
fileToken := gjson.Get(result.Stdout, "data.file_token").String()
require.NotEmpty(t, fileToken, "uploaded file should have a token, stdout:\n%s", result.Stdout)
return fileToken
}
t.Run("status and pull handle duplicate remote files", func(t *testing.T) {
suffix := clie2e.GenerateSuffix()
folderToken := createDriveFolder(t, parentT, ctx, "lark-cli-e2e-drive-dup-pull-"+suffix, "")
workDir := t.TempDir()
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
t.Fatalf("mkdir local: %v", err)
}
firstToken := uploadNamedFile(t, workDir, folderToken, "_dup_first.txt", "dup.txt", "first")
secondToken := uploadNamedFile(t, workDir, folderToken, "_dup_second.txt", "dup.txt", "second")
statusResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+status",
"--local-dir", "local",
"--folder-token", folderToken,
},
WorkDir: workDir,
DefaultAs: "bot",
})
require.NoError(t, err)
skipDriveStatusExactIfMissingDownloadScope(t, statusResult)
if statusResult.ExitCode == 0 {
t.Fatalf("+status should fail on duplicate remote rel_path\nstdout:\n%s\nstderr:\n%s", statusResult.Stdout, statusResult.Stderr)
}
if !strings.Contains(statusResult.Stderr, `"type": "validation"`) ||
!strings.Contains(statusResult.Stderr, "map to multiple Drive entries") {
t.Fatalf("+status stderr should be a typed validation error for duplicate rel_path\nstdout:\n%s\nstderr:\n%s", statusResult.Stdout, statusResult.Stderr)
}
pullFailResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+pull",
"--local-dir", "local",
"--folder-token", folderToken,
},
WorkDir: workDir,
DefaultAs: "bot",
})
require.NoError(t, err)
if pullFailResult.ExitCode == 0 {
t.Fatalf("+pull should fail on duplicate remote rel_path by default\nstdout:\n%s\nstderr:\n%s", pullFailResult.Stdout, pullFailResult.Stderr)
}
if !strings.Contains(pullFailResult.Stderr, `"type": "validation"`) ||
!strings.Contains(pullFailResult.Stderr, "map to multiple Drive entries") {
t.Fatalf("+pull stderr should be a typed validation error for duplicate rel_path\nstdout:\n%s\nstderr:\n%s", pullFailResult.Stdout, pullFailResult.Stderr)
}
if _, statErr := os.Stat(filepath.Join(workDir, "local", "dup.txt")); !os.IsNotExist(statErr) {
t.Fatalf("default duplicate failure must not write dup.txt; stat err=%v", statErr)
}
pullRenameResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+pull",
"--local-dir", "local",
"--folder-token", folderToken,
"--on-duplicate-remote", "rename",
},
WorkDir: workDir,
DefaultAs: "bot",
})
require.NoError(t, err)
pullRenameResult.AssertExitCode(t, 0)
pullRenameResult.AssertStdoutStatus(t, true)
items := gjson.Get(pullRenameResult.Stdout, "data.items")
if items.Array() == nil || len(items.Array()) != 2 {
t.Fatalf("+pull rename should produce two items, stdout:\n%s", pullRenameResult.Stdout)
}
if got := gjson.Get(pullRenameResult.Stdout, "data.summary.downloaded").Int(); got != 2 {
t.Fatalf("+pull rename downloaded=%d, want 2\nstdout:\n%s", got, pullRenameResult.Stdout)
}
relPaths := []string{
gjson.Get(pullRenameResult.Stdout, "data.items.0.rel_path").String(),
gjson.Get(pullRenameResult.Stdout, "data.items.1.rel_path").String(),
}
var renamedRel string
for _, rel := range relPaths {
if rel != "dup.txt" {
renamedRel = rel
}
}
if renamedRel == "" || !strings.HasPrefix(renamedRel, "dup__lark_") || !strings.HasSuffix(renamedRel, ".txt") {
t.Fatalf("renamed rel_path = %q, want dup__lark_<hash>.txt\nstdout:\n%s", renamedRel, pullRenameResult.Stdout)
}
if !strings.Contains(pullRenameResult.Stdout, `"source_id":"hash_`) &&
!strings.Contains(pullRenameResult.Stdout, `"source_id": "hash_`) {
t.Fatalf("+pull rename stdout should contain source_id for duplicate items\nstdout:\n%s", pullRenameResult.Stdout)
}
if strings.Contains(pullRenameResult.Stdout, firstToken) || strings.Contains(pullRenameResult.Stdout, secondToken) {
t.Fatalf("+pull rename stdout should not expose raw duplicate file tokens\nstdout:\n%s", pullRenameResult.Stdout)
}
require.FileExists(t, filepath.Join(workDir, "local", "dup.txt"))
require.FileExists(t, filepath.Join(workDir, "local", filepath.FromSlash(renamedRel)))
})
t.Run("push resolves duplicate remote files and converges status", func(t *testing.T) {
suffix := clie2e.GenerateSuffix()
folderToken := createDriveFolder(t, parentT, ctx, "lark-cli-e2e-drive-dup-push-"+suffix, "")
workDir := t.TempDir()
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
t.Fatalf("mkdir local: %v", err)
}
if err := os.WriteFile(filepath.Join(workDir, "local", "dup.txt"), []byte("local-overwrite"), 0o644); err != nil {
t.Fatalf("write local dup.txt: %v", err)
}
_ = uploadNamedFile(t, workDir, folderToken, "_push_dup_first.txt", "dup.txt", "remote-first")
time.Sleep(1200 * time.Millisecond)
_ = uploadNamedFile(t, workDir, folderToken, "_push_dup_second.txt", "dup.txt", "remote-second")
pushResult, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{
"drive", "+push",
"--local-dir", "local",
"--folder-token", folderToken,
"--if-exists", "overwrite",
"--on-duplicate-remote", "newest",
"--delete-remote",
"--yes",
},
WorkDir: workDir,
DefaultAs: "bot",
}, clie2e.RetryOptions{})
require.NoError(t, err)
pushResult.AssertExitCode(t, 0)
pushResult.AssertStdoutStatus(t, true)
if got := gjson.Get(pushResult.Stdout, "data.summary.uploaded").Int(); got != 1 {
t.Fatalf("+push uploaded=%d, want 1\nstdout:\n%s", got, pushResult.Stdout)
}
if got := gjson.Get(pushResult.Stdout, "data.summary.deleted_remote").Int(); got != 1 {
t.Fatalf("+push deleted_remote=%d, want 1\nstdout:\n%s", got, pushResult.Stdout)
}
statusResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+status",
"--local-dir", "local",
"--folder-token", folderToken,
},
WorkDir: workDir,
DefaultAs: "bot",
})
require.NoError(t, err)
skipDriveStatusExactIfMissingDownloadScope(t, statusResult)
statusResult.AssertExitCode(t, 0)
statusResult.AssertStdoutStatus(t, true)
if got := gjson.Get(statusResult.Stdout, "data.unchanged.#").Int(); got != 1 {
t.Fatalf("+status unchanged count=%d, want 1\nstdout:\n%s", got, statusResult.Stdout)
}
if got := gjson.Get(statusResult.Stdout, "data.unchanged.0.rel_path").String(); got != "dup.txt" {
t.Fatalf("+status unchanged rel_path=%q, want dup.txt\nstdout:\n%s", got, statusResult.Stdout)
}
if got := gjson.Get(statusResult.Stdout, "data.modified.#").Int(); got != 0 ||
gjson.Get(statusResult.Stdout, "data.new_local.#").Int() != 0 ||
gjson.Get(statusResult.Stdout, "data.new_remote.#").Int() != 0 {
t.Fatalf("+status should converge to a clean unchanged mirror\nstdout:\n%s", statusResult.Stdout)
}
})
t.Run("push overwrites nested remote file under its real parent", func(t *testing.T) {
suffix := clie2e.GenerateSuffix()
folderToken := createDriveFolder(t, parentT, ctx, "lark-cli-e2e-drive-nested-push-"+suffix, "")
subFolderToken := createDriveFolder(t, parentT, ctx, "sub", folderToken)
workDir := t.TempDir()
if err := os.MkdirAll(filepath.Join(workDir, "local", "sub"), 0o755); err != nil {
t.Fatalf("mkdir local/sub: %v", err)
}
if err := os.WriteFile(filepath.Join(workDir, "local", "sub", "keep.txt"), []byte("local-nested-overwrite"), 0o644); err != nil {
t.Fatalf("write local/sub/keep.txt: %v", err)
}
existingToken := uploadNamedFile(t, workDir, subFolderToken, "_nested_keep.txt", "keep.txt", "remote-before")
pushResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+push",
"--local-dir", "local",
"--folder-token", folderToken,
"--if-exists", "overwrite",
},
WorkDir: workDir,
DefaultAs: "bot",
})
require.NoError(t, err)
pushResult.AssertExitCode(t, 0)
pushResult.AssertStdoutStatus(t, true)
if got := gjson.Get(pushResult.Stdout, "data.summary.uploaded").Int(); got != 1 {
t.Fatalf("nested +push uploaded=%d, want 1\nstdout:\n%s", got, pushResult.Stdout)
}
if got := gjson.Get(pushResult.Stdout, `data.items.#(rel_path="sub/keep.txt").action`).String(); got != "overwritten" {
t.Fatalf("nested +push action=%q, want overwritten\nstdout:\n%s", got, pushResult.Stdout)
}
if got := gjson.Get(pushResult.Stdout, `data.items.#(rel_path="sub/keep.txt").file_token`).String(); got != existingToken {
t.Fatalf("nested +push file_token=%q, want existing token %q\nstdout:\n%s", got, existingToken, pushResult.Stdout)
}
statusResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+status",
"--local-dir", "local",
"--folder-token", folderToken,
},
WorkDir: workDir,
DefaultAs: "bot",
})
require.NoError(t, err)
skipDriveStatusExactIfMissingDownloadScope(t, statusResult)
statusResult.AssertExitCode(t, 0)
statusResult.AssertStdoutStatus(t, true)
if got := gjson.Get(statusResult.Stdout, "data.unchanged.#").Int(); got != 1 {
t.Fatalf("nested +status unchanged count=%d, want 1\nstdout:\n%s", got, statusResult.Stdout)
}
if got := gjson.Get(statusResult.Stdout, "data.unchanged.0.rel_path").String(); got != "sub/keep.txt" {
t.Fatalf("nested +status unchanged rel_path=%q, want sub/keep.txt\nstdout:\n%s", got, statusResult.Stdout)
}
if got := gjson.Get(statusResult.Stdout, "data.modified.#").Int(); got != 0 ||
gjson.Get(statusResult.Stdout, "data.new_local.#").Int() != 0 ||
gjson.Get(statusResult.Stdout, "data.new_remote.#").Int() != 0 {
t.Fatalf("nested overwrite should converge to a clean unchanged mirror\nstdout:\n%s", statusResult.Stdout)
}
})
}
@@ -0,0 +1,145 @@
// 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"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestDriveExportDryRun_FileNameMetadata(t *testing.T) {
setDriveDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+export",
"--token", "docxDryRunExport",
"--doc-type", "docx",
"--file-extension", "pdf",
"--file-name", "custom-report",
"--output-dir", "./exports",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "api.0.method").String(); got != "POST" {
t.Fatalf("method=%q, want POST\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/drive/v1/export_tasks" {
t.Fatalf("url=%q, want export_tasks\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.body.token").String(); got != "docxDryRunExport" {
t.Fatalf("body.token=%q, want docxDryRunExport\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.body.type").String(); got != "docx" {
t.Fatalf("body.type=%q, want docx\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.body.file_extension").String(); got != "pdf" {
t.Fatalf("body.file_extension=%q, want pdf\nstdout:\n%s", got, out)
}
if gjson.Get(out, "api.0.body.file_name").Exists() {
t.Fatalf("file_name should stay local metadata, not export_tasks body\nstdout:\n%s", out)
}
if got := gjson.Get(out, "file_name").String(); got != "custom-report.pdf" {
t.Fatalf("file_name=%q, want custom-report.pdf\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "output_dir").String(); got != "./exports" {
t.Fatalf("output_dir=%q, want ./exports\nstdout:\n%s", got, out)
}
}
func TestDriveExportDryRun_MarkdownFetchAPI(t *testing.T) {
setDriveDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+export",
"--token", "docxMdDryRun",
"--doc-type", "docx",
"--file-extension", "markdown",
"--file-name", "my-notes",
"--output-dir", "./md-exports",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "api.0.method").String(); got != "POST" {
t.Fatalf("method=%q, want POST\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/docs_ai/v1/documents/docxMdDryRun/fetch" {
t.Fatalf("url=%q, want docs_ai fetch\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.body.format").String(); got != "markdown" {
t.Fatalf("body.format=%q, want markdown\nstdout:\n%s", got, out)
}
if gjson.Get(out, "api.0.body.extra_param").Exists() {
t.Fatalf("markdown drive export must not enable docs fetch extra_param\nstdout:\n%s", out)
}
if got := gjson.Get(out, "file_name").String(); got != "my-notes.md" {
t.Fatalf("file_name=%q, want my-notes.md\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "output_dir").String(); got != "./md-exports" {
t.Fatalf("output_dir=%q, want ./md-exports\nstdout:\n%s", got, out)
}
}
func TestDriveExportDryRun_BitableBaseOnlySchema(t *testing.T) {
setDriveDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+export",
"--token", "bitableDryRunExport",
"--doc-type", "bitable",
"--file-extension", "base",
"--only-schema",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "api.0.method").String(); got != "POST" {
t.Fatalf("method=%q, want POST\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/drive/v1/export_tasks" {
t.Fatalf("url=%q, want export_tasks\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.body.token").String(); got != "bitableDryRunExport" {
t.Fatalf("body.token=%q, want bitableDryRunExport\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.body.type").String(); got != "bitable" {
t.Fatalf("body.type=%q, want bitable\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.body.file_extension").String(); got != "base" {
t.Fatalf("body.file_extension=%q, want base\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.body.only_schema").Bool(); !got {
t.Fatalf("body.only_schema=%v, want true\nstdout:\n%s", got, out)
}
}
@@ -0,0 +1,31 @@
// 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()
parentFolderName := "lark-cli-e2e-drive-parent-" + suffix
folderName := "lark-cli-e2e-drive-folder-" + suffix
parentFolderToken := createDriveFolder(t, parentT, ctx, parentFolderName, "")
t.Run("create_folder as bot", func(t *testing.T) {
folderToken := createDriveFolder(t, parentT, ctx, folderName, parentFolderToken)
if folderToken == "" {
t.Fatalf("folder token should be available")
}
})
}
@@ -0,0 +1,58 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"os"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestDriveImportDryRunFolderTokenWikiProbe(t *testing.T) {
setDriveDryRunConfigEnv(t)
workDir := t.TempDir()
if err := os.WriteFile(workDir+"/notes.md", []byte("# dry run\n"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+import",
"--file", "notes.md",
"--type", "docx",
"--folder-token", "fldcnImportDryRunTarget",
"--dry-run",
},
WorkDir: workDir,
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "api.0.method").String(); got != "GET" {
t.Fatalf("api.0.method = %q, want GET\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/wiki/v2/spaces/get_node" {
t.Fatalf("api.0.url = %q, want wiki get_node\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.params.token").String(); got != "fldcnImportDryRunTarget" {
t.Fatalf("api.0.params.token = %q, want fldcnImportDryRunTarget\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.1.url").String(); got != "/open-apis/drive/v1/medias/upload_all" {
t.Fatalf("api.1.url = %q, want upload_all\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.2.body.point.mount_key").String(); got != "fldcnImportDryRunTarget" {
t.Fatalf("api.2.body.point.mount_key = %q, want fldcnImportDryRunTarget\nstdout:\n%s", got, out)
}
}
@@ -0,0 +1,255 @@
// 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"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// --- Happy path: all supported URL types ---
func TestDriveInspectDryRun_DocxURL(t *testing.T) {
setDriveInspectE2EEnv(t)
result := runInspectDryRun(t, "https://xxx.feishu.cn/docx/doxcnDryRunE2E")
assertOneStepBatchQuery(t, result)
}
func TestDriveInspectDryRun_DocURL(t *testing.T) {
setDriveInspectE2EEnv(t)
result := runInspectDryRun(t, "https://xxx.feishu.cn/doc/doccnDryRunE2E")
assertOneStepBatchQuery(t, result)
}
func TestDriveInspectDryRun_SheetURL(t *testing.T) {
setDriveInspectE2EEnv(t)
result := runInspectDryRun(t, "https://xxx.feishu.cn/sheets/shtcnDryRunE2E")
assertOneStepBatchQuery(t, result)
}
func TestDriveInspectDryRun_BitableURL(t *testing.T) {
setDriveInspectE2EEnv(t)
result := runInspectDryRun(t, "https://xxx.feishu.cn/base/bascnDryRunE2E")
assertOneStepBatchQuery(t, result)
}
func TestDriveInspectDryRun_FileURL(t *testing.T) {
setDriveInspectE2EEnv(t)
result := runInspectDryRun(t, "https://xxx.feishu.cn/file/boxcnDryRunE2E")
assertOneStepBatchQuery(t, result)
}
func TestDriveInspectDryRun_DoubaoDriveFileURL(t *testing.T) {
setDriveInspectE2EEnv(t)
result := runInspectDryRun(t, "https://feishu.doubao.com/drive/file/boxcnDryRunE2E")
assertOneStepBatchQuery(t, result)
}
func TestDriveInspectDryRun_FolderURL(t *testing.T) {
setDriveInspectE2EEnv(t)
result := runInspectDryRun(t, "https://xxx.feishu.cn/drive/folder/fldcnDryRunE2E")
assertOneStepBatchQuery(t, result)
}
func TestDriveInspectDryRun_DoubaoChatDriveFolderURL(t *testing.T) {
setDriveInspectE2EEnv(t)
result := runInspectDryRun(t, "https://feishu.doubao.com/chat/drive/fldcnDryRunE2E")
assertOneStepBatchQuery(t, result)
}
func TestDriveInspectDryRun_DoubaoDriveShareFolderURL(t *testing.T) {
setDriveInspectE2EEnv(t)
result := runInspectDryRun(t, "https://feishu.doubao.com/drive/shr/fldcnDryRunE2E")
assertOneStepBatchQuery(t, result)
}
func TestDriveInspectDryRun_MindnoteURL(t *testing.T) {
setDriveInspectE2EEnv(t)
result := runInspectDryRun(t, "https://xxx.feishu.cn/mindnote/mncnDryRunE2E")
assertOneStepBatchQuery(t, result)
}
func TestDriveInspectDryRun_SlidesURL(t *testing.T) {
setDriveInspectE2EEnv(t)
result := runInspectDryRun(t, "https://xxx.feishu.cn/slides/slkcnDryRunE2E")
assertOneStepBatchQuery(t, result)
}
// --- Wiki URL: two-step flow ---
func TestDriveInspectDryRun_WikiURL(t *testing.T) {
setDriveInspectE2EEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+inspect",
"--url", "https://xxx.feishu.cn/wiki/wikcnDryRunE2E",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
require.Equal(t, int64(2), gjson.Get(result.Stdout, "api.#").Int(),
"expected exactly 2 dry-run API steps for wiki URL, stdout:\n%s", result.Stdout)
require.Equal(t, "/open-apis/wiki/v2/spaces/get_node",
gjson.Get(result.Stdout, "api.0.url").String(),
"expected get_node as first step, stdout:\n%s", result.Stdout)
require.Equal(t, "/open-apis/drive/v1/metas/batch_query",
gjson.Get(result.Stdout, "api.1.url").String(),
"expected batch_query as second step, stdout:\n%s", result.Stdout)
}
// --- Bare token with --type ---
func TestDriveInspectDryRun_BareTokenWithType(t *testing.T) {
setDriveInspectE2EEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+inspect",
"--url", "doxcnBareToken",
"--type", "docx",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assertOneStepBatchQuery(t, result)
}
// --- Validation errors ---
func TestDriveInspectValidation_EmptyURL(t *testing.T) {
setDriveInspectE2EEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+inspect",
"--url", "",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
require.Contains(t, result.Stderr, "--url cannot be empty",
"expected empty URL validation error, stderr:\n%s", result.Stderr)
}
func TestDriveInspectValidation_UnsupportedURL(t *testing.T) {
setDriveInspectE2EEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+inspect",
"--url", "https://google.com/some/page",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
require.Contains(t, result.Stderr, "unsupported --url",
"expected unsupported URL validation error, stderr:\n%s", result.Stderr)
}
func TestDriveInspectValidation_BareTokenWithoutType(t *testing.T) {
setDriveInspectE2EEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+inspect",
"--url", "doxcnBareToken",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
require.Contains(t, result.Stderr, "--type is required when --url is a bare token",
"expected bare-token-without-type validation error, stderr:\n%s", result.Stderr)
}
func TestDriveInspectValidation_InvalidType(t *testing.T) {
setDriveInspectE2EEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+inspect",
"--url", "someToken",
"--type", "invalid_type",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
require.Contains(t, result.Stderr, "invalid_type",
"expected invalid type validation error, stderr:\n%s", result.Stderr)
}
// --- Helpers ---
func runInspectDryRun(t *testing.T, url string) *clie2e.Result {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+inspect",
"--url", url,
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
return result
}
func assertOneStepBatchQuery(t *testing.T, result *clie2e.Result) {
t.Helper()
require.Equal(t, int64(1), gjson.Get(result.Stdout, "api.#").Int(),
"expected exactly 1 dry-run API step, stdout:\n%s", result.Stdout)
require.Equal(t, "/open-apis/drive/v1/metas/batch_query",
gjson.Get(result.Stdout, "api.0.url").String(),
"expected batch_query URL, stdout:\n%s", result.Stdout)
}
func setDriveInspectE2EEnv(t *testing.T) {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "drive_inspect_e2e_app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "drive_inspect_e2e_secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
}
@@ -0,0 +1,499 @@
// 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"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestDrive_MemberAddDryRun locks in the request shape the shortcut emits
// under --dry-run: the real CLI binary is invoked end-to-end (so flag parsing,
// validation, and dry-run rendering all execute), and the printed request is
// inspected to confirm
// - HTTP method, URL template, and token path segment,
// - type query parameter (auto-inferred from a URL input, explicit for a
// bare token input),
// - explicit --type overriding URL inference,
// - member_type / member kind / perm / perm_type body fields,
// - single-member vs batch endpoint selection.
//
// Fake credentials are sufficient because --dry-run short-circuits before any
// network call.
func TestDrive_MemberAddDryRun(t *testing.T) {
// Isolate from any local CLI state: the subprocess inherits the parent
// test environment, and without an explicit config dir it could read a
// developer's real credentials/profile instead of the fake ones below.
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
tests := []struct {
name string
args []string
wantURL string
wantResourceType string
wantNeedNotification string
wantMemberID string
wantMemberType string
wantPerm string
wantMemberKind string
wantPermType string
wantBatch bool
}{
{
name: "URL input auto-infers docx type",
args: []string{
"drive", "+member-add",
"--token", "https://example.feishu.cn/docx/doxcnE2E001?from=share",
"--member-id", "ou_e2e_user",
"--member-type", "openid",
"--perm", "view",
"--need-notification=false",
"--dry-run",
},
wantURL: "/open-apis/drive/v1/permissions/doxcnE2E001/members",
wantResourceType: "docx",
wantNeedNotification: "false",
wantMemberID: "ou_e2e_user",
wantMemberType: "openid",
wantPerm: "view",
wantMemberKind: "user",
},
{
name: "URL input auto-infers mindnote type from mindnotes path",
args: []string{
"drive", "+member-add",
"--token", "https://example.feishu.cn/mindnotes/mndE2E011?from=share",
"--member-id", "ou_e2e_user",
"--member-type", "openid",
"--perm", "view",
"--dry-run",
},
wantURL: "/open-apis/drive/v1/permissions/mndE2E011/members",
wantResourceType: "mindnote",
wantMemberID: "ou_e2e_user",
wantMemberType: "openid",
wantPerm: "view",
wantMemberKind: "user",
},
{
name: "bare token with explicit wiki type defaults perm_type container",
args: []string{
"drive", "+member-add",
"--token", "wikcnE2E002",
"--type", "wiki",
"--member-id", "ou_e2e_admin",
"--member-type", "openid",
"--perm", "full_access",
"--dry-run",
},
wantURL: "/open-apis/drive/v1/permissions/wikcnE2E002/members",
wantResourceType: "wiki",
wantMemberID: "ou_e2e_admin",
wantMemberType: "openid",
wantPerm: "full_access",
wantMemberKind: "user",
wantPermType: "container",
},
{
// Explicit --type must override URL inference: the /docx/ marker
// would infer type=docx, but the caller asked to grant permission
// against a wiki node. The URL token itself is still used as the
// path token.
name: "explicit --type overrides URL inference",
args: []string{
"drive", "+member-add",
"--token", "https://example.feishu.cn/docx/doxcnE2E009",
"--type", "wiki",
"--member-id", "ou_e2e_override",
"--member-type", "openid",
"--perm", "view",
"--dry-run",
},
wantURL: "/open-apis/drive/v1/permissions/doxcnE2E009/members",
wantResourceType: "wiki",
wantMemberID: "ou_e2e_override",
wantMemberType: "openid",
wantPerm: "view",
wantMemberKind: "user",
wantPermType: "container",
},
{
name: "bare token with explicit folder type",
args: []string{
"drive", "+member-add",
"--token", "fldE2E010",
"--type", "folder",
"--member-id", "ou_e2e_folder",
"--member-type", "openid",
"--perm", "view",
"--dry-run",
},
wantURL: "/open-apis/drive/v1/permissions/fldE2E010/members",
wantResourceType: "folder",
wantMemberID: "ou_e2e_folder",
wantMemberType: "openid",
wantPerm: "view",
wantMemberKind: "user",
},
{
name: "email member-type",
args: []string{
"drive", "+member-add",
"--token", "shtcnE2E003",
"--type", "sheet",
"--member-id", "user@example.com",
"--member-type", "email",
"--perm", "edit",
"--dry-run",
},
wantURL: "/open-apis/drive/v1/permissions/shtcnE2E003/members",
wantResourceType: "sheet",
wantMemberID: "user@example.com",
wantMemberType: "email",
wantPerm: "edit",
wantMemberKind: "user",
},
{
name: "unionid member-type",
args: []string{
"drive", "+member-add",
"--token", "doxcnE2E006",
"--type", "docx",
"--member-id", "on_e2e_union",
"--member-type", "unionid",
"--perm", "view",
"--dry-run",
},
wantURL: "/open-apis/drive/v1/permissions/doxcnE2E006/members",
wantResourceType: "docx",
wantMemberID: "on_e2e_union",
wantMemberType: "unionid",
wantPerm: "view",
wantMemberKind: "user",
},
{
name: "explicit-only groupid member-type",
args: []string{
"drive", "+member-add",
"--token", "doxcnE2E007",
"--type", "docx",
"--member-id", "group_e2e",
"--member-type", "groupid",
"--perm", "view",
"--dry-run",
},
wantURL: "/open-apis/drive/v1/permissions/doxcnE2E007/members",
wantResourceType: "docx",
wantMemberID: "group_e2e",
wantMemberType: "groupid",
wantPerm: "view",
wantMemberKind: "group",
},
{
name: "batch members use batch_create endpoint",
args: []string{
"drive", "+member-add",
"--token", "bascnE2E004",
"--type", "bitable",
"--member-id", "ou_a,ou_b",
"--member-type", "openid",
"--perm", "view",
"--dry-run",
},
wantURL: "/open-apis/drive/v1/permissions/bascnE2E004/members/batch_create",
wantResourceType: "bitable",
wantMemberID: "ou_a",
wantMemberType: "openid",
wantPerm: "view",
wantMemberKind: "user",
wantBatch: true,
},
{
name: "explicit groupid member-type",
args: []string{
"drive", "+member-add",
"--token", "doxcnE2E005",
"--type", "docx",
"--member-id", "grp_abc",
"--member-type", "groupid",
"--perm", "edit",
"--dry-run",
},
wantURL: "/open-apis/drive/v1/permissions/doxcnE2E005/members",
wantResourceType: "docx",
wantMemberID: "grp_abc",
wantMemberType: "groupid",
wantPerm: "edit",
wantMemberKind: "group",
},
{
name: "appid member-type is passed through",
args: []string{
"drive", "+member-add",
"--token", "doxcnE2E008",
"--type", "docx",
"--member-id", "cli_app_123",
"--member-type", "appid",
"--perm", "view",
"--dry-run",
},
wantURL: "/open-apis/drive/v1/permissions/doxcnE2E008/members",
wantResourceType: "docx",
wantMemberID: "cli_app_123",
wantMemberType: "appid",
wantPerm: "view",
},
{
name: "wikispaceid member-type requires wiki-space body type",
args: []string{
"drive", "+member-add",
"--token", "doxcnE2E012",
"--type", "docx",
"--member-id", "spc_e2e_wiki",
"--member-type", "wikispaceid",
"--member-kind", "wiki_space_editor",
"--perm", "view",
"--dry-run",
},
wantURL: "/open-apis/drive/v1/permissions/doxcnE2E012/members",
wantResourceType: "docx",
wantMemberID: "spc_e2e_wiki",
wantMemberType: "wikispaceid",
wantPerm: "view",
wantMemberKind: "wiki_space_editor",
},
}
for _, temp := range tests {
tt := temp
t.Run(tt.name, func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: tt.args,
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "api.0.method").String(); got != "POST" {
t.Fatalf("method = %q, want POST\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.url").String(); got != tt.wantURL {
t.Fatalf("url = %q, want %q\nstdout:\n%s", got, tt.wantURL, out)
}
if got := gjson.Get(out, "api.0.params.type").String(); got != tt.wantResourceType {
t.Fatalf("params.type = %q, want %q\nstdout:\n%s", got, tt.wantResourceType, out)
}
notification := gjson.Get(out, "api.0.params.need_notification")
if tt.wantNeedNotification == "" {
if notification.Exists() {
t.Fatalf("need_notification should be omitted\nstdout:\n%s", out)
}
} else if got := notification.String(); got != tt.wantNeedNotification {
t.Fatalf("need_notification = %q, want %q\nstdout:\n%s", got, tt.wantNeedNotification, out)
}
bodyPath := "api.0.body"
if tt.wantBatch {
bodyPath = "api.0.body.members.0"
if count := len(gjson.Get(out, "api.0.body.members").Array()); count != 2 {
t.Fatalf("body.members count = %d, want 2\nstdout:\n%s", count, out)
}
}
if got := gjson.Get(out, bodyPath+".member_id").String(); got != tt.wantMemberID {
t.Fatalf("body.member_id = %q, want %q\nstdout:\n%s", got, tt.wantMemberID, out)
}
if got := gjson.Get(out, bodyPath+".member_type").String(); got != tt.wantMemberType {
t.Fatalf("body.member_type = %q, want %q\nstdout:\n%s", got, tt.wantMemberType, out)
}
if got := gjson.Get(out, bodyPath+".perm").String(); got != tt.wantPerm {
t.Fatalf("body.perm = %q, want %q\nstdout:\n%s", got, tt.wantPerm, out)
}
if got := gjson.Get(out, bodyPath+".type").String(); got != tt.wantMemberKind {
t.Fatalf("body.type = %q, want %q\nstdout:\n%s", got, tt.wantMemberKind, out)
}
permType := gjson.Get(out, bodyPath+".perm_type")
if tt.wantPermType == "" {
if permType.Exists() {
t.Fatalf("perm_type should be omitted\nstdout:\n%s", out)
}
} else if got := permType.String(); got != tt.wantPermType {
t.Fatalf("body.perm_type = %q, want %q\nstdout:\n%s", got, tt.wantPermType, out)
}
})
}
}
func TestDrive_MemberAddDryRunRejectsInvalidInputs(t *testing.T) {
// Isolate from any local CLI state: the subprocess inherits the parent
// test environment, and without an explicit config dir it could read a
// developer's real credentials/profile instead of the fake ones below.
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
tests := []struct {
name string
args []string
defaultAs string
wantErr string
}{
{
name: "any host accepted",
args: []string{
"drive", "+member-add",
"--token", "https://google.com/docx/doxcnE2E001",
"--member-id", "ou_e2e_user",
"--member-type", "openid",
"--dry-run",
},
},
{
name: "unsupported URL path",
args: []string{
"drive", "+member-add",
"--token", "https://example.feishu.cn/calendar/calE2E001",
"--member-id", "ou_e2e_user",
"--member-type", "openid",
"--dry-run",
},
wantErr: "unsupported URL path",
},
{
name: "bare token requires explicit type",
args: []string{
"drive", "+member-add",
"--token", "unknownE2E001",
"--member-id", "ou_e2e_user",
"--member-type", "openid",
"--dry-run",
},
wantErr: "--type is required when --token is a bare token",
},
{
name: "duplicate member IDs are rejected",
args: []string{
"drive", "+member-add",
"--token", "doxcnE2E001",
"--type", "docx",
"--member-id", "ou_a,ou_b,ou_a",
"--member-type", "openid",
"--dry-run",
},
wantErr: "duplicate collaborator ID",
},
{
name: "invalid explicit type is rejected",
args: []string{
"drive", "+member-add",
"--token", "mincnE2E001",
"--type", "invalidtype",
"--member-id", "ou_e2e_user",
"--member-type", "openid",
"--dry-run",
},
wantErr: "--type must be one of: docx, doc, sheet, bitable, file, folder, wiki, mindnote, slides, minutes",
},
{
name: "member-id prefix conflicts with explicit member-type",
args: []string{
"drive", "+member-add",
"--token", "doxcnE2E001",
"--type", "docx",
"--member-id", "ou_e2e_user,oc_e2e_chat",
"--member-type", "openid",
"--dry-run",
},
wantErr: "implies --member-type openchat",
},
{
name: "explicit member-type conflicts with member-id prefix",
args: []string{
"drive", "+member-add",
"--token", "doxcnE2E001",
"--type", "docx",
"--member-id", "oc_e2e_chat",
"--member-type", "openid",
"--dry-run",
},
wantErr: "implies --member-type openchat",
},
{
name: "department collaborator requires user identity",
args: []string{
"drive", "+member-add",
"--token", "doxcnE2E001",
"--type", "docx",
"--member-id", "od_e2e_dept",
"--member-type", "opendepartmentid",
"--dry-run",
},
defaultAs: "bot",
wantErr: "--member-type=opendepartmentid requires --as user",
},
{
name: "wikispaceid requires member-kind",
args: []string{
"drive", "+member-add",
"--token", "doxcnE2E001",
"--type", "docx",
"--member-id", "spc_e2e_wiki",
"--member-type", "wikispaceid",
"--dry-run",
},
wantErr: "--member-kind is required when --member-type=wikispaceid",
},
{
name: "member-kind only applies to wikispaceid",
args: []string{
"drive", "+member-add",
"--token", "doxcnE2E001",
"--type", "docx",
"--member-id", "ou_e2e_user",
"--member-type", "openid",
"--member-kind", "wiki_space_member",
"--dry-run",
},
wantErr: "--member-kind only applies when --member-type=wikispaceid",
},
}
for _, temp := range tests {
tt := temp
t.Run(tt.name, func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
defaultAs := tt.defaultAs
if defaultAs == "" {
defaultAs = "user"
}
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: tt.args,
DefaultAs: defaultAs,
})
require.NoError(t, err)
if tt.wantErr == "" {
result.AssertExitCode(t, 0)
return
}
result.AssertExitCode(t, 2)
output := result.Stdout + result.Stderr
require.Contains(t, output, tt.wantErr, "stdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
})
}
}
@@ -0,0 +1,146 @@
// 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"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestDrivePreviewDryRun_ListOnly verifies preview dry-run request structure
// for list mode.
func TestDrivePreviewDryRun_ListOnly(t *testing.T) {
setDriveDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+preview",
"--file-token", "fileDryRunPreview",
"--list-only",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "api.0.method").String(); got != "POST" {
t.Fatalf("method=%q, want POST\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/drive/v1/medias/fileDryRunPreview/preview_result" {
t.Fatalf("url=%q, want preview_result endpoint\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "mode").String(); got != "list" {
t.Fatalf("mode=%q, want list\nstdout:\n%s", got, out)
}
}
// TestDrivePreviewDryRun_Download verifies preview dry-run request structure
// for download mode.
func TestDrivePreviewDryRun_Download(t *testing.T) {
setDriveDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+preview",
"--file-token", "fileDryRunPreview",
"--type", "pdf",
"--version", "12",
"--output", "./artifacts/report",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "api.#").Int(); got != 2 {
t.Fatalf("api count=%d, want 2\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.body.version").String(); got != "12" {
t.Fatalf("version=%q, want 12\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.1.method").String(); got != "GET" {
t.Fatalf("download method=%q, want GET\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.1.url").String(); got != "/open-apis/drive/v1/medias/fileDryRunPreview/preview_download" {
t.Fatalf("download url=%q, want preview_download endpoint\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.1.params.preview_type").String(); got != "<selected type_code from preview_result>" {
t.Fatalf("preview_type=%q, want placeholder\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.1.params.version").String(); got != "12" {
t.Fatalf("download version=%q, want 12\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "requested_type").String(); got != "pdf" {
t.Fatalf("requested_type=%q, want pdf\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "output").String(); got != "./artifacts/report" {
t.Fatalf("output=%q, want ./artifacts/report\nstdout:\n%s", got, out)
}
}
// TestDriveCoverDryRun_Download verifies cover dry-run request structure for
// download mode.
func TestDriveCoverDryRun_Download(t *testing.T) {
setDriveDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+cover",
"--file-token", "fileDryRunCover",
"--spec", "square",
"--output", "./artifacts/cover",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "api.0.method").String(); got != "GET" {
t.Fatalf("method=%q, want GET\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/drive/v1/medias/fileDryRunCover/preview_download" {
t.Fatalf("url=%q, want preview_download endpoint\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.params.preview_type").String(); got != "1" {
t.Fatalf("preview_type=%q, want 1\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.params.bus_type").Exists(); got {
t.Fatalf("bus_type should be omitted for square crop flow\nstdout:\n%s", out)
}
if got := gjson.Get(out, "api.0.params.platform").Exists(); got {
t.Fatalf("platform should be omitted when using default platform\nstdout:\n%s", out)
}
if got := gjson.Get(out, "api.0.params.width").String(); got != "360" {
t.Fatalf("width=%q, want 360\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.params.height").String(); got != "360" {
t.Fatalf("height=%q, want 360\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.params.policy").String(); got != "near" {
t.Fatalf("policy=%q, want near\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "selected_spec").String(); got != "square" {
t.Fatalf("selected_spec=%q, want square\nstdout:\n%s", got, out)
}
}
@@ -0,0 +1,258 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestDrive_PreviewAndCoverWorkflow verifies preview and cover shortcuts against
// a live Drive workflow, skipping when required bot scopes are unavailable.
func TestDrive_PreviewAndCoverWorkflow(t *testing.T) {
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute)
t.Cleanup(cancel)
suffix := clie2e.GenerateSuffix()
folderName := "lark-cli-e2e-drive-preview-" + suffix
folderToken := createDriveFolderOrSkipPermission(t, parentT, ctx, folderName)
workDir := t.TempDir()
sourceRelPath := "fixture/report.txt"
sourceContent := "drive preview and cover workflow\n"
writePreviewFixture(t, workDir, sourceRelPath, sourceContent)
fileToken := uploadPreviewFixture(t, parentT, ctx, workDir, folderToken, sourceRelPath, "report.txt")
t.Run("preview list and download", func(t *testing.T) {
listResult, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{
"drive", "+preview",
"--file-token", fileToken,
"--list-only",
},
DefaultAs: "bot",
}, clie2e.RetryOptions{
Attempts: 8,
InitialDelay: 2 * time.Second,
MaxDelay: 8 * time.Second,
BackoffMultiple: 2,
ShouldRetry: func(result *clie2e.Result) bool {
if result == nil || result.ExitCode != 0 {
return true
}
return !previewListContainsReadyType(result.Stdout, "text")
},
})
require.NoError(t, err)
listResult.AssertExitCode(t, 0)
listResult.AssertStdoutStatus(t, true)
if !previewListContainsReadyType(listResult.Stdout, "text") {
t.Fatalf("preview list did not expose downloadable text preview\nstdout:\n%s", listResult.Stdout)
}
downloadDir := t.TempDir()
downloadResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+preview",
"--file-token", fileToken,
"--type", "text",
"--output", "./artifacts/report-preview",
},
WorkDir: downloadDir,
DefaultAs: "bot",
})
require.NoError(t, err)
downloadResult.AssertExitCode(t, 0)
downloadResult.AssertStdoutStatus(t, true)
stdout := downloadResult.Stdout
if got := gjson.Get(stdout, "data.selected_type").String(); got != "text" {
t.Fatalf("selected_type=%q, want text\nstdout:\n%s", got, stdout)
}
outputPath := gjson.Get(stdout, "data.output_path").String()
require.NotEmpty(t, outputPath, "preview download should return output_path")
if ext := filepath.Ext(outputPath); ext != ".txt" {
t.Fatalf("preview output extension=%q, want .txt\nstdout:\n%s", ext, stdout)
}
data, readErr := os.ReadFile(outputPath)
require.NoError(t, readErr)
if !strings.Contains(string(data), "drive preview and cover workflow") {
t.Fatalf("preview artifact content mismatch: %q", string(data))
}
})
t.Run("cover list and download", func(t *testing.T) {
listResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+cover",
"--file-token", fileToken,
"--list-only",
},
DefaultAs: "bot",
})
require.NoError(t, err)
listResult.AssertExitCode(t, 0)
listResult.AssertStdoutStatus(t, true)
if !gjson.Get(listResult.Stdout, `data.candidates.#(spec=="default")`).Exists() {
t.Fatalf("cover list missing default spec\nstdout:\n%s", listResult.Stdout)
}
downloadDir := t.TempDir()
coverResult, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{
"drive", "+cover",
"--file-token", fileToken,
"--spec", "default",
"--output", "./artifacts/report-cover",
},
WorkDir: downloadDir,
DefaultAs: "bot",
}, clie2e.RetryOptions{
Attempts: 8,
InitialDelay: 2 * time.Second,
MaxDelay: 8 * time.Second,
BackoffMultiple: 2,
ShouldRetry: func(result *clie2e.Result) bool {
if result == nil {
return true
}
if result.ExitCode == 0 {
return false
}
return false
},
})
require.NoError(t, err)
coverResult.AssertExitCode(t, 0)
coverResult.AssertStdoutStatus(t, true)
stdout := coverResult.Stdout
if got := gjson.Get(stdout, "data.selected_spec").String(); got != "default" {
t.Fatalf("selected_spec=%q, want default\nstdout:\n%s", got, stdout)
}
outputPath := gjson.Get(stdout, "data.output_path").String()
require.NotEmpty(t, outputPath, "cover download should return output_path")
if ext := filepath.Ext(outputPath); ext != ".png" && ext != ".jpg" && ext != ".jpeg" && ext != ".webp" {
t.Fatalf("cover output extension=%q, want image extension\nstdout:\n%s", ext, stdout)
}
info, statErr := os.Stat(outputPath)
require.NoError(t, statErr)
if info.Size() <= 0 {
t.Fatalf("cover artifact should not be empty: %s", outputPath)
}
})
}
// writePreviewFixture writes a local fixture file used by the live workflow.
func writePreviewFixture(t *testing.T, workDir, relPath, content string) {
t.Helper()
fullPath := filepath.Join(workDir, relPath)
if err := os.MkdirAll(filepath.Dir(fullPath), 0o755); err != nil {
t.Fatalf("mkdir fixture parent: %v", err)
}
if err := os.WriteFile(fullPath, []byte(content), 0o644); err != nil {
t.Fatalf("write fixture: %v", err)
}
}
// uploadPreviewFixture uploads a fixture into Drive and registers cleanup for
// the created file token.
func uploadPreviewFixture(t *testing.T, parentT *testing.T, ctx context.Context, workDir, folderToken, relPath, uploadName string) string {
t.Helper()
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+upload",
"--file", relPath,
"--folder-token", folderToken,
"--name", uploadName,
},
WorkDir: workDir,
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
fileToken := gjson.Get(result.Stdout, "data.file_token").String()
require.NotEmpty(t, fileToken, "uploaded file should have a token")
parentT.Cleanup(func() {
cleanupCtx, cleanupCancel := clie2e.CleanupContext()
defer cleanupCancel()
deleteResult, deleteErr := clie2e.RunCmdWithRetry(cleanupCtx, clie2e.Request{
Args: []string{"drive", "+delete", "--file-token", fileToken, "--type", "file", "--yes"},
DefaultAs: "bot",
}, clie2e.RetryOptions{})
clie2e.ReportCleanupFailure(parentT, "delete drive file "+fileToken, deleteResult, deleteErr)
})
return fileToken
}
// previewListContainsReadyType reports whether a preview list response contains
// a downloadable candidate for the requested type.
func previewListContainsReadyType(stdout, wantType string) bool {
for _, candidate := range gjson.Get(stdout, "data.candidates").Array() {
if candidate.Get("type").String() != wantType {
continue
}
if candidate.Get("downloadable").Bool() {
return true
}
}
return false
}
// createDriveFolderOrSkipPermission creates a Drive folder for the live
// workflow and skips when the bot lacks required folder scopes.
func createDriveFolderOrSkipPermission(t *testing.T, parentT *testing.T, ctx context.Context, name string) string {
t.Helper()
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"drive", "+create-folder", "--name", name},
DefaultAs: "bot",
})
require.NoError(t, err)
if result.ExitCode != 0 {
combinedOutput := strings.ToLower(result.Stdout + "\n" + result.Stderr)
if strings.Contains(combinedOutput, "app scope not enabled") ||
strings.Contains(combinedOutput, "space:folder:create") ||
strings.Contains(combinedOutput, "99991672") {
t.Skipf("skip drive preview/cover workflow due to missing bot scope space:folder:create: %s", strings.TrimSpace(result.Stdout+"\n"+result.Stderr))
}
}
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
folderToken := gjson.Get(result.Stdout, "data.folder_token").String()
require.NotEmpty(t, folderToken, "drive folder token should not be empty")
parentT.Cleanup(func() {
cleanupCtx, cancel := clie2e.CleanupContext()
defer cancel()
deleteResult, deleteErr := clie2e.RunCmdWithRetry(cleanupCtx, clie2e.Request{
Args: []string{"drive", "+delete", "--file-token", folderToken, "--type", "folder", "--yes"},
DefaultAs: "bot",
}, clie2e.RetryOptions{})
clie2e.ReportCleanupFailure(parentT, "delete drive folder "+folderToken, deleteResult, deleteErr)
})
return folderToken
}
@@ -0,0 +1,251 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestDrive_PullDryRun locks in the request shape the +pull shortcut emits
// under --dry-run: the real CLI binary is invoked end-to-end, so flag
// parsing, Validate (still runs in dry-run mode), and the dry-run renderer
// all execute. The printed envelope is then inspected for GET method,
// list-files URL, the folder_token parameter, and key phrases from Desc.
//
// Fake credentials are sufficient because --dry-run short-circuits before
// any real network call.
func TestDrive_PullDryRun(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
workDir := t.TempDir()
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+pull",
"--local-dir", "local",
"--folder-token", "fldcnE2E001",
"--dry-run",
},
WorkDir: workDir,
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "api.0.method").String(); got != "GET" {
t.Fatalf("method = %q, want GET\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/drive/v1/files" {
t.Fatalf("url = %q, want /open-apis/drive/v1/files\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "folder_token").String(); got != "fldcnE2E001" {
t.Fatalf("folder_token = %q, want fldcnE2E001\nstdout:\n%s", got, out)
}
desc := gjson.Get(out, "description").String()
if !strings.Contains(desc, "list --folder-token") {
t.Fatalf("description missing list phrase, got %q\nstdout:\n%s", desc, out)
}
}
// TestDrive_PullDryRunRejectsAbsoluteLocalDir confirms the path validator
// runs in the real binary's Validate stage and surfaces a structured error
// referencing --local-dir.
func TestDrive_PullDryRunRejectsAbsoluteLocalDir(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+pull",
"--local-dir", "/etc",
"--folder-token", "fldcnE2E001",
"--dry-run",
},
WorkDir: t.TempDir(),
DefaultAs: "user",
})
require.NoError(t, err)
if result.ExitCode == 0 {
t.Fatalf("absolute --local-dir must be rejected, got exit=0\nstdout:\n%s", result.Stdout)
}
combined := result.Stdout + "\n" + result.Stderr
if !strings.Contains(combined, "--local-dir") {
t.Fatalf("expected --local-dir in error, got:\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
}
}
// TestDrive_PullDryRunRejectsDeleteLocalWithoutYes locks in the safety
// guard: --delete-local without --yes must be refused upfront, even under
// --dry-run, so an unintended delete flag never silently slides through.
func TestDrive_PullDryRunRejectsDeleteLocalWithoutYes(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
workDir := t.TempDir()
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+pull",
"--local-dir", "local",
"--folder-token", "fldcnE2E001",
"--delete-local",
"--dry-run",
},
WorkDir: workDir,
DefaultAs: "user",
})
require.NoError(t, err)
if result.ExitCode == 0 {
t.Fatalf("--delete-local without --yes must be rejected, got exit=0\nstdout:\n%s", result.Stdout)
}
combined := result.Stdout + "\n" + result.Stderr
if !strings.Contains(combined, "--yes") {
t.Fatalf("expected --yes hint in error, got:\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
}
}
// TestDrive_PullDryRunRejectsMissingFolderToken confirms cobra's
// required-flag enforcement runs before our custom Validate.
func TestDrive_PullDryRunRejectsMissingFolderToken(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
workDir := t.TempDir()
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+pull",
"--local-dir", "local",
"--dry-run",
},
WorkDir: workDir,
DefaultAs: "user",
})
require.NoError(t, err)
if result.ExitCode == 0 {
t.Fatalf("missing --folder-token must be rejected, got exit=0\nstdout:\n%s", result.Stdout)
}
combined := result.Stdout + "\n" + result.Stderr
if !strings.Contains(combined, "folder-token") {
t.Fatalf("expected folder-token in error, got:\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
}
}
func TestDrive_PullDryRunAcceptsDuplicateRemoteStrategies(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
for _, strategy := range []string{"rename", "newest", "oldest"} {
t.Run(strategy, func(t *testing.T) {
workDir := t.TempDir()
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+pull",
"--local-dir", "local",
"--folder-token", "fldcnE2E001",
"--on-duplicate-remote", strategy,
"--dry-run",
},
WorkDir: workDir,
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "api.0.method").String(); got != "GET" {
t.Fatalf("method = %q, want GET\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "folder_token").String(); got != "fldcnE2E001" {
t.Fatalf("folder_token = %q, want fldcnE2E001\nstdout:\n%s", got, out)
}
})
}
}
func TestDrive_PullDryRunAcceptsIfExistsSmart(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
workDir := t.TempDir()
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+pull",
"--local-dir", "local",
"--folder-token", "fldcnE2E001",
"--if-exists", "smart",
"--dry-run",
},
WorkDir: workDir,
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "api.0.method").String(); got != "GET" {
t.Fatalf("method = %q, want GET\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "folder_token").String(); got != "fldcnE2E001" {
t.Fatalf("folder_token = %q, want fldcnE2E001\nstdout:\n%s", got, out)
}
}
@@ -0,0 +1,317 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestDrive_PushDryRun locks in the request shape the +push shortcut emits
// under --dry-run: the real CLI binary is invoked end-to-end, so flag
// parsing, Validate (still runs in dry-run mode), and the dry-run renderer
// all execute. The printed envelope is then inspected for GET method,
// list-files URL, the folder_token parameter, and key phrases from Desc.
//
// Fake credentials are sufficient because --dry-run short-circuits before
// any real network call.
func TestDrive_PushDryRun(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
workDir := t.TempDir()
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+push",
"--local-dir", "local",
"--folder-token", "fldcnE2E001",
"--dry-run",
},
WorkDir: workDir,
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "api.0.method").String(); got != "GET" {
t.Fatalf("method = %q, want GET\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/drive/v1/files" {
t.Fatalf("url = %q, want /open-apis/drive/v1/files\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "folder_token").String(); got != "fldcnE2E001" {
t.Fatalf("folder_token = %q, want fldcnE2E001\nstdout:\n%s", got, out)
}
desc := gjson.Get(out, "description").String()
if !strings.Contains(desc, "list --folder-token") {
t.Fatalf("description missing list phrase, got %q\nstdout:\n%s", desc, out)
}
if !strings.Contains(desc, "upload") {
t.Fatalf("description missing upload phrase, got %q\nstdout:\n%s", desc, out)
}
}
// TestDrive_PushDryRunRejectsAbsoluteLocalDir confirms the path validator
// runs in the real binary's Validate stage and surfaces a structured error
// referencing --local-dir.
func TestDrive_PushDryRunRejectsAbsoluteLocalDir(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+push",
"--local-dir", "/etc",
"--folder-token", "fldcnE2E001",
"--dry-run",
},
WorkDir: t.TempDir(),
DefaultAs: "user",
})
require.NoError(t, err)
// Validate-stage rejection emits ExitValidation (2). A regression
// that reclassified this as a generic api_error (1) or success (0)
// would slip through a loose `!= 0` check, so assert the exact code.
if result.ExitCode != 2 {
t.Fatalf("absolute --local-dir must be rejected with exit=2 (Validate), got exit=%d\nstdout:\n%s\nstderr:\n%s", result.ExitCode, result.Stdout, result.Stderr)
}
combined := result.Stdout + "\n" + result.Stderr
if !strings.Contains(combined, "--local-dir") {
t.Fatalf("expected --local-dir in error, got:\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
}
}
// TestDrive_PushDryRunRejectsDeleteRemoteWithoutYes locks in the safety
// guard: --delete-remote without --yes must be refused upfront, even
// under --dry-run, so an unintended delete flag never silently slides
// through.
func TestDrive_PushDryRunRejectsDeleteRemoteWithoutYes(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
workDir := t.TempDir()
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+push",
"--local-dir", "local",
"--folder-token", "fldcnE2E001",
"--delete-remote",
"--dry-run",
},
WorkDir: workDir,
DefaultAs: "user",
})
require.NoError(t, err)
// Same exact-code reasoning as the absolute-path test: this is a
// Validate-stage rejection so it must surface as ExitValidation (2).
if result.ExitCode != 2 {
t.Fatalf("--delete-remote without --yes must be rejected with exit=2 (Validate), got exit=%d\nstdout:\n%s\nstderr:\n%s", result.ExitCode, result.Stdout, result.Stderr)
}
combined := result.Stdout + "\n" + result.Stderr
if !strings.Contains(combined, "--yes") {
t.Fatalf("expected --yes hint in error, got:\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
}
}
// TestDrive_PushDryRunAcceptsDeleteRemoteWithYes is the symmetric guard
// to TestDrive_PushDryRunRejectsDeleteRemoteWithoutYes: when --yes is
// passed alongside --delete-remote, Validate must accept the run and
// hand off to the dry-run renderer.
//
// Specifically pins the conditional scope pre-check added to Validate:
// when the resolver has no token / no scope metadata (the e2e setup
// uses fake credentials with no real auth state), runtime.EnsureScopes
// is a silent no-op so dry-run still emits its envelope. A regression
// where the pre-check incorrectly fired against an empty scope list
// would surface here as a non-zero exit and a missing_scope error.
func TestDrive_PushDryRunAcceptsDeleteRemoteWithYes(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
workDir := t.TempDir()
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+push",
"--local-dir", "local",
"--folder-token", "fldcnE2E001",
"--delete-remote",
"--yes",
"--dry-run",
},
WorkDir: workDir,
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "api.0.method").String(); got != "GET" {
t.Fatalf("method = %q, want GET\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "folder_token").String(); got != "fldcnE2E001" {
t.Fatalf("folder_token = %q, want fldcnE2E001\nstdout:\n%s", got, out)
}
// No structured error envelope on stdout/stderr — the conditional
// EnsureScopes call must not trip a missing_scope here.
if strings.Contains(out, `"type": "missing_scope"`) || strings.Contains(result.Stderr, "missing_scope") {
t.Fatalf("conditional scope pre-check fired in a no-credential env\nstdout:\n%s\nstderr:\n%s", out, result.Stderr)
}
}
// TestDrive_PushDryRunRejectsMissingFolderToken confirms cobra's
// required-flag enforcement runs before our custom Validate.
func TestDrive_PushDryRunRejectsMissingFolderToken(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
workDir := t.TempDir()
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+push",
"--local-dir", "local",
"--dry-run",
},
WorkDir: workDir,
DefaultAs: "user",
})
require.NoError(t, err)
// A missing cobra required-flag is routed through the typed validation
// envelope (exit 2, invalid_argument) — the same class as the explicit
// flag/subcommand guards, not cobra's plain-text exit 1.
if result.ExitCode != 2 {
t.Fatalf("missing --folder-token must be rejected with exit=2 (typed validation), got exit=%d\nstdout:\n%s\nstderr:\n%s", result.ExitCode, result.Stdout, result.Stderr)
}
combined := result.Stdout + "\n" + result.Stderr
if !strings.Contains(combined, "folder-token") {
t.Fatalf("expected folder-token in error, got:\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
}
}
func TestDrive_PushDryRunAcceptsDuplicateRemoteStrategies(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
for _, strategy := range []string{"newest", "oldest"} {
t.Run(strategy, func(t *testing.T) {
workDir := t.TempDir()
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+push",
"--local-dir", "local",
"--folder-token", "fldcnE2E001",
"--on-duplicate-remote", strategy,
"--dry-run",
},
WorkDir: workDir,
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "api.0.method").String(); got != "GET" {
t.Fatalf("method = %q, want GET\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "folder_token").String(); got != "fldcnE2E001" {
t.Fatalf("folder_token = %q, want fldcnE2E001\nstdout:\n%s", got, out)
}
})
}
}
func TestDrive_PushDryRunAcceptsIfExistsSmart(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
workDir := t.TempDir()
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+push",
"--local-dir", "local",
"--folder-token", "fldcnE2E001",
"--if-exists", "smart",
"--dry-run",
},
WorkDir: workDir,
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "api.0.method").String(); got != "GET" {
t.Fatalf("method = %q, want GET\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "folder_token").String(); got != "fldcnE2E001" {
t.Fatalf("folder_token = %q, want fldcnE2E001\nstdout:\n%s", got, out)
}
}
@@ -0,0 +1,377 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestDriveSearchDryRun_RequestShape locks in the dry-run request body so
// agents that key off of stdout (URL, doc_filter / wiki_filter, scalar
// filters) don't silently regress. Run end-to-end so cobra flag parsing,
// readDriveSearchSpec, and the dry-run renderer all execute against the
// real binary.
//
// Fake credentials are sufficient because --dry-run short-circuits before
// any network call.
func TestDriveSearchDryRun_RequestShape(t *testing.T) {
setDriveSearchE2EEnv(t)
tests := []struct {
name string
args []string
// JSONPath assertions over the dry-run body.
wantURL string
wantQuery string
wantDocFilter bool
wantWikiFilter bool
wantDocFilterFields map[string]string // gjson path under api.0.body.doc_filter -> string value (or "" to require existence only)
wantWikiFilterFields map[string]string
}{
{
name: "basic --query emits both filters",
args: []string{
"drive", "+search",
"--query", "season report",
"--page-size", "5",
"--dry-run",
},
wantURL: "/open-apis/search/v2/doc_wiki/search",
wantQuery: "season report",
wantDocFilter: true,
wantWikiFilter: true,
},
{
name: "--folder-tokens scopes to doc_filter only",
args: []string{
"drive", "+search",
"--query", "x",
"--folder-tokens", "fld_aaa,fld_bbb",
"--dry-run",
},
wantURL: "/open-apis/search/v2/doc_wiki/search",
wantQuery: "x",
wantDocFilter: true,
wantDocFilterFields: map[string]string{
"folder_tokens.0": "fld_aaa",
"folder_tokens.1": "fld_bbb",
},
},
{
name: "--space-ids scopes to wiki_filter only",
args: []string{
"drive", "+search",
"--query", "x",
"--space-ids", "sp_xxx",
"--dry-run",
},
wantURL: "/open-apis/search/v2/doc_wiki/search",
wantQuery: "x",
wantWikiFilter: true,
wantWikiFilterFields: map[string]string{
"space_ids.0": "sp_xxx",
},
},
{
name: "--sort default maps to DEFAULT_TYPE in body",
args: []string{
"drive", "+search",
"--query", "x",
"--sort", "default",
"--dry-run",
},
wantURL: "/open-apis/search/v2/doc_wiki/search",
wantQuery: "x",
wantDocFilter: true,
wantWikiFilter: true,
wantDocFilterFields: map[string]string{
"sort_type": "DEFAULT_TYPE",
},
},
{
name: "mixed-case --doc-types is normalized to upper case in body",
args: []string{
"drive", "+search",
"--query", "x",
"--doc-types", "docx,Sheet,BITABLE",
"--dry-run",
},
wantURL: "/open-apis/search/v2/doc_wiki/search",
wantQuery: "x",
wantDocFilter: true,
wantWikiFilter: true,
wantDocFilterFields: map[string]string{
"doc_types.0": "DOCX",
"doc_types.1": "SHEET",
"doc_types.2": "BITABLE",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: tt.args,
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "api.0.method").String(); got != "POST" {
t.Fatalf("method=%q, want POST\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.url").String(); got != tt.wantURL {
t.Fatalf("url=%q, want %q\nstdout:\n%s", got, tt.wantURL, out)
}
if got := gjson.Get(out, "api.0.body.query").String(); got != tt.wantQuery {
t.Fatalf("body.query=%q, want %q\nstdout:\n%s", got, tt.wantQuery, out)
}
if tt.wantDocFilter && !gjson.Get(out, "api.0.body.doc_filter").Exists() {
t.Fatalf("doc_filter missing\nstdout:\n%s", out)
}
if !tt.wantDocFilter && gjson.Get(out, "api.0.body.doc_filter").Exists() {
t.Fatalf("doc_filter should be omitted\nstdout:\n%s", out)
}
if tt.wantWikiFilter && !gjson.Get(out, "api.0.body.wiki_filter").Exists() {
t.Fatalf("wiki_filter missing\nstdout:\n%s", out)
}
if !tt.wantWikiFilter && gjson.Get(out, "api.0.body.wiki_filter").Exists() {
t.Fatalf("wiki_filter should be omitted\nstdout:\n%s", out)
}
for path, want := range tt.wantDocFilterFields {
if got := gjson.Get(out, "api.0.body.doc_filter."+path).String(); got != want {
t.Fatalf("doc_filter.%s=%q, want %q\nstdout:\n%s", path, got, want, out)
}
}
for path, want := range tt.wantWikiFilterFields {
if got := gjson.Get(out, "api.0.body.wiki_filter."+path).String(); got != want {
t.Fatalf("wiki_filter.%s=%q, want %q\nstdout:\n%s", path, got, want, out)
}
}
})
}
}
func TestDriveSearchDryRun_BotIdentity(t *testing.T) {
setDriveSearchE2EEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+search",
"--query", "season report",
"--page-size", "5",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
require.Contains(t, result.Args, "--as")
require.Contains(t, result.Args, "bot")
out := result.Stdout
if got := gjson.Get(out, "api.0.method").String(); got != "POST" {
t.Fatalf("method=%q, want POST\nstdout:\n%s\nstderr:\n%s", got, out, result.Stderr)
}
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/search/v2/doc_wiki/search" {
t.Fatalf("url=%q, want Search v2 doc_wiki/search\nstdout:\n%s\nstderr:\n%s", got, out, result.Stderr)
}
if got := gjson.Get(out, "api.0.body.query").String(); got != "season report" {
t.Fatalf("body.query=%q, want season report\nstdout:\n%s", got, out)
}
helpResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"drive", "+search", "--help"},
})
require.NoError(t, err)
helpResult.AssertExitCode(t, 0)
require.Contains(t, helpResult.Stdout, "identity type: user | bot")
}
// TestDriveSearchDryRun_OpenedClamping locks in the agent-facing slice
// notice for --opened-* spans over 90 days: the request body must carry
// the most recent 90-day window, and stderr must list slice N's flag
// values verbatim so the agent can re-invoke for older ranges.
func TestDriveSearchDryRun_OpenedClamping(t *testing.T) {
setDriveSearchE2EEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+search",
"--query", "x",
"--opened-since", "8m",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
// Notice goes to stderr alongside other dimension notices.
for _, want := range []string{
"--opened-* window spans",
"3 slices total",
"[slice 1/3 current]",
"[slice 2/3]",
"[slice 3/3]",
"--page-token",
} {
if !strings.Contains(result.Stderr, want) {
t.Fatalf("notice missing %q\nstderr:\n%s", want, result.Stderr)
}
}
// Slice 1 specifically must spell out concrete --opened-* flag values
// (not just the timestamps in arrow form): an agent paginating slice 1
// has to copy these verbatim, otherwise reusing the original relative
// time '8m' would drift the window against time.Now() and mismatch the
// page_token.
for _, label := range []string{"[slice 1/3 current]", "[slice 2/3]", "[slice 3/3]"} {
var line string
for _, l := range strings.Split(result.Stderr, "\n") {
if strings.Contains(l, label) {
line = l
break
}
}
if !strings.Contains(line, "--opened-since ") || !strings.Contains(line, "--opened-until ") {
t.Fatalf("%s line must spell out both flags, got %q\nfull stderr:\n%s", label, line, result.Stderr)
}
}
// And the request body's open_time must reflect the clamped window
// (start and end both present, span = 90 days exactly).
body := result.Stdout
start := gjson.Get(body, "api.0.body.doc_filter.open_time.start").Int()
end := gjson.Get(body, "api.0.body.doc_filter.open_time.end").Int()
if start == 0 || end == 0 {
t.Fatalf("doc_filter.open_time.start/end missing\nstdout:\n%s", body)
}
if delta := end - start; delta != 90*86400 {
t.Fatalf("clamped span = %d seconds, want %d (90 days)\nstdout:\n%s", delta, 90*86400, body)
}
}
// TestDriveSearchDryRun_RejectsOpenedOver1Year locks in the hard cap: a
// --opened-* span beyond 365 days fails validation up front and never
// reaches the API. Important because the alternative (silent slicing into
// many windows) would produce a rate-limit / runaway request loop.
//
// Dry-run captures spec-level validation errors into the JSON envelope's
// `error` field (api list comes back empty); the process still exits 0
// because the dry-run itself succeeded — it just told you what would have
// failed at execution time.
func TestDriveSearchDryRun_RejectsOpenedOver1Year(t *testing.T) {
setDriveSearchE2EEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+search",
"--query", "x",
"--opened-since", "2y",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
if api := gjson.Get(result.Stdout, "api"); api.IsArray() && len(api.Array()) > 0 {
t.Fatalf("dry-run api list must be empty when validation fails\nstdout:\n%s", result.Stdout)
}
errMsg := gjson.Get(result.Stdout, "error").String()
if !strings.Contains(errMsg, "365-day") {
t.Fatalf("expected 365-day cap message in dry-run error, got %q\nstdout:\n%s", errMsg, result.Stdout)
}
}
// TestDriveSearchDryRun_RejectsInvalidSort locks in the cobra Enum guard.
// CLI intentionally exposes only 5 sort values (default, edit_time,
// edit_time_asc, open_time, create_time); the deprecated /
// not-supported server enum values must be rejected before reaching the
// request layer.
func TestDriveSearchDryRun_RejectsInvalidSort(t *testing.T) {
setDriveSearchE2EEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+search",
"--query", "x",
"--sort", "create_time_asc",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
if result.ExitCode == 0 {
t.Fatalf("invalid sort must be rejected, got exit=0\nstdout:\n%s", result.Stdout)
}
combined := result.Stdout + "\n" + result.Stderr
// Pin to the flag name (with dashes) rather than the bare word "sort",
// which would also match "transport" / "sortable" / etc.
if !strings.Contains(combined, "--sort") {
t.Fatalf("expected --sort error message, got:\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
}
}
// TestDriveSearchDryRun_RejectsBadDocType verifies the doc-types validator
// is wired at the dry-run path: an unknown enum value surfaces as a
// validation error inside the dry-run JSON envelope rather than reaching
// the server. The process still exits 0 (see RejectsOpenedOver1Year).
func TestDriveSearchDryRun_RejectsBadDocType(t *testing.T) {
setDriveSearchE2EEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+search",
"--query", "x",
"--doc-types", "docx,pie",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
if api := gjson.Get(result.Stdout, "api"); api.IsArray() && len(api.Array()) > 0 {
t.Fatalf("dry-run api list must be empty when validation fails\nstdout:\n%s", result.Stdout)
}
errMsg := gjson.Get(result.Stdout, "error").String()
if !strings.Contains(errMsg, "--doc-types") {
t.Fatalf("expected --doc-types error in dry-run, got %q\nstdout:\n%s", errMsg, result.Stdout)
}
}
func setDriveSearchE2EEnv(t *testing.T) {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "drive_search_e2e_app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "drive_search_e2e_secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
}
@@ -0,0 +1,98 @@
// 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"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestDrive_SecureLabelDryRun(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
tests := []struct {
name string
args []string
wantMethod string
wantURL string
assert func(t *testing.T, out string)
}{
{
name: "list available labels",
args: []string{
"drive", "+secure-label-list",
"--page-size", "5",
"--page-token", "page_1",
"--lang", "zh",
"--dry-run",
},
wantMethod: "GET",
wantURL: "/open-apis/drive/v2/my_secure_labels",
assert: func(t *testing.T, out string) {
if got := gjson.Get(out, "api.0.params.page_size").Int(); got != 5 {
t.Fatalf("page_size = %d, want 5\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.params.page_token").String(); got != "page_1" {
t.Fatalf("page_token = %q, want page_1\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.params.lang").String(); got != "zh" {
t.Fatalf("lang = %q, want zh\nstdout:\n%s", got, out)
}
},
},
{
name: "update label with URL inference",
args: []string{
"drive", "+secure-label-update",
"--token", "https://example.feishu.cn/docx/doxcnE2E001?from=share",
"--label-id", "7217780879644737539",
"--dry-run",
},
wantMethod: "PATCH",
wantURL: "/open-apis/drive/v2/files/doxcnE2E001/secure_label",
assert: func(t *testing.T, out string) {
if got := gjson.Get(out, "api.0.params.type").String(); got != "docx" {
t.Fatalf("type = %q, want docx\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.body.id").String(); got != "7217780879644737539" {
t.Fatalf("body.id = %q, want label id\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "file_token").String(); got != "doxcnE2E001" {
t.Fatalf("file_token = %q, want doxcnE2E001\nstdout:\n%s", got, out)
}
},
},
}
for _, temp := range tests {
tt := temp
t.Run(tt.name, func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: tt.args,
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "api.0.method").String(); got != tt.wantMethod {
t.Fatalf("method = %q, want %s\nstdout:\n%s", got, tt.wantMethod, out)
}
if got := gjson.Get(out, "api.0.url").String(); got != tt.wantURL {
t.Fatalf("url = %q, want %q\nstdout:\n%s", got, tt.wantURL, out)
}
tt.assert(t, out)
})
}
}
@@ -0,0 +1,183 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestDrive_StatusDryRun locks in the request shape the +status shortcut
// emits under --dry-run: the real CLI binary is invoked end-to-end, so the
// full flag-parsing, Validate (which still runs in dry-run mode), and the
// dry-run renderer all execute. The printed envelope is then inspected to
// confirm the GET method, list-files URL, and folder_token parameter, plus
// the descriptive text from Desc.
//
// Fake credentials are sufficient because --dry-run short-circuits before
// any network call.
func TestDrive_StatusDryRun(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
// Validate runs even under --dry-run, so we need a real --local-dir
// inside the working directory; create one in a temp tree.
workDir := t.TempDir()
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+status",
"--local-dir", "local",
"--folder-token", "fldcnE2E001",
"--dry-run",
},
WorkDir: workDir,
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "api.0.method").String(); got != "GET" {
t.Fatalf("method = %q, want GET\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/drive/v1/files" {
t.Fatalf("url = %q, want /open-apis/drive/v1/files\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "folder_token").String(); got != "fldcnE2E001" {
t.Fatalf("folder_token = %q, want fldcnE2E001\nstdout:\n%s", got, out)
}
desc := gjson.Get(out, "description").String()
if !strings.Contains(desc, "Walk --local-dir") || !strings.Contains(desc, "SHA-256") {
t.Fatalf("description missing key phrases, got %q\nstdout:\n%s", desc, out)
}
}
func TestDrive_StatusDryRunQuick(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
workDir := t.TempDir()
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+status",
"--local-dir", "local",
"--folder-token", "fldcnE2E001",
"--quick",
"--dry-run",
},
WorkDir: workDir,
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "api.0.method").String(); got != "GET" {
t.Fatalf("method = %q, want GET\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/drive/v1/files" {
t.Fatalf("url = %q, want /open-apis/drive/v1/files\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "folder_token").String(); got != "fldcnE2E001" {
t.Fatalf("folder_token = %q, want fldcnE2E001\nstdout:\n%s", got, out)
}
desc := gjson.Get(out, "description").String()
if !strings.Contains(desc, "modified_time") || strings.Contains(desc, "SHA-256") {
t.Fatalf("quick description must mention modified_time and skip SHA-256 wording, got %q\nstdout:\n%s", desc, out)
}
}
// TestDrive_StatusDryRunRejectsAbsoluteLocalDir confirms that the
// --local-dir path validator runs in the real binary's Validate stage and
// surfaces a structured error referencing --local-dir (not the framework
// default --file).
func TestDrive_StatusDryRunRejectsAbsoluteLocalDir(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+status",
"--local-dir", "/etc",
"--folder-token", "fldcnE2E001",
"--dry-run",
},
WorkDir: t.TempDir(),
DefaultAs: "user",
})
require.NoError(t, err)
if result.ExitCode == 0 {
t.Fatalf("absolute --local-dir must be rejected, got exit=0\nstdout:\n%s", result.Stdout)
}
combined := result.Stdout + "\n" + result.Stderr
if !strings.Contains(combined, "--local-dir") {
t.Fatalf("expected --local-dir in error message, got:\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
}
}
// TestDrive_StatusDryRunRejectsMissingFolderToken confirms cobra's
// required-flag enforcement runs before our custom Validate.
func TestDrive_StatusDryRunRejectsMissingFolderToken(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
workDir := t.TempDir()
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+status",
"--local-dir", "local",
"--dry-run",
},
WorkDir: workDir,
DefaultAs: "user",
})
require.NoError(t, err)
if result.ExitCode == 0 {
t.Fatalf("missing --folder-token must be rejected, got exit=0\nstdout:\n%s", result.Stdout)
}
combined := result.Stdout + "\n" + result.Stderr
if !strings.Contains(combined, "folder-token") {
t.Fatalf("expected folder-token in error message, got:\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
}
}
@@ -0,0 +1,464 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestDrive_StatusWorkflow exercises +status against a real Drive folder so
// the parts that dry-run can't reach — recursive listing pagination, the
// download+hash leg, scope handling, and the SHA-256 comparison itself —
// are covered against the real backend.
//
// Layout:
//
// folder/ (--folder-token target)
// ├── unchanged.txt "match" ↔ local: "match" → unchanged
// ├── modified.txt "remote" ↔ local: "local" → modified
// └── remote-only.txt "remote" ↔ (none) → new_remote
// local/ (--local-dir target)
// ├── unchanged.txt "match"
// ├── modified.txt "local"
// └── local-only.txt "anything" → new_local
//
// Expected output: each of the four buckets contains exactly the file we
// expect, with file_token set for the three buckets that have a Drive side.
func TestDrive_StatusWorkflow(t *testing.T) {
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
t.Cleanup(cancel)
suffix := clie2e.GenerateSuffix()
folderName := "lark-cli-e2e-drive-status-" + suffix
folderToken := createDriveFolder(t, parentT, ctx, folderName, "")
// Local working directory. +status's --local-dir must be relative to
// the binary's cwd, so each upload + the +status invocation share the
// same WorkDir.
workDir := t.TempDir()
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
t.Fatalf("mkdir local: %v", err)
}
// Helper: write a local file under workDir/<rel>.
writeLocal := func(rel, content string) {
t.Helper()
full := filepath.Join(workDir, rel)
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
t.Fatalf("mkdir parent of %s: %v", rel, err)
}
if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
t.Fatalf("write %s: %v", rel, err)
}
}
// Helper: stage <content> into a sibling temp file then upload it as
// <name> under folderToken. +upload reads --file relative to its cwd.
uploadDriveFile := func(name, content string) string {
t.Helper()
// Stage outside `local/` so the local-side tree only sees what
// the test wants; +upload still reads relative to workDir.
stage := "_upload_" + name
writeLocal(stage, content)
t.Cleanup(func() { _ = os.Remove(filepath.Join(workDir, stage)) })
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+upload",
"--file", stage,
"--folder-token", folderToken,
"--name", name,
},
WorkDir: workDir,
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
fileToken := gjson.Get(result.Stdout, "data.file_token").String()
require.NotEmpty(t, fileToken, "uploaded file should have a token, stdout:\n%s", result.Stdout)
parentT.Cleanup(func() {
cleanupCtx, cleanupCancel := clie2e.CleanupContext()
defer cleanupCancel()
deleteResult, deleteErr := clie2e.RunCmdWithRetry(cleanupCtx, clie2e.Request{
Args: []string{"drive", "+delete", "--file-token", fileToken, "--type", "file", "--yes"},
DefaultAs: "bot",
}, clie2e.RetryOptions{})
clie2e.ReportCleanupFailure(parentT, "delete drive file "+fileToken, deleteResult, deleteErr)
})
return fileToken
}
// Seed both sides. Order doesn't matter functionally, but doing the
// uploads first lets the +status listing pick up everything in a
// single pass.
tokUnchanged := uploadDriveFile("unchanged.txt", "match")
tokModified := uploadDriveFile("modified.txt", "remote")
tokRemoteOnly := uploadDriveFile("remote-only.txt", "remote")
writeLocal("local/unchanged.txt", "match") // matches remote → unchanged
writeLocal("local/modified.txt", "local") // differs → modified
writeLocal("local/local-only.txt", "extra") // only here → new_local
// Run +status against the real folder.
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+status",
"--local-dir", "local",
"--folder-token", folderToken,
},
WorkDir: workDir,
DefaultAs: "bot",
})
require.NoError(t, err)
skipDriveStatusExactIfMissingDownloadScope(t, result)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
// Assert each bucket contains exactly the file we expect, with the
// correct file_token for sides that have one.
out := result.Stdout
cases := []struct {
bucket string
path string
token string // empty when the bucket has no Drive side
}{
{"unchanged", "unchanged.txt", tokUnchanged},
{"modified", "modified.txt", tokModified},
{"new_local", "local-only.txt", ""},
{"new_remote", "remote-only.txt", tokRemoteOnly},
}
for _, c := range cases {
bucket := gjson.Get(out, "data."+c.bucket)
if !bucket.IsArray() {
t.Fatalf("data.%s must be an array, stdout:\n%s", c.bucket, out)
}
var found bool
bucket.ForEach(func(_, entry gjson.Result) bool {
if entry.Get("rel_path").String() != c.path {
return true // continue
}
found = true
if c.token != "" {
if got := entry.Get("file_token").String(); got != c.token {
t.Errorf("%s entry %q: file_token=%q want %q", c.bucket, c.path, got, c.token)
}
} else if entry.Get("file_token").String() != "" {
t.Errorf("%s entry %q must not carry file_token (local-only), stdout:\n%s", c.bucket, c.path, out)
}
return false // stop
})
if !found {
t.Errorf("%s bucket missing %q\nstdout:\n%s", c.bucket, c.path, out)
}
}
// Make sure each bucket is exactly the size we expect (4 files total,
// no double-bucketing). +upload may attach extra metadata (e.g. a
// folder type entry for `local/` itself) but the lister filters
// type=file so the buckets should be clean.
for _, b := range []struct {
bucket string
want int
}{
{"unchanged", 1},
{"modified", 1},
{"new_local", 1},
{"new_remote", 1},
} {
got := int(gjson.Get(out, "data."+b.bucket+".#").Int())
if got != b.want {
t.Errorf("data.%s length=%d want %d\nstdout:\n%s", b.bucket, got, b.want, out)
}
}
quickResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+status",
"--local-dir", "local",
"--folder-token", folderToken,
"--quick",
},
WorkDir: workDir,
DefaultAs: "bot",
})
require.NoError(t, err)
quickResult.AssertExitCode(t, 0)
quickResult.AssertStdoutStatus(t, true)
quickOut := quickResult.Stdout
if got := gjson.Get(quickOut, "data.detection").String(); got != "quick" {
t.Fatalf("quick detection=%q want quick\nstdout:\n%s", got, quickOut)
}
if got := int(gjson.Get(quickOut, "data.new_local.#").Int()); got != 1 {
t.Fatalf("quick new_local length=%d want 1\nstdout:\n%s", got, quickOut)
}
if got := int(gjson.Get(quickOut, "data.new_remote.#").Int()); got != 1 {
t.Fatalf("quick new_remote length=%d want 1\nstdout:\n%s", got, quickOut)
}
if got := gjson.Get(quickOut, "data.new_local.0.rel_path").String(); got != "local-only.txt" {
t.Fatalf("quick new_local path=%q want local-only.txt\nstdout:\n%s", got, quickOut)
}
if got := gjson.Get(quickOut, "data.new_remote.0.rel_path").String(); got != "remote-only.txt" {
t.Fatalf("quick new_remote path=%q want remote-only.txt\nstdout:\n%s", got, quickOut)
}
sharedCount := int(gjson.Get(quickOut, "data.modified.#").Int() + gjson.Get(quickOut, "data.unchanged.#").Int())
if sharedCount != 2 {
t.Fatalf("quick shared file count=%d want 2 across modified+unchanged\nstdout:\n%s", sharedCount, quickOut)
}
for _, path := range []string{"unchanged.txt", "modified.txt"} {
if !gjson.Get(quickOut, `data.modified.#(rel_path="`+path+`")`).Exists() && !gjson.Get(quickOut, `data.unchanged.#(rel_path="`+path+`")`).Exists() {
t.Fatalf("quick output missing shared path %q\nstdout:\n%s", path, quickOut)
}
}
}
// TestDrive_StatusQuickWorkflow proves that --quick really follows modified_time
// semantics on the live backend instead of silently behaving like the default
// exact hash mode.
//
// The fixture intentionally makes the two shared files diverge in opposite ways:
// - same-mtime.txt: bytes differ, mtime matches remote → quick=unchanged / exact=modified
// - remote-newer.txt: bytes match, local mtime is older → quick=modified / exact=unchanged
//
// This locks in the best-effort nature of quick mode with real Drive
// modified_time values fetched from the list API, plus the expected new_local /
// new_remote buckets.
func TestDrive_StatusQuickWorkflow(t *testing.T) {
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
t.Cleanup(cancel)
suffix := clie2e.GenerateSuffix()
folderName := "lark-cli-e2e-drive-status-quick-" + suffix
folderToken := createDriveFolder(t, parentT, ctx, folderName, "")
workDir := t.TempDir()
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
t.Fatalf("mkdir local: %v", err)
}
writeLocal := func(rel, content string) {
t.Helper()
full := filepath.Join(workDir, rel)
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
t.Fatalf("mkdir parent of %s: %v", rel, err)
}
if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
t.Fatalf("write %s: %v", rel, err)
}
}
uploadDriveFile := func(name, content string) string {
t.Helper()
stage := "_upload_" + name
writeLocal(stage, content)
t.Cleanup(func() { _ = os.Remove(filepath.Join(workDir, stage)) })
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+upload",
"--file", stage,
"--folder-token", folderToken,
"--name", name,
},
WorkDir: workDir,
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
fileToken := gjson.Get(result.Stdout, "data.file_token").String()
require.NotEmpty(t, fileToken, "uploaded file should have a token, stdout:\n%s", result.Stdout)
parentT.Cleanup(func() {
cleanupCtx, cleanupCancel := clie2e.CleanupContext()
defer cleanupCancel()
deleteResult, deleteErr := clie2e.RunCmdWithRetry(cleanupCtx, clie2e.Request{
Args: []string{"drive", "+delete", "--file-token", fileToken, "--type", "file", "--yes"},
DefaultAs: "bot",
}, clie2e.RetryOptions{})
clie2e.ReportCleanupFailure(parentT, "delete drive file "+fileToken, deleteResult, deleteErr)
})
return fileToken
}
tokSameMtime := uploadDriveFile("same-mtime.txt", "remote bytes A")
tokRemoteNewer := uploadDriveFile("remote-newer.txt", "remote bytes B")
tokRemoteOnly := uploadDriveFile("remote-only.txt", "remote only")
remoteFiles := listDriveFolderFilesByName(t, ctx, folderToken)
sameMtimeRemote := remoteFiles["same-mtime.txt"]
remoteNewer := remoteFiles["remote-newer.txt"]
if sameMtimeRemote.ModifiedTime == "" || remoteNewer.ModifiedTime == "" {
t.Fatalf("expected modified_time for shared remote files, got: %#v", remoteFiles)
}
writeLocal("local/same-mtime.txt", "local bytes A") // bytes differ from remote
writeLocal("local/remote-newer.txt", "remote bytes B") // bytes match remote
writeLocal("local/local-only.txt", "local only") // local-only bucket
sameMtimePath := filepath.Join(workDir, "local", "same-mtime.txt")
remoteNewerPath := filepath.Join(workDir, "local", "remote-newer.txt")
sameMtimeAt := mustParseDriveEpochForE2E(t, sameMtimeRemote.ModifiedTime)
remoteNewerAt := mustParseDriveEpochForE2E(t, remoteNewer.ModifiedTime)
if err := os.Chtimes(sameMtimePath, sameMtimeAt, sameMtimeAt); err != nil {
t.Fatalf("chtimes same-mtime.txt: %v", err)
}
localOlder := remoteNewerAt.Add(-2 * time.Second)
if err := os.Chtimes(remoteNewerPath, localOlder, localOlder); err != nil {
t.Fatalf("chtimes remote-newer.txt: %v", err)
}
quickResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+status",
"--local-dir", "local",
"--folder-token", folderToken,
"--quick",
},
WorkDir: workDir,
DefaultAs: "bot",
})
require.NoError(t, err)
quickResult.AssertExitCode(t, 0)
quickResult.AssertStdoutStatus(t, true)
quickOut := quickResult.Stdout
if got := gjson.Get(quickOut, "data.detection").String(); got != "quick" {
t.Fatalf("quick detection=%q want quick\nstdout:\n%s", got, quickOut)
}
assertStatusBucketEntry(t, quickOut, "unchanged", "same-mtime.txt", tokSameMtime)
assertStatusBucketEntry(t, quickOut, "modified", "remote-newer.txt", tokRemoteNewer)
assertStatusBucketEntry(t, quickOut, "new_local", "local-only.txt", "")
assertStatusBucketEntry(t, quickOut, "new_remote", "remote-only.txt", tokRemoteOnly)
assertStatusBucketLen(t, quickOut, "unchanged", 1)
assertStatusBucketLen(t, quickOut, "modified", 1)
assertStatusBucketLen(t, quickOut, "new_local", 1)
assertStatusBucketLen(t, quickOut, "new_remote", 1)
exactResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+status",
"--local-dir", "local",
"--folder-token", folderToken,
},
WorkDir: workDir,
DefaultAs: "bot",
})
require.NoError(t, err)
skipDriveStatusExactIfMissingDownloadScope(t, exactResult)
exactResult.AssertExitCode(t, 0)
exactResult.AssertStdoutStatus(t, true)
exactOut := exactResult.Stdout
if got := gjson.Get(exactOut, "data.detection").String(); got != "exact" {
t.Fatalf("exact detection=%q want exact\nstdout:\n%s", got, exactOut)
}
assertStatusBucketEntry(t, exactOut, "modified", "same-mtime.txt", tokSameMtime)
assertStatusBucketEntry(t, exactOut, "unchanged", "remote-newer.txt", tokRemoteNewer)
assertStatusBucketEntry(t, exactOut, "new_local", "local-only.txt", "")
assertStatusBucketEntry(t, exactOut, "new_remote", "remote-only.txt", tokRemoteOnly)
assertStatusBucketLen(t, exactOut, "unchanged", 1)
assertStatusBucketLen(t, exactOut, "modified", 1)
assertStatusBucketLen(t, exactOut, "new_local", 1)
assertStatusBucketLen(t, exactOut, "new_remote", 1)
}
type driveStatusListedFile struct {
Token string
ModifiedTime string
}
func listDriveFolderFilesByName(t *testing.T, ctx context.Context, folderToken string) map[string]driveStatusListedFile {
t.Helper()
params := fmt.Sprintf(`{"folder_token":"%s","page_size":200}`, folderToken)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"drive", "files", "list", "--params", params},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
files := make(map[string]driveStatusListedFile)
gjson.Get(result.Stdout, "data.files").ForEach(func(_, entry gjson.Result) bool {
name := entry.Get("name").String()
if name == "" {
return true
}
files[name] = driveStatusListedFile{
Token: entry.Get("token").String(),
ModifiedTime: entry.Get("modified_time").String(),
}
return true
})
return files
}
func mustParseDriveEpochForE2E(t *testing.T, raw string) time.Time {
t.Helper()
v, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
t.Fatalf("parse Drive epoch %q: %v", raw, err)
}
switch {
case v > 1e14 || v < -1e14:
return time.UnixMicro(v)
case v > 1e11 || v < -1e11:
return time.UnixMilli(v)
default:
return time.Unix(v, 0)
}
}
func assertStatusBucketEntry(t *testing.T, stdout, bucket, relPath, fileToken string) {
t.Helper()
entry := gjson.Get(stdout, `data.`+bucket+`.#(rel_path="`+relPath+`")`)
if !entry.Exists() {
t.Fatalf("bucket %s missing rel_path %q\nstdout:\n%s", bucket, relPath, stdout)
}
if fileToken == "" {
if got := entry.Get("file_token").String(); got != "" {
t.Fatalf("bucket %s rel_path %q unexpectedly carried file_token=%q\nstdout:\n%s", bucket, relPath, got, stdout)
}
return
}
if got := entry.Get("file_token").String(); got != fileToken {
t.Fatalf("bucket %s rel_path %q file_token=%q want %q\nstdout:\n%s", bucket, relPath, got, fileToken, stdout)
}
}
func assertStatusBucketLen(t *testing.T, stdout, bucket string, want int) {
t.Helper()
if got := int(gjson.Get(stdout, "data."+bucket+".#").Int()); got != want {
t.Fatalf("bucket %s length=%d want %d\nstdout:\n%s", bucket, got, want, stdout)
}
}
func skipDriveStatusExactIfMissingDownloadScope(t *testing.T, result *clie2e.Result) {
t.Helper()
if result == nil || result.ExitCode == 0 {
return
}
combinedLower := strings.ToLower(result.Stdout + "\n" + result.Stderr)
if strings.Contains(combinedLower, "missing_scope") && strings.Contains(combinedLower, "drive:file:download") {
t.Skipf("skip drive +status exact live workflow due to missing drive:file:download scope: stdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
}
}
@@ -0,0 +1,258 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestDrive_SyncDryRun locks in the request shape the +sync shortcut emits
// under --dry-run: the real CLI binary is invoked end-to-end, so flag
// parsing, Validate (still runs in dry-run mode), and the dry-run renderer
// all execute. The printed envelope is then inspected for GET method,
// list-files URL, the folder_token parameter, and key phrases from Desc.
//
// Fake credentials are sufficient because --dry-run short-circuits before
// any real network call.
func TestDrive_SyncDryRun(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
workDir := t.TempDir()
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+sync",
"--local-dir", "local",
"--folder-token", "fldcnE2E001",
"--dry-run",
},
WorkDir: workDir,
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "api.0.method").String(); got != "GET" {
t.Fatalf("method = %q, want GET\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/drive/v1/files" {
t.Fatalf("url = %q, want /open-apis/drive/v1/files\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "folder_token").String(); got != "fldcnE2E001" {
t.Fatalf("folder_token = %q, want fldcnE2E001\nstdout:\n%s", got, out)
}
desc := gjson.Get(out, "description").String()
if !strings.Contains(desc, "diff") {
t.Fatalf("description missing diff phrase, got %q\nstdout:\n%s", desc, out)
}
}
// TestDrive_SyncDryRunRejectsAbsoluteLocalDir confirms the path validator
// runs in the real binary's Validate stage and surfaces a structured error
// referencing --local-dir.
func TestDrive_SyncDryRunRejectsAbsoluteLocalDir(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+sync",
"--local-dir", "/etc",
"--folder-token", "fldcnE2E001",
"--dry-run",
},
WorkDir: t.TempDir(),
DefaultAs: "user",
})
require.NoError(t, err)
if result.ExitCode == 0 {
t.Fatalf("absolute --local-dir must be rejected, got exit=0\nstdout:\n%s", result.Stdout)
}
combined := result.Stdout + "\n" + result.Stderr
if !strings.Contains(combined, "--local-dir") {
t.Fatalf("expected --local-dir in error, got:\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
}
}
// TestDrive_SyncDryRunRejectsMissingFolderToken confirms cobra's
// required-flag enforcement runs before our custom Validate.
func TestDrive_SyncDryRunRejectsMissingFolderToken(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
workDir := t.TempDir()
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+sync",
"--local-dir", "local",
"--dry-run",
},
WorkDir: workDir,
DefaultAs: "user",
})
require.NoError(t, err)
if result.ExitCode == 0 {
t.Fatalf("missing --folder-token must be rejected, got exit=0\nstdout:\n%s", result.Stdout)
}
combined := result.Stdout + "\n" + result.Stderr
if !strings.Contains(combined, "folder-token") {
t.Fatalf("expected folder-token in error, got:\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
}
}
// TestDrive_SyncDryRunAcceptsConflictStrategies verifies that all valid
// --on-conflict values pass Validate and produce a well-formed dry-run
// envelope.
func TestDrive_SyncDryRunAcceptsConflictStrategies(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
for _, strategy := range []string{"remote-wins", "local-wins", "keep-both", "ask"} {
t.Run(strategy, func(t *testing.T) {
workDir := t.TempDir()
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+sync",
"--local-dir", "local",
"--folder-token", "fldcnE2E001",
"--on-conflict", strategy,
"--dry-run",
},
WorkDir: workDir,
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "api.0.method").String(); got != "GET" {
t.Fatalf("method = %q, want GET\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "folder_token").String(); got != "fldcnE2E001" {
t.Fatalf("folder_token = %q, want fldcnE2E001\nstdout:\n%s", got, out)
}
})
}
}
// TestDrive_SyncDryRunAcceptsDuplicateRemoteStrategies verifies that all
// valid --on-duplicate-remote values pass Validate.
func TestDrive_SyncDryRunAcceptsDuplicateRemoteStrategies(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
for _, strategy := range []string{"fail", "newest", "oldest"} {
t.Run(strategy, func(t *testing.T) {
workDir := t.TempDir()
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+sync",
"--local-dir", "local",
"--folder-token", "fldcnE2E001",
"--on-duplicate-remote", strategy,
"--dry-run",
},
WorkDir: workDir,
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "folder_token").String(); got != "fldcnE2E001" {
t.Fatalf("folder_token = %q, want fldcnE2E001\nstdout:\n%s", got, out)
}
})
}
}
// TestDrive_SyncDryRunAcceptsQuickFlag verifies that --quick passes Validate
// and produces a well-formed dry-run envelope.
func TestDrive_SyncDryRunAcceptsQuickFlag(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
workDir := t.TempDir()
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+sync",
"--local-dir", "local",
"--folder-token", "fldcnE2E001",
"--quick",
"--dry-run",
},
WorkDir: workDir,
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "api.0.method").String(); got != "GET" {
t.Fatalf("method = %q, want GET\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "folder_token").String(); got != "fldcnE2E001" {
t.Fatalf("folder_token = %q, want fldcnE2E001\nstdout:\n%s", got, out)
}
}
@@ -0,0 +1,346 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestDrive_SyncWorkflow exercises +sync against a real Drive folder, proving
// that new_remote files are pulled, new_local files are pushed, and conflicts
// are resolved according to --on-conflict.
//
// Layout (before sync):
//
// folder/ (--folder-token target)
// ├── remote-only.txt "remote" ↔ (none) → new_remote → pull
// ├── conflict.txt "remote" ↔ local: "local" → modified → resolve
// └── unchanged.txt "match" ↔ local: "match" → unchanged → skip
// local/ (--local-dir target)
// ├── local-only.txt "local" → new_local → push
// ├── conflict.txt "local" → modified → resolve
// └── unchanged.txt "match" → unchanged → skip
func TestDrive_SyncWorkflow(t *testing.T) {
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute)
t.Cleanup(cancel)
suffix := clie2e.GenerateSuffix()
folderToken := createDriveFolder(t, parentT, ctx, "lark-cli-e2e-drive-sync-"+suffix, "")
workDir := t.TempDir()
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
t.Fatalf("mkdir local: %v", err)
}
writeLocal := func(rel, content string) {
t.Helper()
full := filepath.Join(workDir, rel)
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
t.Fatalf("mkdir parent of %s: %v", rel, err)
}
if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
t.Fatalf("write %s: %v", rel, err)
}
}
uploadDriveFile := func(name, content string) string {
t.Helper()
stage := "_upload_" + name
writeLocal(stage, content)
t.Cleanup(func() { _ = os.Remove(filepath.Join(workDir, stage)) })
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+upload",
"--file", stage,
"--folder-token", folderToken,
"--name", name,
},
WorkDir: workDir,
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
fileToken := gjson.Get(result.Stdout, "data.file_token").String()
require.NotEmpty(t, fileToken, "uploaded file should have a token, stdout:\n%s", result.Stdout)
parentT.Cleanup(func() {
cleanupCtx, cleanupCancel := clie2e.CleanupContext()
defer cleanupCancel()
deleteResult, deleteErr := clie2e.RunCmdWithRetry(cleanupCtx, clie2e.Request{
Args: []string{"drive", "+delete", "--file-token", fileToken, "--type", "file", "--yes"},
DefaultAs: "bot",
}, clie2e.RetryOptions{})
clie2e.ReportCleanupFailure(parentT, "delete drive file "+fileToken, deleteResult, deleteErr)
})
return fileToken
}
// --- Subtest: remote-wins (default) ---
t.Run("remote-wins pulls new_remote and overwrites on conflict", func(t *testing.T) {
tokUnchanged := uploadDriveFile("unchanged.txt", "match")
tokConflict := uploadDriveFile("conflict.txt", "remote")
tokRemoteOnly := uploadDriveFile("remote-only.txt", "remote")
writeLocal("local/unchanged.txt", "match")
writeLocal("local/conflict.txt", "local")
writeLocal("local/local-only.txt", "local")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+sync",
"--local-dir", "local",
"--folder-token", folderToken,
"--on-conflict", "remote-wins",
},
WorkDir: workDir,
DefaultAs: "bot",
})
require.NoError(t, err)
skipDriveStatusExactIfMissingDownloadScope(t, result)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
out := result.Stdout
// Summary checks.
if got := gjson.Get(out, "data.summary.pulled").Int(); got != 2 {
t.Fatalf("pulled=%d want 2 (remote-only + conflict resolved by pull)\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "data.summary.pushed").Int(); got < 1 {
t.Fatalf("pushed=%d want >=1 (local-only)\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "data.summary.failed").Int(); got != 0 {
t.Fatalf("failed=%d want 0\nstdout:\n%s", got, out)
}
// Item-level checks.
assertSyncItem(t, out, "downloaded", "pull", "remote-only.txt", tokRemoteOnly)
assertSyncItem(t, out, "downloaded", "pull", "conflict.txt", tokConflict)
assertSyncItem(t, out, "uploaded", "push", "local-only.txt", "")
// Verify local file content after sync.
conflictContent, err := os.ReadFile(filepath.Join(workDir, "local", "conflict.txt"))
if err != nil {
t.Fatalf("read conflict.txt: %v", err)
}
if string(conflictContent) != "remote" {
t.Fatalf("conflict.txt content=%q want %q", string(conflictContent), "remote")
}
require.FileExists(t, filepath.Join(workDir, "local", "remote-only.txt"))
// Convergence: +status should now show all files as unchanged.
assertSyncConverges(t, ctx, workDir, folderToken, tokUnchanged)
})
// --- Subtest: local-wins ---
t.Run("local-wins pushes new_local and overwrites remote on conflict", func(t *testing.T) {
tokConflict := uploadDriveFile("conflict-lw.txt", "remote")
_ = uploadDriveFile("remote-only-lw.txt", "remote")
writeLocal("local/conflict-lw.txt", "local-wins")
writeLocal("local/local-only-lw.txt", "local")
writeLocal("local/remote-only-lw.txt", "already-here")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+sync",
"--local-dir", "local",
"--folder-token", folderToken,
"--on-conflict", "local-wins",
},
WorkDir: workDir,
DefaultAs: "bot",
})
require.NoError(t, err)
skipDriveStatusExactIfMissingDownloadScope(t, result)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
out := result.Stdout
// Conflict file should be overwritten with local version.
assertSyncItem(t, out, "overwritten", "push", "conflict-lw.txt", tokConflict)
// Verify local content is unchanged (local-wins).
conflictContent, err := os.ReadFile(filepath.Join(workDir, "local", "conflict-lw.txt"))
if err != nil {
t.Fatalf("read conflict-lw.txt: %v", err)
}
if string(conflictContent) != "local-wins" {
t.Fatalf("conflict-lw.txt content=%q want %q", string(conflictContent), "local-wins")
}
})
// --- Subtest: keep-both ---
t.Run("keep-both renames local and pulls remote", func(t *testing.T) {
uploadDriveFile("conflict-kb.txt", "remote-kb")
writeLocal("local/conflict-kb.txt", "local-kb")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+sync",
"--local-dir", "local",
"--folder-token", folderToken,
"--on-conflict", "keep-both",
},
WorkDir: workDir,
DefaultAs: "bot",
})
require.NoError(t, err)
skipDriveStatusExactIfMissingDownloadScope(t, result)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
out := result.Stdout
// Should have a renamed_local item and a downloaded item.
assertSyncItem(t, out, "renamed_local", "conflict", "conflict-kb.txt", "")
assertSyncItem(t, out, "downloaded", "pull", "conflict-kb.txt", "")
// Original path now has remote content.
origContent, err := os.ReadFile(filepath.Join(workDir, "local", "conflict-kb.txt"))
if err != nil {
t.Fatalf("read conflict-kb.txt: %v", err)
}
if string(origContent) != "remote-kb" {
t.Fatalf("conflict-kb.txt content=%q want %q", string(origContent), "remote-kb")
}
// A suffixed sibling should exist with the local content.
entries, err := os.ReadDir(filepath.Join(workDir, "local"))
if err != nil {
t.Fatalf("readdir local: %v", err)
}
var foundSuffixed bool
for _, e := range entries {
if strings.HasPrefix(e.Name(), "conflict-kb__lark_") && strings.HasSuffix(e.Name(), ".txt") {
foundSuffixed = true
suffixedContent, readErr := os.ReadFile(filepath.Join(workDir, "local", e.Name()))
if readErr != nil {
t.Fatalf("read suffixed file: %v", readErr)
}
if string(suffixedContent) != "local-kb" {
t.Fatalf("suffixed file content=%q want %q", string(suffixedContent), "local-kb")
}
break
}
}
if !foundSuffixed {
t.Fatalf("expected suffixed sibling conflict-kb__lark_*.txt, entries: %v", entries)
}
})
}
// TestDrive_SyncEmptyDirWorkflow proves that empty local directories are
// created on Drive during +sync, and that a subsequent +status converges.
func TestDrive_SyncEmptyDirWorkflow(t *testing.T) {
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
t.Cleanup(cancel)
suffix := clie2e.GenerateSuffix()
folderToken := createDriveFolder(t, parentT, ctx, "lark-cli-e2e-drive-sync-emptydir-"+suffix, "")
workDir := t.TempDir()
// Create an empty subdirectory under local.
if err := os.MkdirAll(filepath.Join(workDir, "local", "empty_sub"), 0o755); err != nil {
t.Fatalf("mkdir local/empty_sub: %v", err)
}
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+sync",
"--local-dir", "local",
"--folder-token", folderToken,
},
WorkDir: workDir,
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
out := result.Stdout
// Should report folder_created for the empty directory.
if !strings.Contains(out, `"action": "folder_created"`) {
t.Fatalf("expected folder_created action for empty directory, got:\n%s", out)
}
if !strings.Contains(out, `"empty_sub"`) {
t.Fatalf("expected empty_sub in items, got:\n%s", out)
}
}
// assertSyncItem checks that a sync item with the given action, direction,
// and rel_path exists in the output. If fileToken is non-empty, it also
// verifies the item carries that token.
func assertSyncItem(t *testing.T, stdout, action, direction, relPath, fileToken string) {
t.Helper()
items := gjson.Get(stdout, "data.items")
if !items.IsArray() {
t.Fatalf("data.items is not an array\nstdout:\n%s", stdout)
}
var found bool
items.ForEach(func(_, item gjson.Result) bool {
if item.Get("action").String() != action || item.Get("direction").String() != direction || item.Get("rel_path").String() != relPath {
return true
}
found = true
if fileToken != "" {
if got := item.Get("file_token").String(); got != fileToken {
t.Errorf("item %s/%s/%s file_token=%q want %q", action, direction, relPath, got, fileToken)
}
}
return false
})
if !found {
t.Fatalf("missing sync item action=%s direction=%s rel_path=%s\nstdout:\n%s", action, direction, relPath, stdout)
}
}
// assertSyncConverges runs +status after a sync and asserts that all shared
// files are unchanged (i.e. the mirror has converged).
func assertSyncConverges(t *testing.T, ctx context.Context, workDir, folderToken, unchangedToken string) {
t.Helper()
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+status",
"--local-dir", "local",
"--folder-token", folderToken,
},
WorkDir: workDir,
DefaultAs: "bot",
})
require.NoError(t, err)
skipDriveStatusExactIfMissingDownloadScope(t, result)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
out := result.Stdout
if got := gjson.Get(out, "data.modified.#").Int(); got != 0 {
t.Fatalf("post-sync +status modified=%d want 0\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "data.new_local.#").Int(); got != 0 {
t.Fatalf("post-sync +status new_local=%d want 0\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "data.new_remote.#").Int(); got != 0 {
t.Fatalf("post-sync +status new_remote=%d want 0\nstdout:\n%s", got, out)
}
if unchangedToken != "" {
assertStatusBucketEntry(t, out, "unchanged", "unchanged.txt", unchangedToken)
}
}
@@ -0,0 +1,98 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestDriveUploadDryRun_WikiTarget(t *testing.T) {
setDriveDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+upload",
"--file", "./report.pdf",
"--wiki-token", "wikcnDryRunUploadTarget",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
output := strings.TrimSpace(result.Stdout)
assert.Contains(t, output, "/open-apis/drive/v1/files/upload_all")
assert.Contains(t, output, "/open-apis/drive/v1/metas/batch_query")
assert.Contains(t, output, `"with_url": true`)
assert.Contains(t, output, "parent_type")
assert.Contains(t, output, "parent_node")
assert.Contains(t, output, "wikcnDryRunUploadTarget")
assert.Contains(t, output, `"parent_type": "wiki"`)
}
func TestDriveUploadDryRun_WithFileToken(t *testing.T) {
setDriveDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+upload",
"--file", "./report.pdf",
"--folder-token", "fldDryRunUploadTarget",
"--file-token", "boxcnDryRunOverwriteTarget",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
output := strings.TrimSpace(result.Stdout)
assert.Contains(t, output, "/open-apis/drive/v1/files/upload_all")
assert.Contains(t, output, "/open-apis/drive/v1/metas/batch_query")
assert.Contains(t, output, `"with_url": true`)
assert.Contains(t, output, `"parent_node": "fldDryRunUploadTarget"`)
assert.Contains(t, output, `"file_token": "boxcnDryRunOverwriteTarget"`)
}
func TestDriveUploadDryRunRejectsEmptyWikiToken(t *testing.T) {
setDriveDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+upload",
"--file", "./report.pdf",
"--wiki-token", "",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
assert.Contains(t, result.Stderr, "--wiki-token cannot be empty")
}
func setDriveDryRunConfigEnv(t *testing.T) {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "drive_dryrun_test")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "drive_dryrun_secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
}
@@ -0,0 +1,119 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"os"
"path/filepath"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestDrive_UploadWorkflow(t *testing.T) {
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
t.Cleanup(cancel)
suffix := clie2e.GenerateSuffix()
folderToken := createDriveFolder(t, parentT, ctx, "lark-cli-e2e-drive-upload-"+suffix, "")
workDir := t.TempDir()
cleanupTokens := map[string]struct{}{}
scheduleDelete := func(fileToken string) {
t.Helper()
if fileToken == "" {
return
}
if _, seen := cleanupTokens[fileToken]; seen {
return
}
cleanupTokens[fileToken] = struct{}{}
parentT.Cleanup(func() {
cleanupCtx, cleanupCancel := clie2e.CleanupContext()
defer cleanupCancel()
deleteResult, deleteErr := clie2e.RunCmdWithRetry(cleanupCtx, clie2e.Request{
Args: []string{"drive", "+delete", "--file-token", fileToken, "--type", "file", "--yes"},
DefaultAs: "bot",
}, clie2e.RetryOptions{})
clie2e.ReportCleanupFailure(parentT, "delete drive file "+fileToken, deleteResult, deleteErr)
})
}
uploadFile := func(stageName, remoteName, content, fileToken string) string {
t.Helper()
stagePath := filepath.Join(workDir, stageName)
if err := os.WriteFile(stagePath, []byte(content), 0o644); err != nil {
t.Fatalf("write stage file %s: %v", stageName, err)
}
args := []string{
"drive", "+upload",
"--file", stageName,
"--folder-token", folderToken,
"--name", remoteName,
}
if fileToken != "" {
args = append(args, "--file-token", fileToken)
}
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: args,
WorkDir: workDir,
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
gotToken := gjson.Get(result.Stdout, "data.file_token").String()
require.NotEmpty(t, gotToken, "uploaded file should have a token, stdout:\n%s", result.Stdout)
if got := gjson.Get(result.Stdout, "data.file_name").String(); got != remoteName {
t.Fatalf("data.file_name=%q want %q\nstdout:\n%s", got, remoteName, result.Stdout)
}
if got := gjson.Get(result.Stdout, "data.size").Int(); got != int64(len(content)) {
t.Fatalf("data.size=%d want %d\nstdout:\n%s", got, len(content), result.Stdout)
}
return gotToken
}
initialContent := "drive upload e2e: initial content\n"
initialToken := uploadFile("_upload_initial.txt", "overwrite.txt", initialContent, "")
scheduleDelete(initialToken)
updatedContent := "drive upload e2e: overwritten via file-token\n"
overwriteToken := uploadFile("_upload_overwrite.txt", "overwrite.txt", updatedContent, initialToken)
scheduleDelete(overwriteToken)
if overwriteToken != initialToken {
t.Fatalf("overwrite token=%q want original token=%q", overwriteToken, initialToken)
}
downloadResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+download",
"--file-token", overwriteToken,
"--output", "downloaded.txt",
"--overwrite",
},
WorkDir: workDir,
DefaultAs: "bot",
})
require.NoError(t, err)
downloadResult.AssertExitCode(t, 0)
downloadResult.AssertStdoutStatus(t, true)
data, err := os.ReadFile(filepath.Join(workDir, "downloaded.txt"))
if err != nil {
t.Fatalf("read downloaded file: %v", err)
}
if string(data) != updatedContent {
t.Fatalf("downloaded content=%q want %q", string(data), updatedContent)
}
}
@@ -0,0 +1,229 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestDriveVersionHistoryDryRun(t *testing.T) {
setDriveDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+version-history",
"--file-token", "boxcnHistoryDryRun",
"--limit", "5",
"--cursor", "1777013761763",
"--dry-run",
},
DefaultAs: "bot",
BinaryPath: "../../../lark-cli",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
output := strings.TrimSpace(result.Stdout)
assert.Contains(t, output, "/open-apis/drive/v1/files/boxcnHistoryDryRun/history")
assert.Contains(t, output, `"only_tag": true`)
assert.Contains(t, output, `"page_size": 5`)
assert.Contains(t, output, `"last_edit_time": "1777013761763"`)
}
func TestDriveVersionGetDryRun(t *testing.T) {
setDriveDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+version-get",
"--file-token", "boxcnVersionDryRun",
"--version", "7633658129540910621",
"--output", "./artifact.bin",
"--dry-run",
},
DefaultAs: "bot",
BinaryPath: "../../../lark-cli",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
output := strings.TrimSpace(result.Stdout)
assert.Contains(t, output, "/open-apis/drive/v1/files/boxcnVersionDryRun/download")
assert.Contains(t, output, `"version": "7633658129540910621"`)
assert.Contains(t, output, `"output": "./artifact.bin"`)
}
func TestDriveVersionGetDryRunWithoutOutputUsesCurrentDirectory(t *testing.T) {
setDriveDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+version-get",
"--file-token", "boxcnVersionDryRun",
"--version", "7633658129540910621",
"--dry-run",
},
DefaultAs: "bot",
BinaryPath: "../../../lark-cli",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
output := strings.TrimSpace(result.Stdout)
assert.Contains(t, output, "/open-apis/drive/v1/files/boxcnVersionDryRun/download")
assert.Contains(t, output, `"version": "7633658129540910621"`)
assert.Contains(t, output, `"output": "."`)
}
func TestDriveVersionRevertDryRun(t *testing.T) {
setDriveDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+version-revert",
"--file-token", "boxcnVersionDryRun",
"--version", "7633658129540910621",
"--dry-run",
},
DefaultAs: "bot",
BinaryPath: "../../../lark-cli",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
output := strings.TrimSpace(result.Stdout)
assert.Contains(t, output, "/open-apis/drive/v1/files/boxcnVersionDryRun/revert")
assert.Contains(t, output, `"version": "7633658129540910621"`)
}
func TestDriveVersionDeleteDryRun(t *testing.T) {
setDriveDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+version-delete",
"--file-token", "boxcnVersionDryRun",
"--version", "7633658129540910621",
"--dry-run",
},
DefaultAs: "bot",
BinaryPath: "../../../lark-cli",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
output := strings.TrimSpace(result.Stdout)
assert.Contains(t, output, "/open-apis/drive/v1/files/boxcnVersionDryRun/version_del")
assert.Contains(t, output, `"version": "7633658129540910621"`)
}
func TestDriveVersionDryRunSupportsUser(t *testing.T) {
clie2e.SkipWithoutUserToken(t)
setDriveDryRunConfigEnv(t)
tests := []struct {
name string
args []string
wantContains []string
}{
{
name: "history",
args: []string{
"drive", "+version-history",
"--file-token", "boxcnHistoryDryRunUser",
"--limit", "5",
"--cursor", "1777013761763",
"--dry-run",
},
wantContains: []string{
"/open-apis/drive/v1/files/boxcnHistoryDryRunUser/history",
`"only_tag": true`,
`"page_size": 5`,
},
},
{
name: "get",
args: []string{
"drive", "+version-get",
"--file-token", "boxcnVersionDryRunUser",
"--version", "7633658129540910621",
"--output", "./artifact-user.bin",
"--dry-run",
},
wantContains: []string{
"/open-apis/drive/v1/files/boxcnVersionDryRunUser/download",
`"version": "7633658129540910621"`,
`"output": "./artifact-user.bin"`,
},
},
{
name: "revert",
args: []string{
"drive", "+version-revert",
"--file-token", "boxcnVersionDryRunUser",
"--version", "7633658129540910621",
"--dry-run",
},
wantContains: []string{
"/open-apis/drive/v1/files/boxcnVersionDryRunUser/revert",
`"version": "7633658129540910621"`,
},
},
{
name: "delete",
args: []string{
"drive", "+version-delete",
"--file-token", "boxcnVersionDryRunUser",
"--version", "7633658129540910621",
"--dry-run",
},
wantContains: []string{
"/open-apis/drive/v1/files/boxcnVersionDryRunUser/version_del",
`"version": "7633658129540910621"`,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: tt.args,
DefaultAs: "user",
BinaryPath: "../../../lark-cli",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
output := strings.TrimSpace(result.Stdout)
for _, needle := range tt.wantContains {
assert.Contains(t, output, needle)
}
})
}
}
@@ -0,0 +1,234 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"os"
"path/filepath"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestDriveVersionWorkflow(t *testing.T) {
if os.Getenv("LARK_DRIVE_VERSION_E2E") == "" {
t.Skip("set LARK_DRIVE_VERSION_E2E=1 to run drive version live workflow")
}
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
t.Cleanup(cancel)
suffix := clie2e.GenerateSuffix()
fileName := "lark-cli-version-workflow-" + suffix + ".md"
createResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"markdown", "+create",
"--name", fileName,
"--content", "# v1\n",
},
DefaultAs: "bot",
BinaryPath: "../../../lark-cli",
})
require.NoError(t, err)
createResult.AssertExitCode(t, 0)
createResult.AssertStdoutStatus(t, true)
fileToken := gjson.Get(createResult.Stdout, "data.file_token").String()
require.NotEmpty(t, fileToken, "stdout:\n%s", createResult.Stdout)
parentT.Cleanup(func() {
cleanupCtx, cleanupCancel := clie2e.CleanupContext()
defer cleanupCancel()
deleteResult, deleteErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{
Args: []string{
"drive", "+delete",
"--file-token", fileToken,
"--type", "file",
"--yes",
},
DefaultAs: "bot",
BinaryPath: "../../../lark-cli",
})
clie2e.ReportCleanupFailure(parentT, "delete version workflow file "+fileToken, deleteResult, deleteErr)
})
overwriteResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"markdown", "+overwrite",
"--file-token", fileToken,
"--content", "# v2\n",
},
DefaultAs: "bot",
BinaryPath: "../../../lark-cli",
})
require.NoError(t, err)
overwriteResult.AssertExitCode(t, 0)
overwriteResult.AssertStdoutStatus(t, true)
overwriteResult, err = clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"markdown", "+overwrite",
"--file-token", fileToken,
"--content", "# v3\n",
},
DefaultAs: "bot",
BinaryPath: "../../../lark-cli",
})
require.NoError(t, err)
overwriteResult.AssertExitCode(t, 0)
overwriteResult.AssertStdoutStatus(t, true)
historyResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+version-history",
"--file-token", fileToken,
},
DefaultAs: "bot",
BinaryPath: "../../../lark-cli",
})
require.NoError(t, err)
historyResult.AssertExitCode(t, 0)
historyResult.AssertStdoutStatus(t, true)
versions := gjson.Get(historyResult.Stdout, "data.versions").Array()
require.GreaterOrEqual(t, len(versions), 3, "stdout:\n%s", historyResult.Stdout)
var (
versionToDownload string
versionV1 string
versionV2 string
)
for _, version := range versions {
versionID := version.Get("version").String()
if versionID == "" {
continue
}
downloadDir := t.TempDir()
downloadResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+version-get",
"--file-token", fileToken,
"--version", versionID,
},
DefaultAs: "bot",
BinaryPath: "../../../lark-cli",
WorkDir: downloadDir,
})
require.NoError(t, err)
downloadResult.AssertExitCode(t, 0)
downloadResult.AssertStdoutStatus(t, true)
downloadedPath := filepath.Join(downloadDir, fileName)
body, err := os.ReadFile(downloadedPath)
require.NoError(t, err)
switch string(body) {
case "# v1\n":
versionV1 = versionID
case "# v2\n":
versionV2 = versionID
}
if versionToDownload == "" {
versionToDownload = versionID
}
}
require.NotEmpty(t, versionToDownload, "stdout:\n%s", historyResult.Stdout)
require.NotEmpty(t, versionV1, "stdout:\n%s", historyResult.Stdout)
require.NotEmpty(t, versionV2, "stdout:\n%s", historyResult.Stdout)
downloadDir := t.TempDir()
downloadResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+version-get",
"--file-token", fileToken,
"--version", versionToDownload,
},
DefaultAs: "bot",
BinaryPath: "../../../lark-cli",
WorkDir: downloadDir,
})
require.NoError(t, err)
downloadResult.AssertExitCode(t, 0)
downloadResult.AssertStdoutStatus(t, true)
downloadedPath := filepath.Join(downloadDir, fileName)
if _, err := os.Stat(downloadedPath); err != nil {
t.Fatalf("expected downloaded version at %q: %v", downloadedPath, err)
}
revertResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+version-revert",
"--file-token", fileToken,
"--version", versionV1,
},
DefaultAs: "bot",
BinaryPath: "../../../lark-cli",
})
require.NoError(t, err)
revertResult.AssertExitCode(t, 0)
revertResult.AssertStdoutStatus(t, true)
fetchResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"markdown", "+fetch",
"--file-token", fileToken,
},
DefaultAs: "bot",
BinaryPath: "../../../lark-cli",
})
require.NoError(t, err)
fetchResult.AssertExitCode(t, 0)
fetchResult.AssertStdoutStatus(t, true)
if got := gjson.Get(fetchResult.Stdout, "data.content").String(); got != "# v1\n" {
t.Fatalf("markdown content after revert = %q, want %q\nstdout:\n%s", got, "# v1\n", fetchResult.Stdout)
}
deleteResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+version-delete",
"--file-token", fileToken,
"--version", versionV2,
},
DefaultAs: "bot",
BinaryPath: "../../../lark-cli",
Yes: true,
})
require.NoError(t, err)
deleteResult.AssertExitCode(t, 0)
deleteResult.AssertStdoutStatus(t, true)
historyAfterDelete, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+version-history",
"--file-token", fileToken,
},
DefaultAs: "bot",
BinaryPath: "../../../lark-cli",
})
require.NoError(t, err)
historyAfterDelete.AssertExitCode(t, 0)
historyAfterDelete.AssertStdoutStatus(t, true)
foundDeletedVersion := false
for _, version := range gjson.Get(historyAfterDelete.Stdout, "data.versions").Array() {
if version.Get("version").String() != versionV2 {
continue
}
foundDeletedVersion = true
if !version.Get("is_deleted").Bool() {
t.Fatalf("version %s should be marked deleted after +version-delete\nstdout:\n%s", versionV2, historyAfterDelete.Stdout)
}
}
if !foundDeletedVersion {
t.Fatalf("deleted version %s not found in history after delete\nstdout:\n%s", versionV2, historyAfterDelete.Stdout)
}
}
+172
View File
@@ -0,0 +1,172 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"fmt"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/tidwall/gjson"
)
const (
driveDeleteVisibilityTimeout = 30 * time.Second
driveDeleteVisibilityPoll = 3 * time.Second
)
var driveDeleteVisibilityWait = clie2e.WaitOptions{
// This wait only covers the post-delete visibility lag after Drive accepts
// deletion. The delete command itself is bounded by clie2e.CleanupContext.
Timeout: driveDeleteVisibilityTimeout,
Interval: driveDeleteVisibilityPoll,
}
// CreateDriveFolder creates a Drive folder, optionally under a parent folder, and
// deletes it during parent cleanup.
func CreateDriveFolder(t *testing.T, parentT *testing.T, ctx context.Context, name string, defaultAs string, parentFolderToken string) string {
t.Helper()
if defaultAs == "" {
defaultAs = "bot"
}
args := []string{"drive", "+create-folder", "--name", name}
if parentFolderToken != "" {
args = append(args, "--folder-token", parentFolderToken)
}
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: args,
DefaultAs: defaultAs,
})
if err != nil {
t.Fatalf("create drive folder %q: %v", name, err)
}
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
folderToken := gjson.Get(result.Stdout, "data.folder_token").String()
if folderToken == "" {
t.Fatalf("drive folder token should not be empty, stdout:\n%s", result.Stdout)
}
parentT.Cleanup(func() {
cleanupCtx, cancel := clie2e.CleanupContext()
defer cancel()
deleteResult, deleteErr := DeleteDriveResourceAndVerify(cleanupCtx, folderToken, "folder", defaultAs)
clie2e.ReportCleanupFailure(parentT, "delete drive folder "+folderToken, deleteResult, deleteErr)
})
return folderToken
}
// DeleteDriveResourceAndVerify deletes a Drive-backed resource, then polls
// drive meta until the token is either gone or no longer has an accessible URL.
// This prevents cleanup from looking successful when the delete command
// returned a suppressed not_found or partial API error but the resource still
// exists.
func DeleteDriveResourceAndVerify(ctx context.Context, token, docType, defaultAs string) (*clie2e.Result, error) {
return deleteDriveResourceAndVerify(ctx, token, docType, defaultAs, driveDeleteVisibilityWait)
}
func deleteDriveResourceAndVerify(ctx context.Context, token, docType, defaultAs string, visibilityWait clie2e.WaitOptions) (*clie2e.Result, error) {
if defaultAs == "" {
defaultAs = "bot"
}
deleteResult, deleteErr := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"drive", "+delete", "--file-token", token, "--type", docType, "--yes"},
DefaultAs: defaultAs,
})
if deleteErr != nil || deleteResult == nil {
return deleteResult, deleteErr
}
if deleteResult.ExitCode != 0 {
deleted, verifyErr := IsDriveResourceDeleted(ctx, token, docType, defaultAs)
if verifyErr != nil {
return deleteResult, verifyErr
}
if deleted {
deleteResult.ExitCode = 0
return deleteResult, nil
}
return deleteResult, fmt.Errorf("drive resource %s/%s still exists after delete failed: exit=%d stdout=%s stderr=%s", docType, token, deleteResult.ExitCode, deleteResult.Stdout, deleteResult.Stderr)
}
if err := waitDriveResourceDeleted(ctx, token, docType, defaultAs, visibilityWait); err != nil {
return deleteResult, clie2e.CleanupWarning(
fmt.Errorf("drive resource %s/%s still visible after accepted delete: %w", docType, token, err),
)
}
return deleteResult, nil
}
func waitDriveResourceDeleted(ctx context.Context, token, docType, defaultAs string, opts clie2e.WaitOptions) error {
opts.TimeoutError = func() error {
return fmt.Errorf("drive resource %s/%s still exists %s after delete", docType, token, opts.Timeout)
}
return clie2e.WaitForCondition(ctx, opts, func() (bool, error) {
return IsDriveResourceDeleted(ctx, token, docType, defaultAs)
})
}
func IsDriveResourceDeleted(ctx context.Context, token, docType, defaultAs string) (bool, error) {
if defaultAs == "" {
defaultAs = "bot"
}
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"api", "post", "/open-apis/drive/v1/metas/batch_query"},
DefaultAs: defaultAs,
Data: map[string]any{
"request_docs": []map[string]any{{
"doc_token": token,
"doc_type": docType,
}},
},
})
if err != nil {
return false, err
}
if result.ExitCode != 0 {
combined := strings.ToLower(result.Stdout + "\n" + result.Stderr)
if strings.Contains(combined, "not found") || strings.Contains(combined, "404") {
return true, nil
}
return false, fmt.Errorf("verify drive resource %s/%s after delete: exit=%d stdout=%s stderr=%s", docType, token, result.ExitCode, result.Stdout, result.Stderr)
}
if !isDriveMetaQuerySuccessful(result.Stdout) {
return false, fmt.Errorf("verify drive resource %s/%s after delete returned unsuccessful envelope: stdout=%s stderr=%s", docType, token, result.Stdout, result.Stderr)
}
metas := gjson.Get(result.Stdout, "data.metas").Array()
if len(metas) == 0 {
return true, nil
}
if docType != "folder" {
allURLsCleared := true
for _, meta := range metas {
if meta.Get("url").String() != "" {
allURLsCleared = false
break
}
}
if allURLsCleared {
return true, nil
}
}
return false, nil
}
func isDriveMetaQuerySuccessful(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
}
+79
View File
@@ -0,0 +1,79 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"os"
"path/filepath"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func createDriveFolder(t *testing.T, parentT *testing.T, ctx context.Context, name string, parentFolderToken string) string {
t.Helper()
folderToken := CreateDriveFolder(t, parentT, ctx, name, "bot", parentFolderToken)
require.NotEmpty(t, folderToken)
return folderToken
}
func TestDeleteDriveResourceAndVerify(t *testing.T) {
t.Run("successful delete with stale meta returns cleanup warning", func(t *testing.T) {
fake := mustWriteDriveCleanupFakeCLI(t)
t.Setenv(clie2e.EnvBinaryPath, fake)
result, err := deleteDriveResourceAndVerify(context.Background(), "fld_stale", "folder", "bot", clie2e.WaitOptions{
Timeout: 10 * time.Millisecond,
Interval: time.Millisecond,
})
require.NotNil(t, result)
assert.Equal(t, 0, result.ExitCode)
require.Error(t, err)
assert.True(t, clie2e.IsCleanupWarning(err), "err: %v", err)
})
t.Run("failed delete with existing meta remains fatal", func(t *testing.T) {
fake := mustWriteDriveCleanupFakeCLI(t)
t.Setenv(clie2e.EnvBinaryPath, fake)
t.Setenv("FAKE_DRIVE_DELETE_EXIT", "1")
result, err := DeleteDriveResourceAndVerify(context.Background(), "fld_existing", "folder", "bot")
require.NotNil(t, result)
assert.Equal(t, 1, result.ExitCode)
require.Error(t, err)
assert.False(t, clie2e.IsCleanupWarning(err), "err: %v", err)
assert.Contains(t, err.Error(), "still exists after delete failed")
})
}
func mustWriteDriveCleanupFakeCLI(t *testing.T) string {
t.Helper()
script := `#!/bin/sh
if [ "$1" = "drive" ] && [ "$2" = "+delete" ]; then
if [ "${FAKE_DRIVE_DELETE_EXIT:-0}" != "0" ]; then
echo '{"ok":false,"error":{"type":"api","message":"delete failed"}}' >&2
exit "$FAKE_DRIVE_DELETE_EXIT"
fi
echo '{"ok":true}'
exit 0
fi
if [ "$1" = "api" ] && [ "$2" = "post" ] && [ "$3" = "/open-apis/drive/v1/metas/batch_query" ]; then
echo '{"ok":true,"data":{"metas":[{"url":"https://example.com/still-visible"}]}}'
exit 0
fi
echo "unexpected fake CLI args: $*" >&2
exit 2
`
binaryPath := filepath.Join(t.TempDir(), "fake-lark-cli")
require.NoError(t, os.WriteFile(binaryPath, []byte(script), 0o755))
return binaryPath
}
@@ -0,0 +1,41 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"context"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestEventConsumeUnknownKeyRegression locks the typed error envelope emitted
// on stderr when `event consume` rejects an unknown EventKey. The lookup fails
// before any daemon fork or network access, so the test needs no credentials.
func TestEventConsumeUnknownKeyRegression(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"event", "consume", "bogus.key"},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
errJSON := gjson.Get(result.Stderr, "error")
require.True(t, errJSON.Exists(), "stderr missing 'error' JSON envelope\nstderr:\n%s", result.Stderr)
require.Equal(t, "validation", errJSON.Get("type").String(), "stderr:\n%s", result.Stderr)
require.Equal(t, "invalid_argument", errJSON.Get("subtype").String(), "stderr:\n%s", result.Stderr)
require.Contains(t, errJSON.Get("message").String(), "unknown EventKey: bogus.key", "stderr:\n%s", result.Stderr)
require.Contains(t, errJSON.Get("hint").String(), "event list", "stderr:\n%s", result.Stderr)
}
@@ -0,0 +1,46 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"context"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestEventSubscribeDryRun(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"event", "+subscribe",
"--event-types", "im.message.receive_v1,contact.user.created_v3",
"--filter", "^im\\.",
"--output-dir", "events_out",
"--route", "^im\\.message=dir:./messages",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
require.Equal(t, "event +subscribe", gjson.Get(out, "command").String(), "stdout:\n%s", out)
require.Equal(t, "app", gjson.Get(out, "app_id").String(), "stdout:\n%s", out)
require.Equal(t, "im.message.receive_v1,contact.user.created_v3", gjson.Get(out, "event_types").String(), "stdout:\n%s", out)
require.Equal(t, "^im\\.", gjson.Get(out, "filter").String(), "stdout:\n%s", out)
require.Equal(t, "events_out", gjson.Get(out, "output_dir").String(), "stdout:\n%s", out)
require.Equal(t, "^im\\.message=dir:./messages", gjson.Get(out, "route").String(), "stdout:\n%s", out)
}
@@ -0,0 +1,47 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"context"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestEventSubscribeInvalidRouteRegression locks the typed error envelope
// emitted on stderr when +subscribe route parsing rejects user input. Route
// validation fails before any WebSocket connection is opened, so the test
// needs no credentials or network.
func TestEventSubscribeInvalidRouteRegression(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"event", "+subscribe",
"--force",
"--route", "no-equals-sign",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
errJSON := gjson.Get(result.Stderr, "error")
require.True(t, errJSON.Exists(), "stderr missing 'error' JSON envelope\nstderr:\n%s", result.Stderr)
require.Equal(t, "validation", errJSON.Get("type").String(), "stderr:\n%s", result.Stderr)
require.Equal(t, "invalid_argument", errJSON.Get("subtype").String(), "stderr:\n%s", result.Stderr)
require.Equal(t, "--route", errJSON.Get("param").String(), "stderr:\n%s", result.Stderr)
require.Equal(t, `invalid --route "no-equals-sign": expected format regex=dir:./path`,
errJSON.Get("message").String(), "stderr:\n%s", result.Stderr)
}
@@ -0,0 +1,78 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestIM_ChatMessageWorkflowAsUser(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
clie2e.SkipWithoutUserToken(t)
parentT := t
suffix := clie2e.GenerateSuffix()
chatName := "im-chat-" + suffix
messageText := "im-chat-msg-" + suffix
var chatID string
var messageID string
t.Run("create chat as user", func(t *testing.T) {
chatID = createChatAs(t, parentT, ctx, chatName, "user")
})
t.Run("send message as user", func(t *testing.T) {
messageID = sendMessageAs(t, ctx, chatID, messageText, "user")
})
t.Run("list chat messages as user", func(t *testing.T) {
startTime := time.Now().UTC().Add(-10 * time.Minute).Format(time.RFC3339)
endTime := time.Now().UTC().Add(10 * time.Minute).Format(time.RFC3339)
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{
"im", "+chat-messages-list",
"--chat-id", chatID,
"--start", startTime,
"--end", endTime,
},
DefaultAs: "user",
}, clie2e.RetryOptions{
ShouldRetry: func(result *clie2e.Result) bool {
if result == nil || result.ExitCode != 0 {
return true
}
for _, item := range gjson.Get(result.Stdout, "data.messages").Array() {
if item.Get("message_id").String() == messageID && strings.Contains(item.Get("content").String(), messageText) {
return false
}
}
return true
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
var found bool
for _, item := range gjson.Get(result.Stdout, "data.messages").Array() {
if item.Get("message_id").String() != messageID {
continue
}
require.True(t, strings.Contains(item.Get("content").String(), messageText), "stdout:\n%s", result.Stdout)
found = true
break
}
require.True(t, found, "expected message %s in stdout:\n%s", messageID, result.Stdout)
})
}
+129
View File
@@ -0,0 +1,129 @@
// 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 as bot", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"im", "+chat-update",
"--chat-id", chatID,
"--name", updatedName,
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
})
t.Run("update chat description as bot", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"im", "+chat-update",
"--chat-id", chatID,
"--description", updatedDescription,
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
})
t.Run("get updated chat as bot", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"im", "chats", "get"},
DefaultAs: "bot",
Params: map[string]any{"chat_id": chatID},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
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 as bot", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"im", "chats", "get"},
DefaultAs: "bot",
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, true)
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 as bot", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"im", "chats", "link"},
DefaultAs: "bot",
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, true)
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)
})
}
+52
View File
@@ -0,0 +1,52 @@
# IM CLI E2E Coverage
## Metrics
- Denominator: 30 leaf commands
- Covered: 11
- Coverage: 36.7%
## Summary
- TestIM_ChatUpdateWorkflow: proves `im +chat-create`, `im +chat-update`, and `im chats get`; key `t.Run(...)` proof points are `update chat name as bot`, `update chat description as bot`, and `get updated chat as bot`.
- TestIM_ChatsGetWorkflow: proves `im chats get` on a fresh chat fixture via `get chat info as bot`.
- TestIM_ChatsLinkWorkflow: proves `im chats link` via `get chat share link as bot`.
- TestIM_ChatMessageWorkflowAsUser: proves the user chat message flow through `create chat as user`, `send message as user`, and `list chat messages as user` with the created message ID and content asserted from read-after-write output.
- TestIM_MessageGetWorkflowAsUser: proves user message readback through `batch get message as user` after creating a fresh chat and sending a unique message.
- TestIM_MessageReplyWorkflowAsBot: proves threaded reply flow through `reply to message in thread as bot` and `list thread replies as bot`, reading back the reply from `im +threads-messages-list`.
- TestIM_MessagesSendAudioDryRunRejectsNonOpus: proves the `im +messages-send --audio` dry-run validation rejects non-Opus local audio before upload, with typed validation metadata and recovery guidance.
- TestIM_MessageForwardWorkflowAsUser: proves UAT-backed API forwarding through `im messages forward` and `im threads forward` using a fresh message/thread fixture; skips the forward assertions when the current test app/UAT lacks IM forward permission.
- Blocked area: `im +chat-search` did not reliably return freshly created private chats in UAT, and `im +messages-search` did not reliably index freshly sent messages in time for a deterministic read-after-write assertion, so both remain uncovered.
## Command Table
| Status | Cmd | Type | Testcase | Key parameter shapes | Notes / uncovered reason |
| --- | --- | --- | --- | --- | --- |
| ✓ | im +chat-create | shortcut | im/chat_message_workflow_test.go::TestIM_ChatMessageWorkflowAsUser/create chat as user; im/chat_workflow_test.go::TestIM_ChatUpdateWorkflow; im/chat_workflow_test.go::TestIM_ChatsGetWorkflow; im/chat_workflow_test.go::TestIM_ChatsLinkWorkflow; im/message_get_workflow_test.go::TestIM_MessageGetWorkflowAsUser; im/message_reply_workflow_test.go::TestIM_MessageReplyWorkflowAsBot | `--name`; `--type private` | covered via workflow setup with created chat IDs asserted |
| ✓ | im +chat-messages-list | shortcut | im/chat_message_workflow_test.go::TestIM_ChatMessageWorkflowAsUser/list chat messages as user; im/message_reply_workflow_test.go::TestIM_MessageReplyWorkflowAsBot/list thread replies as bot | `--chat-id`; `--start`; `--end` | reads back created message and discovers thread ID |
| ✕ | im +chat-search | shortcut | | none | UAT did not reliably return freshly created private chats, so it is left uncovered |
| ✓ | im +chat-update | shortcut | im/chat_workflow_test.go::TestIM_ChatUpdateWorkflow/update chat name as bot; im/chat_workflow_test.go::TestIM_ChatUpdateWorkflow/update chat description as bot | `--chat-id`; `--name`; `--description` | |
| ✓ | im +messages-mget | shortcut | im/message_get_workflow_test.go::TestIM_MessageGetWorkflowAsUser/batch get message as user | `--message-ids` | verifies sent message content by ID |
| ✓ | im +messages-reply | shortcut | im/message_reply_workflow_test.go::TestIM_MessageReplyWorkflowAsBot/reply to message in thread as bot | `--message-id`; `--text`; `--reply-in-thread` | reply is read back via thread list |
| ✕ | im +messages-resources-download | shortcut | | none | needs a stable image/file message fixture plus file_key proof; left uncovered |
| ✕ | im +messages-search | shortcut | | none | freshly sent messages were not indexed deterministically in UAT time for a stable read-after-write proof |
| ✓ | im +messages-send | shortcut | im/chat_message_workflow_test.go::TestIM_ChatMessageWorkflowAsUser/send message as user; im/message_get_workflow_test.go::TestIM_MessageGetWorkflowAsUser; im/message_reply_workflow_test.go::TestIM_MessageReplyWorkflowAsBot; im/message_audio_dryrun_test.go::TestIM_MessagesSendAudioDryRunRejectsNonOpus | `--chat-id`; `--text`; `--audio ./voice.mp3 --dry-run` | live text sends feed follow-up reads; dry-run pins non-Opus audio validation before upload |
| ✓ | im +threads-messages-list | shortcut | im/message_reply_workflow_test.go::TestIM_MessageReplyWorkflowAsBot/list thread replies as bot | `--thread` | proves threaded reply is persisted |
| ✕ | im chat.members create | api | | none | no member mutation workflow yet |
| ✕ | im chat.members get | api | | none | no member get workflow yet |
| ✕ | im chats create | api | | none | only covered indirectly through `+chat-create` |
| ✓ | im chats get | api | im/chat_workflow_test.go::TestIM_ChatUpdateWorkflow/get updated chat as bot; im/chat_workflow_test.go::TestIM_ChatsGetWorkflow/get chat info as bot | `chat_id` in `--params` | |
| ✓ | im chats link | api | im/chat_workflow_test.go::TestIM_ChatsLinkWorkflow/get chat share link as bot | `chat_id` in `--params`; `validity_period` in `--data` | |
| ✕ | im chats list | api | | none | no chats list workflow yet |
| ✕ | im chats update | api | | none | only covered indirectly through `+chat-update` |
| ✕ | im images create | api | | none | no image upload workflow yet |
| ✕ | im messages delete | api | | none | no recall workflow yet |
| ✓ | im messages forward | api | im/message_forward_workflow_test.go::TestIM_MessageForwardWorkflowAsUser/forward message with api command as user | `message_id`; `receive_id_type`; `uuid`; `receive_id` | forwards a fresh message back into the test chat using UAT |
| ✕ | im messages merge_forward | api | | none | no merge-forward workflow yet |
| ✕ | im messages read_users | api | | none | no read-user workflow yet |
| ✓ | im threads forward | api | im/message_forward_workflow_test.go::TestIM_MessageForwardWorkflowAsUser/forward thread with api command as user | `thread_id`; `receive_id_type`; `uuid`; `receive_id` | forwards a fresh thread back into the test chat using UAT |
| ✕ | im pins create | api | | none | pin workflows not covered |
| ✕ | im pins delete | api | | none | pin workflows not covered |
| ✕ | im pins list | api | | none | pin workflows not covered |
| ✕ | im reactions batch_query | api | | none | reaction workflows not covered |
| ✕ | im reactions create | api | | none | reaction workflows not covered |
| ✕ | im reactions delete | api | | none | reaction workflows not covered |
| ✕ | im reactions list | api | | none | reaction workflows not covered |
@@ -0,0 +1,447 @@
// 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"
"github.com/tidwall/gjson"
)
// TestIM_FeedShortcutWorkflowAsUser walks the full create → list → remove
// loop for a single CHAT-type feed shortcut, mirroring the +flag-* workflow
// test. The feed_shortcuts API is user-identity only and version-locked, so
// the assertion strategy uses RunCmdWithRetry against the list endpoint
// rather than assuming the index is updated synchronously.
func TestIM_FeedShortcutWorkflowAsUser(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
clie2e.SkipWithoutUserToken(t)
parentT := t
suffix := clie2e.GenerateSuffix()
chatName := "im-feed-shortcut-" + suffix
var chatID string
t.Cleanup(func() {
cleanupFeedShortcuts(parentT, "user", chatID)
})
t.Run("create chat as user", func(t *testing.T) {
chatID = createChatAs(t, parentT, ctx, chatName, "user")
})
t.Run("pin chat to feed as user", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"im", "+feed-shortcut-create",
"--chat-id", chatID,
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
// failed_shortcuts may be absent (server returns {} on full success)
// or present-and-empty — either way it must not contain our chat.
for _, item := range gjson.Get(result.Stdout, "data.failed_shortcuts").Array() {
require.NotEqual(t, chatID, item.Get("shortcut.feed_card_id").String(),
"create should not report our chat as failed")
}
})
t.Run("list feed shortcuts as user with detail enrichment", func(t *testing.T) {
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{
"im", "+feed-shortcut-list",
},
DefaultAs: "user",
}, clie2e.RetryOptions{
ShouldRetry: func(result *clie2e.Result) bool {
if result == nil || result.ExitCode != 0 {
return true
}
for _, item := range gjson.Get(result.Stdout, "data.shortcuts").Array() {
if item.Get("feed_card_id").String() == chatID {
return false
}
}
return true
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
var found bool
for _, item := range gjson.Get(result.Stdout, "data.shortcuts").Array() {
if item.Get("feed_card_id").String() != chatID {
continue
}
found = true
require.Equal(t, int64(1), item.Get("type").Int(), "type should be 1 (CHAT)")
// detail enrichment is on by default — the chat we just created
// must come back with the chat info object attached.
require.True(t, item.Get("detail").Exists(),
"detail field should be attached when enrichment is enabled")
require.Equal(t, chatID, item.Get("detail.chat_id").String(),
"detail.chat_id should echo feed_card_id")
require.Equal(t, chatName, item.Get("detail.name").String(),
"detail.name should carry the chat's group name")
break
}
require.True(t, found, "expected chat %s in feed shortcut list", chatID)
})
t.Run("list feed shortcuts with --no-detail skips lookup", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"im", "+feed-shortcut-list",
"--no-detail",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
var foundEntry gjson.Result
for _, item := range gjson.Get(result.Stdout, "data.shortcuts").Array() {
if item.Get("feed_card_id").String() == chatID {
foundEntry = item
break
}
}
require.True(t, foundEntry.Exists(), "expected our chat in the bare list")
require.False(t, foundEntry.Get("detail").Exists(),
"detail field should NOT be present with --no-detail")
})
t.Run("unpin chat from feed as user", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"im", "+feed-shortcut-remove",
"--chat-id", chatID,
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
for _, item := range gjson.Get(result.Stdout, "data.failed_shortcuts").Array() {
require.NotEqual(t, chatID, item.Get("shortcut.feed_card_id").String(),
"remove should not report our chat as failed")
}
})
t.Run("verify chat removed from feed", func(t *testing.T) {
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{
"im", "+feed-shortcut-list",
"--no-detail",
},
DefaultAs: "user",
}, clie2e.RetryOptions{
ShouldRetry: func(result *clie2e.Result) bool {
if result == nil || result.ExitCode != 0 {
return true
}
for _, item := range gjson.Get(result.Stdout, "data.shortcuts").Array() {
if item.Get("feed_card_id").String() == chatID {
return true // still there, retry
}
}
return false
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
for _, item := range gjson.Get(result.Stdout, "data.shortcuts").Array() {
require.NotEqual(t, chatID, item.Get("feed_card_id").String(),
"chat should not be in feed list after remove")
}
})
}
// TestIM_FeedShortcutBatchAsUser exercises batch create / remove with two
// chats in a single API call. The per-item failure path (failed_shortcuts)
// is checked by mixing a real chat with an obviously invalid id.
func TestIM_FeedShortcutBatchAsUser(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
clie2e.SkipWithoutUserToken(t)
parentT := t
suffix := clie2e.GenerateSuffix()
var chatA, chatB string
t.Cleanup(func() {
cleanupFeedShortcuts(parentT, "user", chatA, chatB)
})
t.Run("create two chats as user", func(t *testing.T) {
chatA = createChatAs(t, parentT, ctx, "im-feed-batch-a-"+suffix, "user")
chatB = createChatAs(t, parentT, ctx, "im-feed-batch-b-"+suffix, "user")
})
t.Run("batch pin both with --tail", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"im", "+feed-shortcut-create",
"--chat-id", chatA + "," + chatB,
"--tail",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
})
t.Run("batch pin with one invalid id reports per-item failure", func(t *testing.T) {
// Re-pinning chatA (already pinned) should still succeed without
// noting it as failure; the synthetic invalid id should land in
// failed_shortcuts with reason_label=invalid_item.
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"im", "+feed-shortcut-create",
"--chat-id", chatA,
"--chat-id", "oc_definitely_not_a_real_chat_id_" + suffix,
},
DefaultAs: "user",
})
require.NoError(t, err)
// Partial failure exits with the generic API failure code while the
// full ledger stays machine-readable on stdout.
result.AssertExitCode(t, 1)
result.AssertStdoutStatus(t, false)
require.Equal(t, int64(2), gjson.Get(result.Stdout, "data.total").Int())
require.Equal(t, int64(1), gjson.Get(result.Stdout, "data.success_count").Int())
require.Equal(t, int64(1), gjson.Get(result.Stdout, "data.failure_count").Int())
var sawInvalid bool
for _, item := range gjson.Get(result.Stdout, "data.failed_shortcuts").Array() {
require.NotEqual(t, chatA, item.Get("shortcut.feed_card_id").String(),
"real chat should not appear in failed_shortcuts")
if item.Get("shortcut.feed_card_id").String() == "oc_definitely_not_a_real_chat_id_"+suffix {
sawInvalid = true
require.Equal(t, "invalid_item", item.Get("reason_label").String(),
"invalid id should be labeled invalid_item")
}
}
require.True(t, sawInvalid, "expected the bogus chat id in failed_shortcuts")
var sawSucceeded bool
for _, item := range gjson.Get(result.Stdout, "data.succeeded_shortcuts").Array() {
if item.Get("feed_card_id").String() == chatA {
sawSucceeded = true
require.Equal(t, int64(1), item.Get("type").Int(), "succeeded shortcut type should be CHAT")
}
}
require.True(t, sawSucceeded, "expected the real chat id in succeeded_shortcuts")
})
t.Run("batch remove both", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"im", "+feed-shortcut-remove",
"--chat-id", chatA,
"--chat-id", chatB,
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
})
}
func cleanupFeedShortcuts(parentT *testing.T, defaultAs string, chatIDs ...string) {
parentT.Helper()
var ids []string
for _, id := range chatIDs {
if id != "" {
ids = append(ids, id)
}
}
if len(ids) == 0 {
return
}
cleanupCtx, cancel := clie2e.CleanupContext()
defer cancel()
listResult, listErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{
Args: []string{"im", "+feed-shortcut-list", "--no-detail"},
DefaultAs: defaultAs,
})
clie2e.ReportCleanupFailure(parentT, "cleanup feed shortcuts list", listResult, listErr)
if listErr != nil || listResult == nil || listResult.ExitCode != 0 {
return
}
present := make(map[string]bool, len(ids))
for _, id := range ids {
present[id] = false
}
for _, item := range gjson.Get(listResult.Stdout, "data.shortcuts").Array() {
id := item.Get("feed_card_id").String()
if _, ok := present[id]; ok {
present[id] = true
}
}
args := []string{"im", "+feed-shortcut-remove"}
for _, id := range ids {
if present[id] {
args = append(args, "--chat-id", id)
}
}
if len(args) == 2 {
return
}
result, err := clie2e.RunCmd(cleanupCtx, clie2e.Request{
Args: args,
DefaultAs: defaultAs,
})
clie2e.ReportCleanupFailure(parentT, "cleanup feed shortcuts", result, err)
}
// TestIM_FeedShortcutDryRun covers all three shortcuts in --dry-run mode
// using env-only identity hints. strict_mode/default_as lock the command to
// user identity without injecting a fake user token that would trigger
// user_info verification during startup.
func TestIM_FeedShortcutDryRun(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
t.Setenv("LARKSUITE_CLI_STRICT_MODE", "user")
t.Setenv("LARKSUITE_CLI_DEFAULT_AS", "user")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
t.Run("create dry-run", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"im", "+feed-shortcut-create",
"--chat-id", "oc_test_dry_run",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
require.Contains(t, result.Stdout, "POST")
require.Contains(t, result.Stdout, "/open-apis/im/v2/feed_shortcuts")
require.Contains(t, result.Stdout, "oc_test_dry_run")
// --head is the default so the body should be is_header=true
require.Contains(t, result.Stdout, `"is_header"`)
})
t.Run("create dry-run with --tail flips is_header to false", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"im", "+feed-shortcut-create",
"--chat-id", "oc_test_dry_run",
"--tail",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
require.Contains(t, result.Stdout, `"is_header": false`)
})
t.Run("create dry-run rejects --head + --tail", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"im", "+feed-shortcut-create",
"--chat-id", "oc_test_dry_run",
"--head",
"--tail",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
// Validation errors exit 2 with the structured error envelope;
// assert against combined output to stay channel-agnostic.
result.AssertExitCode(t, 2)
combined := result.Stdout + "\n" + result.Stderr
require.Contains(t, combined, "mutually exclusive")
})
t.Run("create dry-run rejects non-oc chat ids", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"im", "+feed-shortcut-create",
"--chat-id", "om_not_a_chat",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
combined := result.Stdout + "\n" + result.Stderr
require.Contains(t, combined, "must be an open_chat_id")
})
t.Run("remove dry-run hits /remove endpoint", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"im", "+feed-shortcut-remove",
"--chat-id", "oc_a,oc_b",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
require.Contains(t, result.Stdout, "POST")
require.Contains(t, result.Stdout, "/open-apis/im/v2/feed_shortcuts/remove")
require.Contains(t, result.Stdout, "oc_a")
require.Contains(t, result.Stdout, "oc_b")
require.NotContains(t, result.Stdout, "is_header", "remove must not send is_header")
})
t.Run("list dry-run mentions detail enrichment path", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"im", "+feed-shortcut-list",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
require.Contains(t, result.Stdout, "GET")
require.Contains(t, result.Stdout, "/open-apis/im/v2/feed_shortcuts")
// Enrichment is on by default → DryRun adds a desc about the extra
// chats.batch_query call and the conditional scope.
require.Contains(t, result.Stdout, "im:chat:read")
require.Contains(t, result.Stdout, "batch_query")
})
t.Run("list dry-run with --no-detail omits the extra-scope note", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"im", "+feed-shortcut-list",
"--no-detail",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
require.NotContains(t, result.Stdout, "im:chat:read",
"with --no-detail, dry-run must not mention im:chat:read")
})
}
+305
View File
@@ -0,0 +1,305 @@
// 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"
"github.com/tidwall/gjson"
)
func TestIM_FlagWorkflowAsUser(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
clie2e.SkipWithoutUserToken(t)
parentT := t
suffix := clie2e.GenerateSuffix()
chatName := "im-flag-" + suffix
messageText := "flag-test-msg-" + suffix
var chatID string
var messageID string
t.Run("create chat as user", func(t *testing.T) {
chatID = createChatAs(t, parentT, ctx, chatName, "user")
})
t.Run("send message as user", func(t *testing.T) {
messageID = sendMessageAs(t, ctx, chatID, messageText, "user")
})
t.Run("create flag as user", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"im", "+flag-create",
"--message-id", messageID,
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
})
t.Run("list flags as user", func(t *testing.T) {
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{
"im", "+flag-list",
"--page-size", "10",
"--page-all",
},
DefaultAs: "user",
}, clie2e.RetryOptions{
ShouldRetry: func(result *clie2e.Result) bool {
if result == nil || result.ExitCode != 0 {
return true
}
// Check if our message is in the list
for _, item := range gjson.Get(result.Stdout, "data.flag_items").Array() {
if item.Get("item_id").String() == messageID {
return false
}
}
return true
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
// Verify our flagged message is in the list
var found bool
for _, item := range gjson.Get(result.Stdout, "data.flag_items").Array() {
if item.Get("item_id").String() == messageID {
found = true
// Verify it's a message-type flag (flag_type=2)
require.Equal(t, "2", item.Get("flag_type").String(), "expected flag_type=2 (message)")
break
}
}
require.True(t, found, "expected message %s in flag list", messageID)
})
t.Run("cancel flag as user", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"im", "+flag-cancel",
"--message-id", messageID,
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
})
t.Run("verify flag removed", func(t *testing.T) {
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{
"im", "+flag-list",
"--page-size", "10",
"--page-all",
},
DefaultAs: "user",
}, clie2e.RetryOptions{
ShouldRetry: func(result *clie2e.Result) bool {
if result == nil || result.ExitCode != 0 {
return true
}
// Check if our message is still in the list
for _, item := range gjson.Get(result.Stdout, "data.flag_items").Array() {
if item.Get("item_id").String() == messageID {
return true // Still there, retry
}
}
return false // Not found, success
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
// Verify our message is NOT in the list
for _, item := range gjson.Get(result.Stdout, "data.flag_items").Array() {
require.NotEqual(t, messageID, item.Get("item_id").String(), "message should not be in flag list after cancel")
}
})
}
func TestIM_FlagCreateWithExplicitTypeAsUser(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
clie2e.SkipWithoutUserToken(t)
parentT := t
suffix := clie2e.GenerateSuffix()
chatName := "im-flag-explicit-" + suffix
messageText := "flag-explicit-msg-" + suffix
var chatID string
var messageID string
t.Run("create chat as user", func(t *testing.T) {
chatID = createChatAs(t, parentT, ctx, chatName, "user")
})
t.Run("send message as user", func(t *testing.T) {
messageID = sendMessageAs(t, ctx, chatID, messageText, "user")
})
t.Run("create flag with explicit types as user", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"im", "+flag-create",
"--message-id", messageID,
"--item-type", "default",
"--flag-type", "message",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
})
t.Run("list flags to verify explicit types as user", func(t *testing.T) {
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{
"im", "+flag-list",
"--page-size", "10",
"--page-all",
},
DefaultAs: "user",
}, clie2e.RetryOptions{
ShouldRetry: func(result *clie2e.Result) bool {
if result == nil || result.ExitCode != 0 {
return true
}
for _, item := range gjson.Get(result.Stdout, "data.flag_items").Array() {
if item.Get("item_id").String() == messageID {
return false
}
}
return true
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
// Verify explicit types were applied
var found bool
for _, item := range gjson.Get(result.Stdout, "data.flag_items").Array() {
if item.Get("item_id").String() == messageID {
found = true
require.Equal(t, "0", item.Get("item_type").String(), "expected item_type=0 (default)")
require.Equal(t, "2", item.Get("flag_type").String(), "expected flag_type=2 (message)")
break
}
}
require.True(t, found, "expected message %s in flag list", messageID)
})
t.Run("cancel flag with explicit types as user", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"im", "+flag-cancel",
"--message-id", messageID,
"--flag-type", "message",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
})
}
func TestIM_FlagListPaginationAsUser(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
clie2e.SkipWithoutUserToken(t)
t.Run("list flags with page-all as user", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"im", "+flag-list",
"--page-size", "5",
"--page-all",
"--page-limit", "3",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
})
}
func TestIM_FlagDryRun(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
t.Setenv("LARKSUITE_CLI_STRICT_MODE", "user")
t.Setenv("LARKSUITE_CLI_DEFAULT_AS", "user")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
t.Run("create flag dry-run", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"im", "+flag-create",
"--message-id", "om_test_dry_run",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
require.Contains(t, result.Stdout, "POST")
require.Contains(t, result.Stdout, "/open-apis/im/v1/flags")
require.Contains(t, result.Stdout, "flag_items")
require.Contains(t, result.Stdout, "om_test_dry_run")
})
t.Run("cancel flag dry-run with om", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"im", "+flag-cancel",
"--message-id", "om_test_dry_run",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
require.Contains(t, result.Stdout, "POST")
require.Contains(t, result.Stdout, "/open-apis/im/v1/flags/cancel")
require.Contains(t, result.Stdout, "flag_items")
require.Contains(t, result.Stdout, "om_test_dry_run")
})
t.Run("list flag dry-run", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"im", "+flag-list",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
require.Contains(t, result.Stdout, "GET")
require.Contains(t, result.Stdout, "/open-apis/im/v1/flags")
require.Contains(t, result.Stdout, "page_size")
})
}
+72
View File
@@ -0,0 +1,72 @@
// 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()
return createChatAs(t, parentT, ctx, name, "bot")
}
func createChatAs(t *testing.T, parentT *testing.T, ctx context.Context, name string, defaultAs string) string {
t.Helper()
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"im", "+chat-create",
"--name", name,
"--type", "private",
},
DefaultAs: defaultAs,
})
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, ctx context.Context, chatID string, text string) string {
t.Helper()
return sendMessageAs(t, ctx, chatID, text, "bot")
}
func sendMessageAs(t *testing.T, ctx context.Context, chatID string, text string, defaultAs string) string {
t.Helper()
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"im", "+messages-send",
"--chat-id", chatID,
"--text", text,
},
DefaultAs: defaultAs,
})
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
}
@@ -0,0 +1,59 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestIM_DownloadResourcesDryRun verifies the --download-resources flag is wired
// into +chat-messages-list without breaking the existing request structure: the
// underlying GET /open-apis/im/v1/messages call is identical with or without the
// flag (AC4), and the flag only adds a resource-download declaration to dry-run.
func TestIM_DownloadResourcesDryRun(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
run := func(t *testing.T, extraArgs ...string) string {
t.Helper()
args := append([]string{
"im", "+chat-messages-list",
"--chat-id", "oc_dryrun",
"--no-reactions",
}, extraArgs...)
args = append(args, "--dry-run")
result, err := clie2e.RunCmd(ctx, clie2e.Request{Args: args, DefaultAs: "bot"})
require.NoError(t, err)
result.AssertExitCode(t, 0)
return result.Stdout
}
t.Run("default off: no resources declaration, request unchanged", func(t *testing.T) {
out := run(t)
require.Equal(t, "GET", gjson.Get(out, "api.0.method").String(), "stdout:\n%s", out)
require.Equal(t, "/open-apis/im/v1/messages", gjson.Get(out, "api.0.url").String(), "stdout:\n%s", out)
require.Equal(t, "oc_dryrun", gjson.Get(out, "api.0.params.container_id").String(), "stdout:\n%s", out)
require.NotContains(t, strings.ToLower(out), "lark-im-resources", "default must not declare resource download:\n%s", out)
})
t.Run("with --download-resources: request unchanged, declares download", func(t *testing.T) {
out := run(t, "--download-resources")
require.Equal(t, "GET", gjson.Get(out, "api.0.method").String(), "stdout:\n%s", out)
require.Equal(t, "/open-apis/im/v1/messages", gjson.Get(out, "api.0.url").String(), "stdout:\n%s", out)
require.Equal(t, "oc_dryrun", gjson.Get(out, "api.0.params.container_id").String(), "stdout:\n%s", out)
require.Contains(t, strings.ToLower(out), "lark-im-resources", "flag must declare resource download:\n%s", out)
})
}
@@ -0,0 +1,62 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestIM_MessagesSendAudioDryRunRejectsNonOpus(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "im_audio_dryrun_test")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "im_audio_dryrun_secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
workDir := t.TempDir()
audioPath := filepath.Join(workDir, "voice.mp3")
require.NoError(t, os.WriteFile(audioPath, []byte("not real mp3; validation checks extension before upload"), 0o600))
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"im", "+messages-send",
"--chat-id", "oc_123",
"--audio", "./voice.mp3",
"--dry-run",
},
DefaultAs: "bot",
WorkDir: workDir,
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
if got := gjson.Get(result.Stderr, "error.type").String(); got != "validation" {
t.Fatalf("error.type = %q, want validation\nstderr:\n%s", got, result.Stderr)
}
if got := gjson.Get(result.Stderr, "error.subtype").String(); got != "invalid_argument" {
t.Fatalf("error.subtype = %q, want invalid_argument\nstderr:\n%s", got, result.Stderr)
}
if got := gjson.Get(result.Stderr, "error.param").String(); got != "--audio" {
t.Fatalf("error.param = %q, want --audio\nstderr:\n%s", got, result.Stderr)
}
message := gjson.Get(result.Stderr, "error.message").String()
if !strings.Contains(message, "--audio supports only Opus audio files") {
t.Fatalf("error.message = %q, want Opus guidance\nstderr:\n%s", message, result.Stderr)
}
hint := gjson.Get(result.Stderr, "error.hint").String()
if !strings.Contains(hint, "--file") || !strings.Contains(hint, "ffmpeg") {
t.Fatalf("error.hint = %q, want --file and ffmpeg guidance\nstderr:\n%s", hint, result.Stderr)
}
}
@@ -0,0 +1,184 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestIM_MessageForwardWorkflowAsUser(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
t.Cleanup(cancel)
clie2e.SkipWithoutUserToken(t)
suffix := clie2e.GenerateSuffix()
messageText := "im-forward-msg-" + suffix
replyText := "im-forward-reply-" + suffix
selfOpenID := getSelfOpenID(t, ctx)
chatID, messageID := sendDirectMessageToUser(t, ctx, selfOpenID, messageText, "bot")
t.Run("forward message with api command as user", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"im", "messages", "forward"},
DefaultAs: "user",
Params: map[string]any{
"message_id": messageID,
"receive_id_type": "chat_id",
"uuid": "msg-forward-" + suffix,
},
Data: map[string]any{
"receive_id": chatID,
},
})
require.NoError(t, err)
skipIfMissingIMForwardPermission(t, result)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
forwardedID := gjson.Get(result.Stdout, "data.message_id").String()
require.NotEmpty(t, forwardedID, "stdout:\n%s", result.Stdout)
require.NotEqual(t, messageID, forwardedID, "stdout:\n%s", result.Stdout)
require.Equal(t, chatID, gjson.Get(result.Stdout, "data.chat_id").String(), "stdout:\n%s", result.Stdout)
})
var threadID string
t.Run("create thread fixture as bot", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"im", "+messages-reply",
"--message-id", messageID,
"--text", replyText,
"--reply-in-thread",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
threadID = findThreadIDForMessage(t, ctx, chatID, messageID, "bot")
})
t.Run("forward thread with api command as user", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"im", "threads", "forward"},
DefaultAs: "user",
Params: map[string]any{
"thread_id": threadID,
"receive_id_type": "chat_id",
"uuid": "thread-forward-" + suffix,
},
Data: map[string]any{
"receive_id": chatID,
},
})
require.NoError(t, err)
skipIfMissingIMForwardPermission(t, result)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
forwardedID := gjson.Get(result.Stdout, "data.message_id").String()
require.NotEmpty(t, forwardedID, "stdout:\n%s", result.Stdout)
require.Equal(t, chatID, gjson.Get(result.Stdout, "data.chat_id").String(), "stdout:\n%s", result.Stdout)
require.Equal(t, "merge_forward", gjson.Get(result.Stdout, "data.msg_type").String(), "stdout:\n%s", result.Stdout)
})
}
func findThreadIDForMessage(t *testing.T, ctx context.Context, chatID string, messageID string, defaultAs string) string {
t.Helper()
listResult, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{
"im", "+chat-messages-list",
"--chat-id", chatID,
"--start", time.Now().UTC().Add(-10 * time.Minute).Format(time.RFC3339),
"--end", time.Now().UTC().Add(10 * time.Minute).Format(time.RFC3339),
},
DefaultAs: defaultAs,
}, clie2e.RetryOptions{
ShouldRetry: func(result *clie2e.Result) bool {
if result == nil || result.ExitCode != 0 {
return true
}
for _, item := range gjson.Get(result.Stdout, "data.messages").Array() {
if item.Get("message_id").String() == messageID && item.Get("thread_id").String() != "" {
return false
}
}
return true
},
})
require.NoError(t, err)
listResult.AssertExitCode(t, 0)
listResult.AssertStdoutStatus(t, true)
for _, item := range gjson.Get(listResult.Stdout, "data.messages").Array() {
if item.Get("message_id").String() == messageID {
threadID := item.Get("thread_id").String()
require.NotEmpty(t, threadID, "expected thread_id for message %s in stdout:\n%s", messageID, listResult.Stdout)
return threadID
}
}
t.Fatalf("expected message %s in stdout:\n%s", messageID, listResult.Stdout)
return ""
}
func getSelfOpenID(t *testing.T, ctx context.Context) string {
t.Helper()
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"contact", "+get-user"},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
openID := gjson.Get(result.Stdout, "data.user.open_id").String()
require.NotEmpty(t, openID, "stdout:\n%s", result.Stdout)
return openID
}
func sendDirectMessageToUser(t *testing.T, ctx context.Context, userOpenID string, text string, defaultAs string) (string, string) {
t.Helper()
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"im", "+messages-send",
"--user-id", userOpenID,
"--text", text,
},
DefaultAs: defaultAs,
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
chatID := gjson.Get(result.Stdout, "data.chat_id").String()
messageID := gjson.Get(result.Stdout, "data.message_id").String()
require.NotEmpty(t, chatID, "stdout:\n%s", result.Stdout)
require.NotEmpty(t, messageID, "stdout:\n%s", result.Stdout)
return chatID, messageID
}
func skipIfMissingIMForwardPermission(t *testing.T, result *clie2e.Result) {
t.Helper()
if result == nil || result.ExitCode == 0 {
return
}
stderrLower := strings.ToLower(result.Stderr)
if strings.Contains(stderrLower, "permission denied") ||
strings.Contains(stderrLower, "230027") ||
strings.Contains(stderrLower, "missing_scope") {
t.Skipf("skip UAT forward workflow due to missing IM forward permissions: %s", result.Stderr)
}
}
@@ -0,0 +1,45 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestIM_MessageGetWorkflowAsUser(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
clie2e.SkipWithoutUserToken(t)
parentT := t
suffix := clie2e.GenerateSuffix()
chatName := "im-lookup-" + suffix
messageText := "im-msg-" + suffix
chatID := createChatAs(t, parentT, ctx, chatName, "user")
messageID := sendMessageAs(t, ctx, chatID, messageText, "user")
t.Run("batch get message as user", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"im", "+messages-mget", "--message-ids", messageID},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
messages := gjson.Get(result.Stdout, "data.messages").Array()
require.Len(t, messages, 1, "stdout:\n%s", result.Stdout)
require.Equal(t, messageID, messages[0].Get("message_id").String(), "stdout:\n%s", result.Stdout)
require.True(t, strings.Contains(messages[0].Get("content").String(), messageText), "stdout:\n%s", result.Stdout)
})
}
@@ -0,0 +1,111 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
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"
)
func TestIM_MessageReplyWorkflowAsBot(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 := "lark-cli-e2e-original-message-" + suffix
replyText := "lark-cli-e2e-reply-text-" + suffix
chatID := createChat(t, parentT, ctx, chatName)
messageID := sendMessage(t, ctx, chatID, originalMessage)
t.Run("reply to message in thread as bot", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"im", "+messages-reply",
"--message-id", messageID,
"--text", replyText,
"--reply-in-thread",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
assert.NotEmpty(t, gjson.Get(result.Stdout, "data.message_id").String(), "stdout:\n%s", result.Stdout)
assert.Equal(t, chatID, gjson.Get(result.Stdout, "data.chat_id").String(), "stdout:\n%s", result.Stdout)
})
t.Run("list thread replies as bot", func(t *testing.T) {
listResult, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{
"im", "+chat-messages-list",
"--chat-id", chatID,
"--start", time.Now().UTC().Add(-10 * time.Minute).Format(time.RFC3339),
"--end", time.Now().UTC().Add(10 * time.Minute).Format(time.RFC3339),
},
DefaultAs: "bot",
}, clie2e.RetryOptions{
ShouldRetry: func(result *clie2e.Result) bool {
if result == nil || result.ExitCode != 0 {
return true
}
for _, item := range gjson.Get(result.Stdout, "data.messages").Array() {
if item.Get("message_id").String() == messageID && item.Get("thread_id").String() != "" {
return false
}
}
return true
},
})
require.NoError(t, err)
listResult.AssertExitCode(t, 0)
listResult.AssertStdoutStatus(t, true)
var threadID string
for _, item := range gjson.Get(listResult.Stdout, "data.messages").Array() {
if item.Get("message_id").String() == messageID {
threadID = item.Get("thread_id").String()
break
}
}
require.NotEmpty(t, threadID, "expected thread_id for message %s in stdout:\n%s", messageID, listResult.Stdout)
threadResult, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{"im", "+threads-messages-list", "--thread", threadID},
DefaultAs: "bot",
}, clie2e.RetryOptions{
ShouldRetry: func(result *clie2e.Result) bool {
if result == nil || result.ExitCode != 0 {
return true
}
for _, item := range gjson.Get(result.Stdout, "data.messages").Array() {
if strings.Contains(item.Get("content").String(), replyText) {
return false
}
}
return true
},
})
require.NoError(t, err)
threadResult.AssertExitCode(t, 0)
threadResult.AssertStdoutStatus(t, true)
var found bool
for _, item := range gjson.Get(threadResult.Stdout, "data.messages").Array() {
if strings.Contains(item.Get("content").String(), replyText) {
found = true
break
}
}
require.True(t, found, "expected reply content in stdout:\n%s", threadResult.Stdout)
})
}
+83
View File
@@ -0,0 +1,83 @@
# Mail CLI E2E Coverage
## Metrics
- Denominator: 65 leaf commands
- Covered: 16
- Coverage: 24.6%
## Summary
- TestMail_DraftLifecycleWorkflowAsUser: proves a self-contained user draft workflow across `mail user_mailboxes profile`, `mail +draft-create`, `mail user_mailbox.drafts list`, `mail user_mailbox.drafts get`, `mail +draft-edit`, and `mail user_mailbox.drafts delete`; key `t.Run(...)` proof points are `get mailbox profile as user`, `create draft with shortcut as user`, `list draft as user`, `get created draft as user`, `inspect created draft as user`, `update draft subject with shortcut as user`, `inspect updated draft as user`, `delete draft as user`, and `verify draft removed from list as user`.
- TestMail_DraftSendWorkflowAsUser: proves a self-contained user draft-send workflow across `mail user_mailboxes profile`, `mail +draft-create`, `mail +draft-send`, and `mail +triage`; key `t.Run(...)` proof points are `get mailbox profile as user`, `create self-addressed draft as user`, `send draft with shortcut as user`, and `find self-received message for cleanup`.
- TestMail_SendWorkflowAsUser: proves a self-contained self-mail workflow across `mail +send`, `mail +triage`, `mail +message`, `mail +messages`, `mail +thread`, `mail +reply`, and `mail +forward`; key `t.Run(...)` proof points are `send mail to self with shortcut as user`, `find self sent mail in triage as user`, `get sent message as user`, `get received message as user`, `get both self sent messages as user`, `get self send thread as user`, `reply to received message with shortcut as user`, `inspect reply draft as user`, `forward received message with shortcut as user`, and `inspect forward draft as user`.
- Blocked area: `mail +reply-all` is still uncovered because the self-send workflow produces only self-recipient traffic and reply-alls recipient expansion becomes degenerate after self-address exclusion; `+signature`, `+watch`, event commands, and many raw message/thread mutation APIs still need dedicated tenant-aware workflows.
## Command Table
| Status | Cmd | Type | Testcase | Key parameter shapes | Notes / uncovered reason |
| --- | --- | --- | --- | --- | --- |
| ✓ | mail +draft-create | shortcut | mail_draft_lifecycle_workflow_test.go::TestMail_DraftLifecycleWorkflowAsUser/create draft with shortcut as user | `--subject`; `--body`; `--plain-text` | creates a new self-owned draft without relying on external recipients |
| ✓ | mail +draft-edit | shortcut | mail_draft_lifecycle_workflow_test.go::TestMail_DraftLifecycleWorkflowAsUser/inspect created draft as user; mail_draft_lifecycle_workflow_test.go::TestMail_DraftLifecycleWorkflowAsUser/update draft subject with shortcut as user; mail_draft_lifecycle_workflow_test.go::TestMail_DraftLifecycleWorkflowAsUser/inspect updated draft as user | `--draft-id`; `--mailbox me`; `--inspect`; `--set-subject` | shortcut proves readback projection and subject update |
| ✓ | mail +draft-send | shortcut | mail_draft_send_workflow_test.go::TestMail_DraftSendWorkflowAsUser/send draft with shortcut as user; mail_draft_send_dryrun_test.go::TestMail_DraftSendDryRun | `--draft-id`; `--mailbox me`; `--yes`; dry-run repeated/comma-separated `--draft-id` | sends a self-addressed draft through the batch shortcut and locks dry-run request shape |
| ✓ | mail +forward | shortcut | mail_send_workflow_test.go::TestMail_SendWorkflowAsUser/forward received message with shortcut as user; mail_send_workflow_test.go::TestMail_SendWorkflowAsUser/inspect forward draft as user | `--message-id`; `--to`; `--body`; `--plain-text` | uses self-generated inbox message as source and inspects forwarded draft projection |
| ✓ | mail +message | shortcut | mail_send_workflow_test.go::TestMail_SendWorkflowAsUser/get sent message as user; mail_send_workflow_test.go::TestMail_SendWorkflowAsUser/get received message as user | `--mailbox me`; `--message-id` | verifies both SENT and INBOX copies after self-send |
| ✓ | mail +message-modify | shortcut | shortcuts/mail/mail_message_manage_test.go::TestMessageModify_DryRunShowsPlanWithoutValidationGET; shortcuts/mail/mail_message_manage_test.go::TestMessageModify_BatchesAndAggregatesPartialFailure | `--message-ids`; `--add-label-ids`; `--remove-label-ids`; `--add-folder`; `--dry-run` | unit/dry-run coverage locks validation, batching, request shape, and partial failure aggregation; live E2E needs controlled disposable messages/labels/folders |
| ✓ | mail +message-trash | shortcut | shortcuts/mail/mail_message_manage_test.go::TestMessageTrash_RequiresYesAndBatches | `--message-ids`; `--yes`; `--dry-run` | unit coverage locks high-risk confirmation and batch_trash request shape; live E2E needs controlled disposable messages |
| ✓ | mail +messages | shortcut | mail_send_workflow_test.go::TestMail_SendWorkflowAsUser/get both self sent messages as user | `--mailbox me`; `--message-ids` | batch reads both sent and received message copies |
| ✓ | mail +reply | shortcut | mail_send_workflow_test.go::TestMail_SendWorkflowAsUser/reply to received message with shortcut as user; mail_send_workflow_test.go::TestMail_SendWorkflowAsUser/inspect reply draft as user | `--message-id`; `--body`; `--plain-text` | creates reply draft from self-generated inbox message and inspects quoted content |
| ✕ | mail +reply-all | shortcut | | none | self-send traffic leaves no stable non-self recipient set for deterministic reply-all assertions |
| ✓ | mail +send | shortcut | mail_send_workflow_test.go::TestMail_SendWorkflowAsUser/send mail to self with shortcut as user | `--to`; `--subject`; `--body`; `--plain-text`; `--confirm-send` | self-send creates both sent and inbox copies for follow-up assertions |
| ✕ | mail +signature | shortcut | | none | signature availability is mailbox-configuration dependent |
| ✓ | mail +thread | shortcut | mail_send_workflow_test.go::TestMail_SendWorkflowAsUser/get self send thread as user | `--mailbox me`; `--thread-id` | verifies readback of the sent-message thread created by self-send |
| ✓ | mail +triage | shortcut | mail_send_workflow_test.go::TestMail_SendWorkflowAsUser/find self sent mail in triage as user | `--mailbox me`; `--query`; `--max`; `--format data` | polls until self-sent subject becomes searchable and captures sent/inbox message ids |
| ✕ | mail +watch | shortcut | | none | requires websocket event subscription setup and external mail delivery |
| ✕ | mail multi_entity search | api | | none | requires deterministic searchable contact entities |
| ✕ | mail user_mailbox.drafts create | api | | none | only covered indirectly through `mail +draft-create` |
| ✓ | mail user_mailbox.drafts delete | api | mail_draft_lifecycle_workflow_test.go::TestMail_DraftLifecycleWorkflowAsUser/delete draft as user | `user_mailbox_id`; `draft_id` in `--params` | explicit lifecycle delete plus read-after-delete list check |
| ✓ | mail user_mailbox.drafts get | api | mail_draft_lifecycle_workflow_test.go::TestMail_DraftLifecycleWorkflowAsUser/get created draft as user | `user_mailbox_id`; `draft_id` in `--params` | asserts persisted draft id, subject, and draft state |
| ✓ | mail user_mailbox.drafts list | api | mail_draft_lifecycle_workflow_test.go::TestMail_DraftLifecycleWorkflowAsUser/list draft as user; mail_draft_lifecycle_workflow_test.go::TestMail_DraftLifecycleWorkflowAsUser/verify draft removed from list as user | `user_mailbox_id`; `page_size` in `--params` | proves create visibility and delete removal |
| ✕ | mail user_mailbox.drafts send | api | | none | draft send needs recipient-side or send-status assertions to be deterministic |
| ✕ | mail user_mailbox.drafts update | api | | none | only covered indirectly through `mail +draft-edit` |
| ✕ | mail user_mailbox.drafts cancel_scheduled_send | api | | none | requires a scheduled-send draft lifecycle |
| ✕ | mail user_mailbox.event subscribe | api | | none | requires event subscription setup |
| ✕ | mail user_mailbox.event subscription | api | | none | requires event subscription setup |
| ✕ | mail user_mailbox.event unsubscribe | api | | none | requires event subscription setup |
| ✕ | mail user_mailbox.folders create | api | | none | folder lifecycle left for a dedicated workflow |
| ✕ | mail user_mailbox.folders delete | api | | none | folder lifecycle left for a dedicated workflow |
| ✕ | mail user_mailbox.folders get | api | | none | folder lifecycle left for a dedicated workflow |
| ✕ | mail user_mailbox.folders list | api | | none | folder lifecycle left for a dedicated workflow |
| ✕ | mail user_mailbox.folders patch | api | | none | folder lifecycle left for a dedicated workflow |
| ✕ | mail user_mailbox.labels create | api | | none | label lifecycle left for a dedicated workflow |
| ✕ | mail user_mailbox.labels delete | api | | none | label lifecycle left for a dedicated workflow |
| ✕ | mail user_mailbox.labels get | api | | none | label lifecycle left for a dedicated workflow |
| ✕ | mail user_mailbox.labels list | api | | none | label lifecycle left for a dedicated workflow |
| ✕ | mail user_mailbox.labels patch | api | | none | label lifecycle left for a dedicated workflow |
| ✕ | mail user_mailbox.mail_contacts create | api | | none | contact lifecycle left for a dedicated workflow |
| ✕ | mail user_mailbox.mail_contacts delete | api | | none | contact lifecycle left for a dedicated workflow |
| ✕ | mail user_mailbox.mail_contacts list | api | | none | contact lifecycle left for a dedicated workflow |
| ✕ | mail user_mailbox.mail_contacts patch | api | | none | contact lifecycle left for a dedicated workflow |
| ✕ | mail user_mailbox.message.attachments download_url | api | | none | requires an existing message attachment |
| ✕ | mail user_mailbox.messages batch_get | api | | none | requires existing message ids |
| ✕ | mail user_mailbox.messages batch_modify | api | | none | requires existing messages and mailbox folders/labels |
| ✕ | mail user_mailbox.messages batch_trash | api | | none | requires existing messages |
| ✕ | mail user_mailbox.messages get | api | | none | requires an existing message id |
| ✕ | mail user_mailbox.messages list | api | | none | requires deterministic existing folder or label message inventory |
| ✕ | mail user_mailbox.messages modify | api | | none | requires existing messages and mailbox folders/labels |
| ✕ | mail user_mailbox.messages send_status | api | | none | requires a sent message id |
| ✕ | mail user_mailbox.messages trash | api | | none | requires an existing message id |
| ✕ | mail user_mailbox.rules create | api | | none | rule lifecycle left for a dedicated workflow |
| ✕ | mail user_mailbox.rules delete | api | | none | rule lifecycle left for a dedicated workflow |
| ✕ | mail user_mailbox.rules list | api | | none | rule lifecycle left for a dedicated workflow |
| ✕ | mail user_mailbox.rules reorder | api | | none | rule lifecycle left for a dedicated workflow |
| ✕ | mail user_mailbox.rules update | api | | none | rule lifecycle left for a dedicated workflow |
| ✕ | mail user_mailbox.sent_messages get_recall_detail | api | | none | requires a recallable sent message |
| ✕ | mail user_mailbox.sent_messages recall | api | | none | requires a delivered sent message within recall window |
| ✕ | mail user_mailbox.settings send_as | api | | none | mailbox alias availability is tenant-configuration dependent |
| ✕ | mail user_mailbox.threads batch_modify | api | | none | requires existing threads and mailbox folders/labels |
| ✕ | mail user_mailbox.threads batch_trash | api | | none | requires existing thread ids |
| ✕ | mail user_mailbox.threads get | api | | none | requires an existing thread id |
| ✕ | mail user_mailbox.threads list | api | | none | requires deterministic existing folder or label thread inventory |
| ✕ | mail user_mailbox.threads modify | api | | none | requires existing threads and mailbox folders/labels |
| ✕ | mail user_mailbox.threads trash | api | | none | requires an existing thread id |
| ✕ | mail user_mailboxes accessible_mailboxes | api | | none | mailbox visibility differs by tenant and shared-mailbox configuration |
| ✓ | mail user_mailboxes profile | api | mail_draft_lifecycle_workflow_test.go::TestMail_DraftLifecycleWorkflowAsUser/get mailbox profile as user | `user_mailbox_id=me` in `--params` | proves current mailbox identity before draft lifecycle |
| ✕ | mail user_mailboxes search | api | | none | requires deterministic searchable mailbox content |
@@ -0,0 +1,213 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package mail
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 TestMail_DraftLifecycleWorkflowAsUser(t *testing.T) {
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
clie2e.SkipWithoutUserToken(t)
suffix := clie2e.GenerateSuffix()
originalSubject := "lark-cli-e2e-mail-draft-" + suffix
updatedSubject := originalSubject + "-updated"
originalBody := "draft lifecycle body " + suffix
const mailboxID = "me"
var draftID string
var draftDeleted bool
parentT.Cleanup(func() {
if draftID == "" || draftDeleted {
return
}
cleanupCtx, cancel := clie2e.CleanupContext()
defer cancel()
result, err := clie2e.RunCmd(cleanupCtx, clie2e.Request{
Args: []string{"mail", "user_mailbox.drafts", "delete"},
DefaultAs: "user",
Params: map[string]any{
"user_mailbox_id": mailboxID,
"draft_id": draftID,
},
Yes: true,
})
clie2e.ReportCleanupFailure(parentT, "delete draft "+draftID, result, err)
})
t.Run("get mailbox profile as user", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"mail", "user_mailboxes", "profile"},
DefaultAs: "user",
Params: map[string]any{"user_mailbox_id": mailboxID},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
require.NotEmpty(t, gjson.Get(result.Stdout, "data.primary_email_address").String(), "stdout:\n%s", result.Stdout)
})
t.Run("create draft with shortcut as user", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"mail", "+draft-create",
"--subject", originalSubject,
"--body", originalBody,
"--plain-text",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
draftID = gjson.Get(result.Stdout, "data.draft_id").String()
require.NotEmpty(t, draftID, "stdout:\n%s", result.Stdout)
})
t.Run("list draft as user", func(t *testing.T) {
require.NotEmpty(t, draftID, "draft should be created before listing drafts")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"mail", "user_mailbox.drafts", "list"},
DefaultAs: "user",
Params: map[string]any{
"user_mailbox_id": mailboxID,
"page_size": 100,
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
require.True(t, gjson.Get(result.Stdout, `data.items.#(id=="`+draftID+`")`).Exists(), "stdout:\n%s", result.Stdout)
})
t.Run("get created draft as user", func(t *testing.T) {
require.NotEmpty(t, draftID, "draft should be created before get")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"mail", "user_mailbox.drafts", "get"},
DefaultAs: "user",
Params: map[string]any{
"user_mailbox_id": mailboxID,
"draft_id": draftID,
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
assert.Equal(t, draftID, gjson.Get(result.Stdout, "data.draft.id").String())
assert.Equal(t, originalSubject, gjson.Get(result.Stdout, "data.draft.message.subject").String())
assert.Equal(t, int64(3), gjson.Get(result.Stdout, "data.draft.message.message_state").Int())
})
t.Run("inspect created draft as user", func(t *testing.T) {
require.NotEmpty(t, draftID, "draft should be created before inspect")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"mail", "+draft-edit",
"--draft-id", draftID,
"--mailbox", mailboxID,
"--inspect",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
assert.Equal(t, draftID, gjson.Get(result.Stdout, "data.draft_id").String())
assert.Equal(t, originalSubject, gjson.Get(result.Stdout, "data.projection.subject").String())
assert.Equal(t, originalBody, gjson.Get(result.Stdout, "data.projection.body_text").String())
})
t.Run("update draft subject with shortcut as user", func(t *testing.T) {
require.NotEmpty(t, draftID, "draft should be created before update")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"mail", "+draft-edit",
"--draft-id", draftID,
"--mailbox", mailboxID,
"--set-subject", updatedSubject,
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
assert.Equal(t, draftID, gjson.Get(result.Stdout, "data.draft_id").String())
assert.Equal(t, updatedSubject, gjson.Get(result.Stdout, "data.projection.subject").String())
assert.Equal(t, originalBody, gjson.Get(result.Stdout, "data.projection.body_text").String())
})
t.Run("inspect updated draft as user", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"mail", "+draft-edit",
"--draft-id", draftID,
"--mailbox", mailboxID,
"--inspect",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
assert.Equal(t, updatedSubject, gjson.Get(result.Stdout, "data.projection.subject").String())
assert.Equal(t, originalBody, gjson.Get(result.Stdout, "data.projection.body_text").String())
})
t.Run("delete draft as user", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"mail", "user_mailbox.drafts", "delete"},
DefaultAs: "user",
Params: map[string]any{
"user_mailbox_id": mailboxID,
"draft_id": draftID,
},
Yes: true,
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
draftDeleted = true
})
t.Run("verify draft removed from list as user", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"mail", "user_mailbox.drafts", "get"},
DefaultAs: "user",
Params: map[string]any{
"user_mailbox_id": mailboxID,
"draft_id": draftID,
},
})
require.NoError(t, err)
assert.NotEqual(t, 0, result.ExitCode, "stdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
assert.Equal(t, "not_found", gjson.Get(result.Stderr, "error.detail.type").String(), "stdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
})
}
@@ -0,0 +1,124 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package mail
import (
"context"
"strconv"
"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 TestMail_DraftSendDryRun(t *testing.T) {
setMailDraftSendDryRunEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"mail", "+draft-send",
"--mailbox", "alias@example.com",
"--draft-id", " draft_001, draft_002 ",
"--draft-id", " draft_003 ",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
wantURLs := []string{
"/open-apis/mail/v1/user_mailboxes/alias@example.com/drafts/draft_001/send",
"/open-apis/mail/v1/user_mailboxes/alias@example.com/drafts/draft_002/send",
"/open-apis/mail/v1/user_mailboxes/alias@example.com/drafts/draft_003/send",
}
assert.Equal(t, int64(len(wantURLs)), gjson.Get(result.Stdout, "api.#").Int(), "stdout:\n%s", result.Stdout)
for i, wantURL := range wantURLs {
idx := strconv.Itoa(i)
assert.Equal(t, "POST", gjson.Get(result.Stdout, "api."+idx+".method").String(), "stdout:\n%s", result.Stdout)
assert.Equal(t, wantURL, gjson.Get(result.Stdout, "api."+idx+".url").String(), "stdout:\n%s", result.Stdout)
assert.False(t, gjson.Get(result.Stdout, "api."+idx+".body").Exists(), "stdout:\n%s", result.Stdout)
}
}
func TestMail_DraftSendDryRunValidation(t *testing.T) {
setMailDraftSendDryRunEnv(t)
tests := []struct {
name string
args []string
wantMsg string
}{
{
name: "reject whitespace draft id",
args: []string{
"mail", "+draft-send",
"--draft-id", " ",
"--dry-run",
},
wantMsg: "--draft-id contains empty value",
},
{
name: "reject too many draft ids",
args: []string{
"mail", "+draft-send",
"--draft-id", manyDraftIDsForE2E(51),
"--dry-run",
},
wantMsg: "too many drafts",
},
{
name: "reject duplicate draft id",
args: []string{
"mail", "+draft-send",
"--draft-id", "draft_001,draft_002,draft_001",
"--dry-run",
},
wantMsg: "--draft-id contains duplicate value: draft_001",
},
}
for _, temp := range tests {
tt := temp
t.Run(tt.name, func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: tt.args,
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
output := result.Stdout + result.Stderr
assert.Contains(t, output, tt.wantMsg, "stdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
})
}
}
func setMailDraftSendDryRunEnv(t *testing.T) {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "mail_draft_send_dryrun_test")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "mail_draft_send_dryrun_secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
}
func manyDraftIDsForE2E(n int) string {
ids := make([]byte, 0, n*4)
for i := 0; i < n; i++ {
if i > 0 {
ids = append(ids, ',')
}
ids = append(ids, 'd')
ids = strconv.AppendInt(ids, int64(i), 10)
}
return string(ids)
}
@@ -0,0 +1,166 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package mail
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 TestMail_DraftSendWorkflowAsUser(t *testing.T) {
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
t.Cleanup(cancel)
clie2e.SkipWithoutUserToken(t)
const mailboxID = "me"
suffix := clie2e.GenerateSuffix()
subject := "lark-cli-e2e-mail-draft-send-" + suffix
body := "draft-send workflow body " + suffix
var primaryEmail string
var draftID string
var draftSent bool
var sentMessageID string
var inboxMessageID string
parentT.Cleanup(func() {
if draftID != "" && !draftSent {
cleanupCtx, cancel := clie2e.CleanupContext()
defer cancel()
result, err := clie2e.RunCmd(cleanupCtx, clie2e.Request{
Args: []string{"mail", "user_mailbox.drafts", "delete"},
DefaultAs: "user",
Params: map[string]any{
"user_mailbox_id": mailboxID,
"draft_id": draftID,
},
Yes: true,
})
clie2e.ReportCleanupFailure(parentT, "delete draft "+draftID, result, err)
}
var messageIDs []string
if sentMessageID != "" {
messageIDs = append(messageIDs, sentMessageID)
}
if inboxMessageID != "" && inboxMessageID != sentMessageID {
messageIDs = append(messageIDs, inboxMessageID)
}
if len(messageIDs) == 0 {
return
}
cleanupCtx, cancel := clie2e.CleanupContext()
defer cancel()
result, err := clie2e.RunCmd(cleanupCtx, clie2e.Request{
Args: []string{"mail", "user_mailbox.messages", "batch_trash"},
DefaultAs: "user",
Params: map[string]any{"user_mailbox_id": mailboxID},
Data: map[string]any{"message_ids": messageIDs},
})
clie2e.ReportCleanupFailure(parentT, "trash draft-send messages", result, err)
})
t.Run("get mailbox profile as user", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"mail", "user_mailboxes", "profile"},
DefaultAs: "user",
Params: map[string]any{"user_mailbox_id": mailboxID},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
primaryEmail = gjson.Get(result.Stdout, "data.primary_email_address").String()
require.NotEmpty(t, primaryEmail, "stdout:\n%s", result.Stdout)
})
t.Run("create self-addressed draft as user", func(t *testing.T) {
require.NotEmpty(t, primaryEmail, "mailbox profile should be loaded before draft create")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"mail", "+draft-create",
"--to", primaryEmail,
"--subject", subject,
"--body", body,
"--plain-text",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
draftID = gjson.Get(result.Stdout, "data.draft_id").String()
require.NotEmpty(t, draftID, "stdout:\n%s", result.Stdout)
})
t.Run("send draft with shortcut as user", func(t *testing.T) {
require.NotEmpty(t, draftID, "draft should be created before +draft-send")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"mail", "+draft-send",
"--mailbox", mailboxID,
"--draft-id", draftID,
},
DefaultAs: "user",
Yes: true,
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
assert.Equal(t, int64(1), gjson.Get(result.Stdout, "data.total").Int(), "stdout:\n%s", result.Stdout)
assert.Equal(t, int64(1), gjson.Get(result.Stdout, "data.success_count").Int(), "stdout:\n%s", result.Stdout)
assert.Equal(t, int64(0), gjson.Get(result.Stdout, "data.failure_count").Int(), "stdout:\n%s", result.Stdout)
assert.Equal(t, draftID, gjson.Get(result.Stdout, "data.sent.0.draft_id").String(), "stdout:\n%s", result.Stdout)
sentMessageID = gjson.Get(result.Stdout, "data.sent.0.message_id").String()
require.NotEmpty(t, sentMessageID, "stdout:\n%s", result.Stdout)
draftSent = true
})
t.Run("find self-received message for cleanup", func(t *testing.T) {
require.NotEmpty(t, sentMessageID, "draft should be sent before triage lookup")
for attempt := 0; attempt < 12; attempt++ {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"mail", "+triage",
"--mailbox", mailboxID,
"--query", subject,
"--max", "10",
"--format", "data",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
for _, item := range gjson.Get(result.Stdout, "messages").Array() {
if item.Get("subject").String() != subject {
continue
}
messageID := item.Get("message_id").String()
if messageID != "" && messageID != sentMessageID {
inboxMessageID = messageID
return
}
}
time.Sleep(2 * time.Second)
}
})
}
@@ -0,0 +1,342 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package mail
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 TestMail_SendWorkflowAsUser(t *testing.T) {
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute)
t.Cleanup(cancel)
clie2e.SkipWithoutUserToken(t)
const mailboxID = "me"
suffix := clie2e.GenerateSuffix()
subject := "mail-self-" + suffix
body := "self send body " + suffix
replyBody := "self send reply body " + suffix
forwardBody := "self send forward body " + suffix
var primaryEmail string
var sentMessageID string
var threadID string
var inboxMessageID string
var replyDraftID string
var forwardDraftID string
parentT.Cleanup(func() {
if replyDraftID != "" {
cleanupCtx, cancel := clie2e.CleanupContext()
defer cancel()
result, err := clie2e.RunCmd(cleanupCtx, clie2e.Request{
Args: []string{"mail", "user_mailbox.drafts", "delete"},
DefaultAs: "user",
Params: map[string]any{
"user_mailbox_id": mailboxID,
"draft_id": replyDraftID,
},
Yes: true,
})
clie2e.ReportCleanupFailure(parentT, "delete reply draft "+replyDraftID, result, err)
}
if forwardDraftID != "" {
cleanupCtx, cancel := clie2e.CleanupContext()
defer cancel()
result, err := clie2e.RunCmd(cleanupCtx, clie2e.Request{
Args: []string{"mail", "user_mailbox.drafts", "delete"},
DefaultAs: "user",
Params: map[string]any{
"user_mailbox_id": mailboxID,
"draft_id": forwardDraftID,
},
Yes: true,
})
clie2e.ReportCleanupFailure(parentT, "delete forward draft "+forwardDraftID, result, err)
}
var messageIDs []string
if sentMessageID != "" {
messageIDs = append(messageIDs, sentMessageID)
}
if inboxMessageID != "" && inboxMessageID != sentMessageID {
messageIDs = append(messageIDs, inboxMessageID)
}
if len(messageIDs) == 0 {
return
}
cleanupCtx, cancel := clie2e.CleanupContext()
defer cancel()
result, err := clie2e.RunCmd(cleanupCtx, clie2e.Request{
Args: []string{"mail", "user_mailbox.messages", "batch_trash"},
DefaultAs: "user",
Params: map[string]any{"user_mailbox_id": mailboxID},
Data: map[string]any{"message_ids": messageIDs},
})
clie2e.ReportCleanupFailure(parentT, "trash self-send messages", result, err)
})
t.Run("get mailbox profile as user", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"mail", "user_mailboxes", "profile"},
DefaultAs: "user",
Params: map[string]any{"user_mailbox_id": mailboxID},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
primaryEmail = gjson.Get(result.Stdout, "data.primary_email_address").String()
require.NotEmpty(t, primaryEmail, "stdout:\n%s", result.Stdout)
})
t.Run("send mail to self with shortcut as user", func(t *testing.T) {
require.NotEmpty(t, primaryEmail, "mailbox profile should be loaded before self-send")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"mail", "+send",
"--to", primaryEmail,
"--subject", subject,
"--body", body,
"--plain-text",
"--confirm-send",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
sentMessageID = gjson.Get(result.Stdout, "data.message_id").String()
threadID = gjson.Get(result.Stdout, "data.thread_id").String()
require.NotEmpty(t, sentMessageID, "stdout:\n%s", result.Stdout)
require.NotEmpty(t, threadID, "stdout:\n%s", result.Stdout)
})
t.Run("find self sent mail in triage as user", func(t *testing.T) {
require.NotEmpty(t, subject, "subject should be set before triage")
var stdout string
for attempt := 0; attempt < 12; attempt++ {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"mail", "+triage",
"--mailbox", mailboxID,
"--query", subject,
"--max", "10",
"--format", "data",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
stdout = result.Stdout
messages := gjson.Get(stdout, "messages").Array()
for _, item := range messages {
if item.Get("subject").String() != subject {
continue
}
messageID := item.Get("message_id").String()
if messageID == "" {
continue
}
if messageID != sentMessageID {
inboxMessageID = messageID
}
}
if inboxMessageID != "" {
require.GreaterOrEqual(t, int(gjson.Get(stdout, "count").Int()), 2, "stdout:\n%s", stdout)
require.True(t, gjson.Get(stdout, `messages.#(message_id=="`+sentMessageID+`")`).Exists(), "stdout:\n%s", stdout)
require.True(t, gjson.Get(stdout, `messages.#(message_id=="`+inboxMessageID+`")`).Exists(), "stdout:\n%s", stdout)
return
}
time.Sleep(2 * time.Second)
}
t.Fatalf("failed to observe inbox copy for self-sent message in triage:\n%s", stdout)
})
t.Run("get sent message as user", func(t *testing.T) {
require.NotEmpty(t, sentMessageID, "sent message id should be available before message read")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"mail", "+message",
"--mailbox", mailboxID,
"--message-id", sentMessageID,
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
assert.Equal(t, sentMessageID, gjson.Get(result.Stdout, "data.message_id").String())
assert.Equal(t, threadID, gjson.Get(result.Stdout, "data.thread_id").String())
assert.Equal(t, subject, gjson.Get(result.Stdout, "data.subject").String())
assert.Equal(t, body, gjson.Get(result.Stdout, "data.body_plain_text").String())
assert.Equal(t, "SENT", gjson.Get(result.Stdout, "data.folder_id").String())
assert.Equal(t, "sent", gjson.Get(result.Stdout, "data.message_state_text").String())
assert.Equal(t, primaryEmail, gjson.Get(result.Stdout, "data.to.0.mail_address").String())
})
t.Run("get received message as user", func(t *testing.T) {
require.NotEmpty(t, inboxMessageID, "inbox message id should be available before message read")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"mail", "+message",
"--mailbox", mailboxID,
"--message-id", inboxMessageID,
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
assert.Equal(t, inboxMessageID, gjson.Get(result.Stdout, "data.message_id").String())
assert.Equal(t, threadID, gjson.Get(result.Stdout, "data.thread_id").String())
assert.Equal(t, subject, gjson.Get(result.Stdout, "data.subject").String())
assert.Equal(t, body, gjson.Get(result.Stdout, "data.body_plain_text").String())
assert.Equal(t, "INBOX", gjson.Get(result.Stdout, "data.folder_id").String())
assert.Equal(t, "received", gjson.Get(result.Stdout, "data.message_state_text").String())
assert.Equal(t, primaryEmail, gjson.Get(result.Stdout, "data.to.0.mail_address").String())
})
t.Run("get both self sent messages as user", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"mail", "+messages",
"--mailbox", mailboxID,
"--message-ids", sentMessageID + "," + inboxMessageID,
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
assert.Equal(t, int64(2), gjson.Get(result.Stdout, "data.total").Int())
assert.Len(t, gjson.Get(result.Stdout, "data.unavailable_message_ids").Array(), 0, "stdout:\n%s", result.Stdout)
assert.True(t, gjson.Get(result.Stdout, `data.messages.#(message_id=="`+sentMessageID+`")`).Exists(), "stdout:\n%s", result.Stdout)
assert.True(t, gjson.Get(result.Stdout, `data.messages.#(message_id=="`+inboxMessageID+`")`).Exists(), "stdout:\n%s", result.Stdout)
})
t.Run("get self send thread as user", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"mail", "+thread",
"--mailbox", mailboxID,
"--thread-id", threadID,
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
assert.Equal(t, threadID, gjson.Get(result.Stdout, "data.thread_id").String())
assert.Equal(t, int64(1), gjson.Get(result.Stdout, "data.message_count").Int(), "stdout:\n%s", result.Stdout)
assert.True(t, gjson.Get(result.Stdout, `data.messages.#(message_id=="`+sentMessageID+`")`).Exists(), "stdout:\n%s", result.Stdout)
})
t.Run("reply to received message with shortcut as user", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"mail", "+reply",
"--message-id", inboxMessageID,
"--body", replyBody,
"--plain-text",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
replyDraftID = gjson.Get(result.Stdout, "data.draft_id").String()
require.NotEmpty(t, replyDraftID, "stdout:\n%s", result.Stdout)
})
t.Run("inspect reply draft as user", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"mail", "+draft-edit",
"--draft-id", replyDraftID,
"--mailbox", mailboxID,
"--inspect",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
assert.Equal(t, "Re: "+subject, gjson.Get(result.Stdout, "data.projection.subject").String())
assert.Equal(t, primaryEmail, gjson.Get(result.Stdout, "data.projection.to.0.address").String())
assert.Contains(t, gjson.Get(result.Stdout, "data.projection.body_text").String(), replyBody)
assert.Contains(t, gjson.Get(result.Stdout, "data.projection.body_text").String(), body)
assert.NotEmpty(t, gjson.Get(result.Stdout, "data.projection.in_reply_to").String(), "stdout:\n%s", result.Stdout)
})
t.Run("forward received message with shortcut as user", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"mail", "+forward",
"--message-id", inboxMessageID,
"--to", primaryEmail,
"--body", forwardBody,
"--plain-text",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
forwardDraftID = gjson.Get(result.Stdout, "data.draft_id").String()
require.NotEmpty(t, forwardDraftID, "stdout:\n%s", result.Stdout)
})
t.Run("inspect forward draft as user", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"mail", "+draft-edit",
"--draft-id", forwardDraftID,
"--mailbox", mailboxID,
"--inspect",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
assert.Equal(t, "Fwd: "+subject, gjson.Get(result.Stdout, "data.projection.subject").String())
assert.Equal(t, primaryEmail, gjson.Get(result.Stdout, "data.projection.to.0.address").String())
assert.Contains(t, gjson.Get(result.Stdout, "data.projection.body_text").String(), forwardBody)
assert.Contains(t, gjson.Get(result.Stdout, "data.projection.body_text").String(), body)
assert.NotEmpty(t, gjson.Get(result.Stdout, "data.projection.in_reply_to").String(), "stdout:\n%s", result.Stdout)
})
}
@@ -0,0 +1,138 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package mail
import (
"context"
"strconv"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestMail_ShareToChatDryRun validates the request shape emitted by
// +share-to-chat under --dry-run: the full CLI binary is invoked end-to-end
// so flag parsing, validation, and the dry-run renderer all execute.
// Fake credentials are sufficient because --dry-run short-circuits before
// any network call.
func TestMail_ShareToChatDryRun(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
tests := []struct {
name string
args []string
wantURLs []string
wantCreateBody map[string]string
wantSendBody map[string]string
wantSendParams map[string]string
}{
{
name: "message-id with default chat_id",
args: []string{
"mail", "+share-to-chat",
"--message-id", "msg_001",
"--receive-id", "oc_xxx",
"--dry-run",
},
wantURLs: []string{
"/open-apis/mail/v1/user_mailboxes/me/messages/share_token",
"/open-apis/mail/v1/user_mailboxes/me/share_tokens/%3Ccard_id%3E/send",
},
wantCreateBody: map[string]string{"message_id": "msg_001"},
wantSendBody: map[string]string{"receive_id": "oc_xxx"},
wantSendParams: map[string]string{"receive_id_type": "chat_id"},
},
{
name: "thread-id with email type",
args: []string{
"mail", "+share-to-chat",
"--thread-id", "thread_001",
"--receive-id", "user@example.com",
"--receive-id-type", "email",
"--dry-run",
},
wantURLs: []string{
"/open-apis/mail/v1/user_mailboxes/me/messages/share_token",
"/open-apis/mail/v1/user_mailboxes/me/share_tokens/%3Ccard_id%3E/send",
},
wantCreateBody: map[string]string{"thread_id": "thread_001"},
wantSendBody: map[string]string{"receive_id": "user@example.com"},
wantSendParams: map[string]string{"receive_id_type": "email"},
},
{
name: "custom mailbox",
args: []string{
"mail", "+share-to-chat",
"--message-id", "msg_002",
"--receive-id", "oc_xxx",
"--mailbox", "alias@example.com",
"--dry-run",
},
wantURLs: []string{
"/open-apis/mail/v1/user_mailboxes/alias@example.com/messages/share_token",
"/open-apis/mail/v1/user_mailboxes/alias@example.com/share_tokens/%3Ccard_id%3E/send",
},
wantCreateBody: map[string]string{"message_id": "msg_002"},
wantSendBody: map[string]string{"receive_id": "oc_xxx"},
wantSendParams: map[string]string{"receive_id_type": "chat_id"},
},
}
for _, temp := range tests {
tt := temp
t.Run(tt.name, func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: tt.args,
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
gotCount := int(gjson.Get(out, "api.#").Int())
if gotCount != len(tt.wantURLs) {
t.Fatalf("expected %d API calls, got %d\nstdout:\n%s", len(tt.wantURLs), gotCount, out)
}
for i, wantURL := range tt.wantURLs {
idx := strconv.Itoa(i)
gotMethod := gjson.Get(out, "api."+idx+".method").String()
gotURL := gjson.Get(out, "api."+idx+".url").String()
if gotMethod != "POST" {
t.Fatalf("api[%d].method = %q, want POST\nstdout:\n%s", i, gotMethod, out)
}
if gotURL != wantURL {
t.Fatalf("api[%d].url = %q, want %q\nstdout:\n%s", i, gotURL, wantURL, out)
}
}
for k, v := range tt.wantCreateBody {
got := gjson.Get(out, "api.0.body."+k).String()
if got != v {
t.Fatalf("api[0].body.%s = %q, want %q\nstdout:\n%s", k, got, v, out)
}
}
for k, v := range tt.wantSendBody {
got := gjson.Get(out, "api.1.body."+k).String()
if got != v {
t.Fatalf("api[1].body.%s = %q, want %q\nstdout:\n%s", k, got, v, out)
}
}
for k, v := range tt.wantSendParams {
got := gjson.Get(out, "api.1.params."+k).String()
if got != v {
t.Fatalf("api[1].params.%s = %q, want %q\nstdout:\n%s", k, got, v, out)
}
}
})
}
}
@@ -0,0 +1,52 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package mail
import (
"context"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestMail_TriageDryRunPreservesMailboxInRequestChain(t *testing.T) {
setMailTriageDryRunEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"mail", "+triage",
"--mailbox", "alias@example.com",
"--filter", `{"folder_id":"INBOX"}`,
"--max", "3",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
require.Equal(t, int64(2), gjson.Get(result.Stdout, "api.#").Int(), "stdout:\n%s", result.Stdout)
require.Equal(t, "GET", gjson.Get(result.Stdout, "api.0.method").String(), "stdout:\n%s", result.Stdout)
require.Equal(t, "/open-apis/mail/v1/user_mailboxes/alias@example.com/messages", gjson.Get(result.Stdout, "api.0.url").String(), "stdout:\n%s", result.Stdout)
require.Equal(t, int64(3), gjson.Get(result.Stdout, "api.0.params.page_size").Int(), "stdout:\n%s", result.Stdout)
require.Equal(t, "INBOX", gjson.Get(result.Stdout, "api.0.params.folder_id").String(), "stdout:\n%s", result.Stdout)
require.Equal(t, "POST", gjson.Get(result.Stdout, "api.1.method").String(), "stdout:\n%s", result.Stdout)
require.Equal(t, "/open-apis/mail/v1/user_mailboxes/alias@example.com/messages/batch_get", gjson.Get(result.Stdout, "api.1.url").String(), "stdout:\n%s", result.Stdout)
require.Equal(t, "metadata", gjson.Get(result.Stdout, "api.1.body.format").String(), "stdout:\n%s", result.Stdout)
}
func setMailTriageDryRunEnv(t *testing.T) {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "mail_triage_dryrun_test")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "mail_triage_dryrun_secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
}
@@ -0,0 +1,323 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package markdown
import (
"context"
"os"
"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"
)
func TestMarkdownCreateDryRun_Content(t *testing.T) {
setMarkdownDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"markdown", "+create",
"--name", "README.md",
"--content", "# hello",
"--folder-token", "fldcnMarkdownDryRun",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
output := strings.TrimSpace(result.Stdout)
assert.Contains(t, output, "/open-apis/drive/v1/files/upload_all")
assert.Contains(t, output, "/open-apis/drive/v1/metas/batch_query")
assert.Contains(t, output, `"with_url": true`)
assert.Contains(t, output, `"file_name": "README.md"`)
assert.Contains(t, output, `"parent_node": "fldcnMarkdownDryRun"`)
assert.Contains(t, output, `"parent_type": "explorer"`)
assert.Contains(t, output, `"size": 7`)
}
func TestMarkdownCreateDryRun_WikiTarget(t *testing.T) {
setMarkdownDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"markdown", "+create",
"--name", "README.md",
"--content", "# hello",
"--wiki-token", "wikcnMarkdownDryRun",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
output := strings.TrimSpace(result.Stdout)
assert.Contains(t, output, "/open-apis/drive/v1/files/upload_all")
assert.Contains(t, output, "/open-apis/drive/v1/metas/batch_query")
assert.Contains(t, output, `"with_url": true`)
assert.Contains(t, output, `"file_name": "README.md"`)
assert.Contains(t, output, `"parent_node": "wikcnMarkdownDryRun"`)
assert.Contains(t, output, `"parent_type": "wiki"`)
assert.Contains(t, output, `"size": 7`)
}
func TestMarkdownCreateDryRun_FileShowsConcreteSize(t *testing.T) {
setMarkdownDryRunConfigEnv(t)
dir := t.TempDir()
content := "# hi\n"
require.NoError(t, os.WriteFile(dir+"/note.md", []byte(content), 0o644))
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"markdown", "+create",
"--file", "note.md",
"--dry-run",
},
DefaultAs: "bot",
WorkDir: dir,
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
output := strings.TrimSpace(result.Stdout)
assert.Contains(t, output, "/open-apis/drive/v1/files/upload_all")
assert.Contains(t, output, "/open-apis/drive/v1/metas/batch_query")
assert.Contains(t, output, `"with_url": true`)
assert.Contains(t, output, `"file": "@note.md"`)
assert.Contains(t, output, `"size": 5`)
}
func TestMarkdownCreateDryRun_RejectsEmptyContent(t *testing.T) {
setMarkdownDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"markdown", "+create",
"--name", "empty.md",
"--content", "",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
if api := gjson.Get(result.Stdout, "api"); api.IsArray() && len(api.Array()) > 0 {
t.Fatalf("dry-run api list must be empty when validation fails\nstdout:\n%s", result.Stdout)
}
errMsg := gjson.Get(result.Stdout, "error").String()
assert.Contains(t, errMsg, "empty markdown content is not supported")
}
func TestMarkdownDiffDryRun_RemoteVsRemote(t *testing.T) {
setMarkdownDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"markdown", "+diff",
"--file-token", "boxcnMarkdownDryRun",
"--from-version", "7633658129540910621",
"--to-version", "7633658129540910628",
"--context-lines", "1",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
output := strings.TrimSpace(result.Stdout)
assert.Contains(t, output, "/open-apis/drive/v1/files/boxcnMarkdownDryRun/download")
assert.Contains(t, output, `"mode": "remote_vs_remote"`)
assert.Contains(t, output, `"version": "7633658129540910621"`)
assert.Contains(t, output, `"version": "7633658129540910628"`)
assert.Contains(t, output, `"context_lines": 1`)
}
func TestMarkdownDiffDryRun_RemoteVsLocal(t *testing.T) {
setMarkdownDryRunConfigEnv(t)
dir := t.TempDir()
require.NoError(t, os.WriteFile(dir+"/draft.md", []byte("# draft\n"), 0o644))
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"markdown", "+diff",
"--file-token", "boxcnMarkdownDryRun",
"--file", "./draft.md",
"--dry-run",
},
DefaultAs: "bot",
WorkDir: dir,
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
output := strings.TrimSpace(result.Stdout)
assert.Contains(t, output, "/open-apis/drive/v1/files/boxcnMarkdownDryRun/download")
assert.Contains(t, output, `"mode": "remote_vs_local"`)
assert.Contains(t, output, `"local_file": "./draft.md"`)
}
func TestMarkdownCreateDryRun_RejectsEmptyWikiToken(t *testing.T) {
setMarkdownDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"markdown", "+create",
"--name", "README.md",
"--content", "# hello",
"--wiki-token", "",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
assert.Contains(t, result.Stdout+result.Stderr, "--wiki-token cannot be empty")
}
func TestMarkdownFetchDryRun_OutputFile(t *testing.T) {
setMarkdownDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"markdown", "+fetch",
"--file-token", "boxcnMarkdownDryRun",
"--output", "./copy.md",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
output := strings.TrimSpace(result.Stdout)
assert.Contains(t, output, "/open-apis/drive/v1/files/boxcnMarkdownDryRun/download")
assert.Contains(t, output, `"output": "./copy.md"`)
}
func TestMarkdownOverwriteDryRun_ContentFile(t *testing.T) {
setMarkdownDryRunConfigEnv(t)
dir := t.TempDir()
content := "# overwrite test\n"
require.NoError(t, os.WriteFile(dir+"/input.md", []byte(content), 0o644))
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"markdown", "+overwrite",
"--file-token", "boxcnMarkdownDryRun",
"--content", "@input.md",
"--dry-run",
},
DefaultAs: "bot",
WorkDir: dir,
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
output := strings.TrimSpace(result.Stdout)
assert.Contains(t, output, "/open-apis/drive/v1/metas/batch_query")
assert.Contains(t, output, "/open-apis/drive/v1/files/upload_all")
assert.Contains(t, output, `"file_token": "boxcnMarkdownDryRun"`)
assert.Contains(t, output, `"size": 17`)
}
func TestMarkdownOverwriteDryRun_RejectsEmptyFile(t *testing.T) {
setMarkdownDryRunConfigEnv(t)
dir := t.TempDir()
require.NoError(t, os.WriteFile(dir+"/empty.md", []byte{}, 0o644))
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"markdown", "+overwrite",
"--file-token", "boxcnMarkdownDryRun",
"--file", "empty.md",
"--dry-run",
},
DefaultAs: "bot",
WorkDir: dir,
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
if api := gjson.Get(result.Stdout, "api"); api.IsArray() && len(api.Array()) > 0 {
t.Fatalf("dry-run api list must be empty when validation fails\nstdout:\n%s", result.Stdout)
}
errMsg := gjson.Get(result.Stdout, "error").String()
assert.Contains(t, errMsg, "empty markdown content is not supported")
}
func TestMarkdownPatchDryRun_Content(t *testing.T) {
setMarkdownDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"markdown", "+patch",
"--file-token", "boxcnMarkdownDryRun",
"--pattern", "TODO",
"--content", "DONE",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
output := strings.TrimSpace(result.Stdout)
assert.Contains(t, output, "/open-apis/drive/v1/files/boxcnMarkdownDryRun/download")
assert.Contains(t, output, "/open-apis/drive/v1/metas/batch_query")
assert.Contains(t, output, "/open-apis/drive/v1/files/upload_all")
assert.Contains(t, output, "/open-apis/drive/v1/files/upload_prepare")
assert.Contains(t, output, "/open-apis/drive/v1/files/upload_part")
assert.Contains(t, output, "/open-apis/drive/v1/files/upload_finish")
}
func setMarkdownDryRunConfigEnv(t *testing.T) {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "markdown_dryrun_test")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "markdown_dryrun_secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
}
@@ -0,0 +1,264 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package markdown
import (
"context"
"os"
"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"
)
func TestMarkdownLifecycleWorkflow(t *testing.T) {
if os.Getenv("LARK_MARKDOWN_E2E") == "" {
t.Skip("set LARK_MARKDOWN_E2E=1 to run markdown live workflow after backend version support is deployed")
}
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
clie2e.SkipWithoutUserToken(t)
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
suffix := clie2e.GenerateSuffix()
fileName := "lark-cli-e2e-markdown-" + suffix + ".md"
initialContent := "# Initial\n\nhello markdown workflow\n"
patchedContent := "# Initial\n\nhello patched workflow\n"
updatedContent := "# Updated\n\nnew body\n"
createResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"markdown", "+create",
"--name", fileName,
"--content", initialContent,
},
DefaultAs: "user",
})
require.NoError(t, err)
createResult.AssertExitCode(t, 0)
createResult.AssertStdoutStatus(t, true)
fileToken := gjson.Get(createResult.Stdout, "data.file_token").String()
require.NotEmpty(t, fileToken, "stdout:\n%s", createResult.Stdout)
parentT.Cleanup(func() {
cleanupCtx, cleanupCancel := clie2e.CleanupContext()
defer cleanupCancel()
deleteResult, deleteErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{
Args: []string{
"drive", "+delete",
"--file-token", fileToken,
"--type", "file",
"--yes",
},
DefaultAs: "user",
})
clie2e.ReportCleanupFailure(parentT, "delete markdown file "+fileToken, deleteResult, deleteErr)
})
fetchInitialResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"markdown", "+fetch",
"--file-token", fileToken,
},
DefaultAs: "user",
})
require.NoError(t, err)
fetchInitialResult.AssertExitCode(t, 0)
fetchInitialResult.AssertStdoutStatus(t, true)
require.Equal(t, initialContent, gjson.Get(fetchInitialResult.Stdout, "data.content").String(), "stdout:\n%s", fetchInitialResult.Stdout)
patchResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"markdown", "+patch",
"--file-token", fileToken,
"--pattern", "hello markdown workflow",
"--content", "hello patched workflow",
},
DefaultAs: "user",
})
require.NoError(t, err)
patchResult.AssertExitCode(t, 0)
patchResult.AssertStdoutStatus(t, true)
require.Equal(t, true, gjson.Get(patchResult.Stdout, "data.updated").Bool(), "stdout:\n%s", patchResult.Stdout)
require.Equal(t, int64(1), gjson.Get(patchResult.Stdout, "data.match_count").Int(), "stdout:\n%s", patchResult.Stdout)
require.NotEmpty(t, gjson.Get(patchResult.Stdout, "data.version").String(), "stdout:\n%s", patchResult.Stdout)
fetchPatchedResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"markdown", "+fetch",
"--file-token", fileToken,
},
DefaultAs: "user",
})
require.NoError(t, err)
fetchPatchedResult.AssertExitCode(t, 0)
fetchPatchedResult.AssertStdoutStatus(t, true)
require.Equal(t, patchedContent, gjson.Get(fetchPatchedResult.Stdout, "data.content").String(), "stdout:\n%s", fetchPatchedResult.Stdout)
overwriteResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"markdown", "+overwrite",
"--file-token", fileToken,
"--content", updatedContent,
},
DefaultAs: "user",
})
require.NoError(t, err)
overwriteResult.AssertExitCode(t, 0)
overwriteResult.AssertStdoutStatus(t, true)
require.NotEmpty(t, gjson.Get(overwriteResult.Stdout, "data.version").String(), "stdout:\n%s", overwriteResult.Stdout)
fetchUpdatedResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"markdown", "+fetch",
"--file-token", fileToken,
},
DefaultAs: "user",
})
require.NoError(t, err)
fetchUpdatedResult.AssertExitCode(t, 0)
fetchUpdatedResult.AssertStdoutStatus(t, true)
require.Equal(t, updatedContent, gjson.Get(fetchUpdatedResult.Stdout, "data.content").String(), "stdout:\n%s", fetchUpdatedResult.Stdout)
historyResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+version-history",
"--file-token", fileToken,
},
DefaultAs: "user",
})
require.NoError(t, err)
historyResult.AssertExitCode(t, 0)
historyResult.AssertStdoutStatus(t, true)
latestVersion := gjson.Get(overwriteResult.Stdout, "data.version").String()
require.NotEmpty(t, latestVersion, "stdout:\n%s", overwriteResult.Stdout)
versions := gjson.Get(historyResult.Stdout, "data.versions").Array()
require.GreaterOrEqual(t, len(versions), 2, "stdout:\n%s", historyResult.Stdout)
var previousVersion string
// version-history returns versions in descending chronological order;
// pick the first non-latest as the previous version.
for _, version := range versions {
candidate := version.Get("version").String()
if candidate != "" && candidate != latestVersion {
previousVersion = candidate
break
}
}
require.NotEmpty(t, previousVersion, "stdout:\n%s", historyResult.Stdout)
diffResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"markdown", "+diff",
"--file-token", fileToken,
"--from-version", previousVersion,
"--to-version", latestVersion,
},
DefaultAs: "user",
})
require.NoError(t, err)
diffResult.AssertExitCode(t, 0)
diffResult.AssertStdoutStatus(t, true)
assert.True(t, gjson.Get(diffResult.Stdout, "data.changed").Bool(), "stdout:\n%s", diffResult.Stdout)
assert.Equal(t, "remote_vs_remote", gjson.Get(diffResult.Stdout, "data.mode").String(), "stdout:\n%s", diffResult.Stdout)
assert.Equal(t, previousVersion, gjson.Get(diffResult.Stdout, "data.from_version").String(), "stdout:\n%s", diffResult.Stdout)
assert.Equal(t, latestVersion, gjson.Get(diffResult.Stdout, "data.to_version").String(), "stdout:\n%s", diffResult.Stdout)
assert.GreaterOrEqual(t, len(gjson.Get(diffResult.Stdout, "data.hunks").Array()), 1, "stdout:\n%s", diffResult.Stdout)
diffText := gjson.Get(diffResult.Stdout, "data.diff").String()
assert.True(t, strings.Contains(diffText, "-hello markdown workflow") || strings.Contains(diffText, "-# Initial"), "stdout:\n%s", diffResult.Stdout)
assert.True(t, strings.Contains(diffText, "+new body") || strings.Contains(diffText, "+# Updated"), "stdout:\n%s", diffResult.Stdout)
}
func TestMarkdownCreateWorkflow_WikiParent(t *testing.T) {
if os.Getenv("LARK_MARKDOWN_E2E") == "" {
t.Skip("set LARK_MARKDOWN_E2E=1 to run markdown live workflow after backend version support is deployed")
}
wikiToken := strings.TrimSpace(os.Getenv("LARK_MARKDOWN_E2E_WIKI_TOKEN"))
if wikiToken == "" {
t.Skip("set LARK_MARKDOWN_E2E_WIKI_TOKEN to run markdown live workflow against a wiki parent node")
}
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
suffix := clie2e.GenerateSuffix()
fileName := "lark-cli-e2e-markdown-wiki-" + suffix + ".md"
initialContent := "# Wiki Parent\n\nhello wiki markdown workflow\n"
createResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"markdown", "+create",
"--wiki-token", wikiToken,
"--name", fileName,
"--content", initialContent,
},
DefaultAs: "bot",
})
require.NoError(t, err)
createResult.AssertExitCode(t, 0)
createResult.AssertStdoutStatus(t, true)
fileToken := gjson.Get(createResult.Stdout, "data.file_token").String()
require.NotEmpty(t, fileToken, "stdout:\n%s", createResult.Stdout)
require.NotEmpty(t, gjson.Get(createResult.Stdout, "data.url").String(), "stdout:\n%s", createResult.Stdout)
parentT.Cleanup(func() {
requireDeleteWikiHostedMarkdownFile(parentT, fileToken)
})
fetchResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"markdown", "+fetch",
"--file-token", fileToken,
},
DefaultAs: "bot",
})
require.NoError(t, err)
fetchResult.AssertExitCode(t, 0)
fetchResult.AssertStdoutStatus(t, true)
require.Equal(t, initialContent, gjson.Get(fetchResult.Stdout, "data.content").String(), "stdout:\n%s", fetchResult.Stdout)
}
func requireDeleteWikiHostedMarkdownFile(parentT *testing.T, fileToken string) {
parentT.Helper()
request := clie2e.Request{
Args: []string{
"drive", "+delete",
"--file-token", fileToken,
"--type", "file",
"--yes",
},
}
for _, identity := range []string{"bot", "user"} {
cleanupCtx, cleanupCancel := clie2e.CleanupContext()
result, err := clie2e.RunCmd(cleanupCtx, clie2e.Request{
Args: request.Args,
DefaultAs: identity,
})
cleanupCancel()
if err == nil && result != nil && result.ExitCode == 0 {
return
}
}
parentT.Fatalf("cleanup failed: could not delete wiki-hosted markdown file %s with either bot or user identity", fileToken)
}

Some files were not shown because too many files have changed in this diff Show More