feat: add stable cli e2e tests (#401)

* feat: add stable bot-only cli e2e subset

Change-Id: I62edf59d179e407954f65f82e94cf5dcf4938080

* fix: address review comments on stable cli e2e tests

Change-Id: I4436100c30adf2694cd06953961f8d77f576fc1e

* fix: reduce flakiness in drive and im e2e helpers

Change-Id: I51e77d857f1fd9aec5ee34adf5045cc695239f21

* fix: document missing drive cleanup support

Change-Id: I3d4f034145bd69fb7640e707fcda05146b8754c7

* style: unify e2e cleanup comments

Change-Id: I40d906c9168754ad71ef9fb770ff4c340fc19beb

* test: update e2e assertions

Change-Id: I73c21b4b38d4ced7ea27cb327075957ec2b9a2a2

* test: stabilize cli e2e bot-only coverage

Change-Id: Ied897c37c4f42e446d55d110461aa34ae198195d
This commit is contained in:
Yuxuan Zhao
2026-04-12 16:52:41 +08:00
committed by GitHub
parent f6b8091843
commit 085ffd87f3
25 changed files with 1862 additions and 7 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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