Feat/cli e2e tests with UAT (#528)

* test: expand and stabilize cli e2e workflows

* ci: run deadcode with test entrypoints
This commit is contained in:
Yuxuan Zhao
2026-04-17 16:57:17 +08:00
committed by GitHub
parent 3ad6f2fac4
commit 5280517d4b
53 changed files with 2942 additions and 635 deletions

View File

@@ -153,14 +153,14 @@ jobs:
run: |
# Analyze current HEAD (strip line:col for stable diff across line shifts)
# Filter "go: downloading ..." lines to avoid false diffs from module cache state
go run golang.org/x/tools/cmd/deadcode@v0.31.0 ./... 2>&1 | \
go run golang.org/x/tools/cmd/deadcode@v0.31.0 -test ./... 2>&1 | \
grep -v '^go: ' | \
sed 's/:[0-9][0-9]*:[0-9][0-9]*:/:/' | sort > /tmp/dc-head.txt
# Analyze base branch via worktree
git worktree add -q /tmp/dc-base "origin/${{ github.base_ref }}"
(cd /tmp/dc-base && python3 scripts/fetch_meta.py && \
go run golang.org/x/tools/cmd/deadcode@v0.31.0 ./... 2>&1 | \
go run golang.org/x/tools/cmd/deadcode@v0.31.0 -test ./... 2>&1 | \
grep -v '^go: ' | \
sed 's/:[0-9][0-9]*:[0-9][0-9]*:/:/' | sort > /tmp/dc-base.txt) || {
echo "::warning::Failed to analyze base branch — skipping incremental dead code check"
@@ -209,6 +209,7 @@ jobs:
env:
TEST_BOT1_APP_ID: ${{ secrets.TEST_BOT1_APP_ID }}
TEST_BOT1_APP_SECRET: ${{ secrets.TEST_BOT1_APP_SECRET }}
TEST_USER_ACCESS_TOKEN: ${{ secrets.TEST_USER_ACCESS_TOKEN }}
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
@@ -229,6 +230,8 @@ jobs:
- name: Run CLI E2E tests
env:
LARK_CLI_BIN: ${{ github.workspace }}/lark-cli
LARKSUITE_CLI_APP_ID: ${{ env.TEST_BOT1_APP_ID }}
LARKSUITE_CLI_USER_ACCESS_TOKEN: ${{ env.TEST_USER_ACCESS_TOKEN }}
run: |
packages=$(go list ./tests/cli_e2e/... | grep -v '^github.com/larksuite/cli/tests/cli_e2e$' | grep -v '/demo$')
if [ -z "$packages" ]; then

View File

@@ -22,7 +22,7 @@ func TestBase_BasicWorkflow(t *testing.T) {
baseName := "lark-cli-e2e-base-basic-" + clie2e.GenerateSuffix()
baseToken := createBaseWithRetry(t, ctx, baseName)
t.Run("get base", func(t *testing.T) {
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",
@@ -49,7 +49,7 @@ func TestBase_BasicWorkflow(t *testing.T) {
`{"name":"Main","type":"grid"}`,
)
t.Run("get table", func(t *testing.T) {
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",
@@ -61,7 +61,7 @@ func TestBase_BasicWorkflow(t *testing.T) {
assert.Equal(t, tableName, gjson.Get(result.Stdout, "data.table.name").String())
})
t.Run("list tables and find created table", func(t *testing.T) {
t.Run("list tables and find created table as bot", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"base", "+table-list", "--base-token", baseToken},
DefaultAs: "bot",

View File

@@ -49,7 +49,7 @@ func TestBase_RoleWorkflow(t *testing.T) {
}
})
t.Run("list", func(t *testing.T) {
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",
@@ -81,7 +81,7 @@ func TestBase_RoleWorkflow(t *testing.T) {
require.NotEmpty(t, roleID, "stdout:\n%s", result.Stdout)
})
t.Run("get", func(t *testing.T) {
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{
@@ -98,7 +98,7 @@ func TestBase_RoleWorkflow(t *testing.T) {
assert.Equal(t, roleID, gjson.Get(rolePayload, "role_id").String())
})
t.Run("update", func(t *testing.T) {
t.Run("update role as bot", func(t *testing.T) {
require.NotEmpty(t, roleID, "role ID should be resolved before update")
updatedRoleName := roleName + " Updated"

View File

@@ -0,0 +1,91 @@
# Base CLI E2E Coverage
## Metrics
- Denominator: 73 leaf commands
- Covered: 10
- Coverage: 13.7%
## 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`.
- 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 +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 | | none | field workflows not covered |
| ✕ | 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 | | none | record workflows not covered |
| ✕ | 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 |

View File

@@ -31,7 +31,7 @@ func TestCalendar_CreateEvent(t *testing.T) {
var eventID string
calendarID := getPrimaryCalendarID(t, ctx)
t.Run("create event with shortcut", func(t *testing.T) {
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,
@@ -50,7 +50,7 @@ func TestCalendar_CreateEvent(t *testing.T) {
require.NotEmpty(t, eventID)
})
t.Run("verify event created", func(t *testing.T) {
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"},
@@ -69,7 +69,7 @@ func TestCalendar_CreateEvent(t *testing.T) {
assert.Equal(t, unixSecondsRFC3339(endAt), gjson.Get(result.Stdout, "data.event.end_time.timestamp").String())
})
t.Run("delete event", func(t *testing.T) {
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"},

View File

@@ -26,7 +26,7 @@ func TestCalendar_ManageCalendar(t *testing.T) {
var createdCalendarID string
t.Run("list calendars", func(t *testing.T) {
t.Run("list calendars as bot", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"calendar", "calendars", "list"},
DefaultAs: "bot",
@@ -37,12 +37,12 @@ func TestCalendar_ManageCalendar(t *testing.T) {
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) {
t.Run("get primary calendar as bot", func(t *testing.T) {
primaryCalendarID := getPrimaryCalendarID(t, ctx)
require.NotEmpty(t, primaryCalendarID)
})
t.Run("create calendar", func(t *testing.T) {
t.Run("create calendar as bot", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"calendar", "calendars", "create"},
DefaultAs: "bot",
@@ -59,7 +59,7 @@ func TestCalendar_ManageCalendar(t *testing.T) {
require.NotEmpty(t, createdCalendarID)
})
t.Run("get created calendar", func(t *testing.T) {
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"},
@@ -76,7 +76,7 @@ func TestCalendar_ManageCalendar(t *testing.T) {
assert.Equal(t, calendarDescription, gjson.Get(result.Stdout, "data.description").String())
})
t.Run("find created calendar in list", func(t *testing.T) {
t.Run("find created calendar in list as bot", func(t *testing.T) {
require.NotEmpty(t, createdCalendarID)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"calendar", "calendars", "list"},
@@ -88,7 +88,7 @@ func TestCalendar_ManageCalendar(t *testing.T) {
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) {
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"},
@@ -105,7 +105,7 @@ func TestCalendar_ManageCalendar(t *testing.T) {
result.AssertStdoutStatus(t, 0)
})
t.Run("verify updated calendar", func(t *testing.T) {
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"},
@@ -120,7 +120,7 @@ func TestCalendar_ManageCalendar(t *testing.T) {
assert.Equal(t, updatedCalendarSummary, gjson.Get(result.Stdout, "data.summary").String())
})
t.Run("delete calendar", func(t *testing.T) {
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"},

View File

@@ -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, 0)
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, 0)
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)
})
}

View File

@@ -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")
})
}

View File

@@ -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)
})
}

View File

@@ -0,0 +1,43 @@
# Calendar CLI E2E Coverage
## Metrics
- Denominator: 23 leaf commands
- Covered: 12
- Coverage: 52.2%
## 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`, `calendar calendars list`, and `calendar calendars patch`; key `t.Run(...)` proof points are `get primary calendar as bot`, `create calendar as bot`, `get created calendar as bot`, `find created calendar in list 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 | calendar_manage_calendar_test.go::TestCalendar_ManageCalendar/list calendars as bot; calendar_manage_calendar_test.go::TestCalendar_ManageCalendar/find created calendar in list as bot | none | |
| ✓ | 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 |

View File

@@ -30,6 +30,38 @@ func getPrimaryCalendarID(t *testing.T, ctx context.Context) string {
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, 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 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)
}

View File

@@ -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, 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 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)
})
}

View File

@@ -1,51 +0,0 @@
// 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

@@ -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 |

View File

@@ -14,7 +14,6 @@ import (
"os/exec"
"path/filepath"
"strings"
"sync"
"testing"
"time"
@@ -25,9 +24,46 @@ import (
const EnvBinaryPath = "LARK_CLI_BIN"
const projectRootMarkerDir = "tests"
const cliBinaryName = "lark-cli"
const defaultIdentity = "bot"
const CleanupTimeout = 30 * time.Second
var defaultAsInitOnce sync.Once
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 {
@@ -46,6 +82,8 @@ type Request struct {
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
}
// Result captures process execution output.
@@ -74,19 +112,15 @@ func RunCmd(ctx context.Context, req Request) (*Result, error) {
return nil, err
}
// Best-effort initialization only. Failing to set default-as should not hide
// the actual command-under-test result, because some environments may still
// run the target CLI flow successfully without this convenience setup.
defaultAsInitOnce.Do(func() {
_ = setDefaultAs(ctx, binaryPath, defaultIdentity)
})
args, err := BuildArgs(req)
if err != nil {
return nil, err
}
cmd := exec.CommandContext(ctx, binaryPath, args...)
if req.WorkDir != "" {
cmd.Dir = req.WorkDir
}
var stdout bytes.Buffer
var stderr bytes.Buffer
@@ -166,6 +200,72 @@ func GenerateSuffix() string {
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 {
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
}
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
}
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"))
}
// ResolveBinaryPath finds the CLI binary path using request, env, then PATH.
func ResolveBinaryPath(req Request) (string, error) {
if req.BinaryPath != "" {
@@ -259,16 +359,6 @@ func findProjectRootDir() (string, error) {
return "", fmt.Errorf("project root not found from cwd using marker %q", projectRootMarkerDir)
}
func setDefaultAs(ctx context.Context, binaryPath string, identity string) error {
cmd := exec.CommandContext(ctx, binaryPath, "config", "default-as", identity)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("set default-as %q: %w; stderr: %s", identity, err, strings.TrimSpace(stderr.String()))
}
return nil
}
func exitCode(err error) int {
if err == nil {
return 0

View File

@@ -7,11 +7,8 @@ import (
"context"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -101,10 +98,55 @@ func TestBuildArgs(t *testing.T) {
})
}
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) {
resetDefaultAsInitForTest()
fake := newFakeCLI(t, "auto")
fake := newFakeCLI(t)
result, err := RunCmd(context.Background(), Request{
BinaryPath: fake.BinaryPath,
Args: []string{"--stdout-json", `{"ok":true}`},
@@ -119,8 +161,7 @@ func TestRunCmd(t *testing.T) {
})
t.Run("captures stderr and exit code on failure", func(t *testing.T) {
resetDefaultAsInitForTest()
fake := newFakeCLI(t, "auto")
fake := newFakeCLI(t)
result, err := RunCmd(context.Background(), Request{
BinaryPath: fake.BinaryPath,
Args: []string{"--stderr-json", `{"ok":false}`, "--exit", "3"},
@@ -134,45 +175,8 @@ func TestRunCmd(t *testing.T) {
assert.Equal(t, false, errMap["ok"])
})
t.Run("defaults default-as to bot", func(t *testing.T) {
resetDefaultAsInitForTest()
fake := newFakeCLI(t, "auto")
result, err := RunCmd(context.Background(), Request{
BinaryPath: fake.BinaryPath,
Args: []string{"emit-default-as"},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "bot", strings.TrimSpace(result.Stdout))
assert.Equal(t, "bot\n", fake.ReadState(t))
assert.Equal(t, 1, fake.ReadSetCount(t))
})
t.Run("initializes default-as only once per binary", func(t *testing.T) {
resetDefaultAsInitForTest()
fake := newFakeCLI(t, "auto")
first, err := RunCmd(context.Background(), Request{
BinaryPath: fake.BinaryPath,
Args: []string{"emit-default-as"},
})
require.NoError(t, err)
first.AssertExitCode(t, 0)
assert.Equal(t, "bot", strings.TrimSpace(first.Stdout))
second, err := RunCmd(context.Background(), Request{
BinaryPath: fake.BinaryPath,
Args: []string{"emit-default-as"},
})
require.NoError(t, err)
second.AssertExitCode(t, 0)
assert.Equal(t, "bot", strings.TrimSpace(second.Stdout))
assert.Equal(t, "bot\n", fake.ReadState(t))
assert.Equal(t, 1, fake.ReadSetCount(t))
})
t.Run("passes explicit default-as as flag and command-line value wins", func(t *testing.T) {
resetDefaultAsInitForTest()
fake := newFakeCLI(t, "auto")
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"},
@@ -181,13 +185,10 @@ func TestRunCmd(t *testing.T) {
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "user", strings.TrimSpace(result.Stdout))
assert.Equal(t, "bot\n", fake.ReadState(t))
assert.Equal(t, 1, fake.ReadSetCount(t))
})
t.Run("asserts stdout code payloads", func(t *testing.T) {
resetDefaultAsInitForTest()
fake := newFakeCLI(t, "auto")
fake := newFakeCLI(t)
result, err := RunCmd(context.Background(), Request{
BinaryPath: fake.BinaryPath,
Args: []string{"--stdout-json", `{"code":0,"data":{"id":"x"}}`},
@@ -198,27 +199,8 @@ func TestRunCmd(t *testing.T) {
result.AssertStdoutStatus(t, 0)
})
t.Run("default-as init respects context cancellation", func(t *testing.T) {
resetDefaultAsInitForTest()
fake := newFakeCLI(t, "auto")
t.Setenv("FAKE_DEFAULT_AS_SLEEP", "1")
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
result, err := RunCmd(ctx, Request{
BinaryPath: fake.BinaryPath,
Args: []string{"emit-default-as"},
})
require.NoError(t, err)
assert.Error(t, result.RunErr)
assert.ErrorIs(t, result.RunErr, context.DeadlineExceeded)
assert.Equal(t, 0, fake.ReadSetCount(t))
})
t.Run("passes stdin to process", func(t *testing.T) {
resetDefaultAsInitForTest()
fake := newFakeCLI(t, "auto")
fake := newFakeCLI(t)
result, err := RunCmd(context.Background(), Request{
BinaryPath: fake.BinaryPath,
Args: []string{"emit-stdin"},
@@ -232,49 +214,19 @@ func TestRunCmd(t *testing.T) {
type fakeCLI struct {
BinaryPath string
statePath string
countPath string
}
func newFakeCLI(t *testing.T, initialDefaultAs string) fakeCLI {
func newFakeCLI(t *testing.T) fakeCLI {
t.Helper()
tmpDir := t.TempDir()
statePath := filepath.Join(tmpDir, "default-as.txt")
countPath := filepath.Join(tmpDir, "set-count.txt")
require.NoError(t, os.WriteFile(statePath, []byte(initialDefaultAs+"\n"), 0o644))
require.NoError(t, os.WriteFile(countPath, []byte("0\n"), 0o644))
script := `#!/bin/sh
state_file="__STATE_FILE__"
count_file="__COUNT_FILE__"
if [ ! -f "$state_file" ]; then
echo "auto" > "$state_file"
fi
if [ "$1" = "config" ] && [ "$2" = "default-as" ]; then
if [ "$#" -eq 2 ]; then
value=$(tr -d '\r\n' < "$state_file")
echo "default-as: $value"
exit 0
if [ "$1" = "auth" ] && [ "$2" = "status" ] && [ "$3" = "--verify" ]; then
if [ -n "$FAKE_AUTH_STATUS_STDOUT" ]; then
echo "$FAKE_AUTH_STATUS_STDOUT"
fi
if [ "$#" -eq 3 ]; then
if [ -n "$FAKE_DEFAULT_AS_SLEEP" ]; then
sleep "$FAKE_DEFAULT_AS_SLEEP"
fi
count=$(tr -d '\r\n' < "$count_file")
count=$((count + 1))
echo "$count" > "$count_file"
echo "$3" > "$state_file"
exit 0
fi
fi
if [ "$1" = "emit-default-as" ]; then
tr -d '\r\n' < "$state_file"
echo
exit 0
exit "${FAKE_AUTH_STATUS_EXIT_CODE:-0}"
fi
if [ "$1" = "emit-arg" ]; then
@@ -318,34 +270,14 @@ done
exit "$exit_code"
`
script = strings.ReplaceAll(script, "__STATE_FILE__", statePath)
script = strings.ReplaceAll(script, "__COUNT_FILE__", countPath)
binaryPath := filepath.Join(tmpDir, "fake-"+cliBinaryName)
require.NoError(t, os.WriteFile(binaryPath, []byte(script), 0o755))
return fakeCLI{
BinaryPath: binaryPath,
statePath: statePath,
countPath: countPath,
}
}
func (f fakeCLI) ReadState(t *testing.T) string {
t.Helper()
stateBytes, err := os.ReadFile(f.statePath)
require.NoError(t, err)
return string(stateBytes)
}
func (f fakeCLI) ReadSetCount(t *testing.T) int {
t.Helper()
countBytes, err := os.ReadFile(f.countPath)
require.NoError(t, err)
count, err := strconv.Atoi(strings.TrimSpace(string(countBytes)))
require.NoError(t, err)
return count
}
func assertSamePath(t *testing.T, want string, got string) {
t.Helper()
gotReal, err := filepath.EvalSymlinks(got)
@@ -362,7 +294,3 @@ func mustWriteExecutable(t *testing.T, path string) string {
require.NoError(t, err)
return absPath
}
func resetDefaultAsInitForTest() {
defaultAsInitOnce = sync.Once{}
}

View File

@@ -27,9 +27,10 @@ func TestDemo_TaskLifecycle(t *testing.T) {
var taskGUID string
t.Run("create", func(t *testing.T) {
t.Run("create as bot", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"task", "+create"},
Args: []string{"task", "+create"},
DefaultAs: "bot",
Data: map[string]any{
"summary": createdSummary,
"description": createdDescription,
@@ -42,25 +43,24 @@ func TestDemo_TaskLifecycle(t *testing.T) {
require.NotEmpty(t, taskGUID, "stdout:\n%s", result.Stdout)
parentT.Cleanup(func() {
deleteResult, deleteErr := clie2e.RunCmd(context.Background(), clie2e.Request{
Args: []string{"task", "tasks", "delete"},
Params: map[string]any{"task_guid": taskGUID},
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},
})
if deleteErr != nil {
parentT.Errorf("delete task %s: %v", taskGUID, deleteErr)
return
}
if deleteResult.ExitCode != 0 {
parentT.Errorf("delete task %s failed: exit=%d stderr=%s", taskGUID, deleteResult.ExitCode, deleteResult.Stderr)
}
clie2e.ReportCleanupFailure(parentT, "delete task "+taskGUID, deleteResult, deleteErr)
})
})
t.Run("update", func(t *testing.T) {
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},
Args: []string{"task", "+update", "--task-id", taskGUID},
DefaultAs: "bot",
Data: map[string]any{
"summary": updatedSummary,
"description": updatedDescription,
@@ -71,12 +71,13 @@ func TestDemo_TaskLifecycle(t *testing.T) {
result.AssertStdoutStatus(t, true)
})
t.Run("get", func(t *testing.T) {
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"},
Params: map[string]any{"task_guid": taskGUID},
Args: []string{"task", "tasks", "get"},
DefaultAs: "bot",
Params: map[string]any{"task_guid": taskGUID},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)

View File

@@ -0,0 +1,26 @@
# Docs CLI E2E Coverage
## Metrics
- Denominator: 8 leaf commands
- Covered: 3
- Coverage: 37.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.
- 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 | `--folder-token`; `--title`; `--markdown` | helper asserts returned doc id |
| ✓ | docs +fetch | shortcut | 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 | `--doc <docToken>` | |
| ✕ | 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 | `--doc`; `--mode overwrite`; `--markdown`; `--new-title` | |
| ✕ | docs +whiteboard-update | shortcut | | none | requires whiteboard fixture and DSL-specific assertions |

View File

@@ -9,26 +9,28 @@ import (
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
drivee2e "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_CreateAndFetchWorkflow(t *testing.T) {
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."
folderToken := createDocsFolderWithRetry(t, ctx, folderName)
folderToken := drivee2e.CreateDriveFolder(t, parentT, ctx, folderName, "bot", "")
var docToken string
t.Run("create", func(t *testing.T) {
docToken = createDocWithRetry(t, ctx, folderToken, docTitle, docContent)
docToken = createDocWithRetry(t, parentT, ctx, folderToken, docTitle, docContent, "bot")
})
t.Run("fetch", func(t *testing.T) {
@@ -46,3 +48,35 @@ func TestDocs_CreateAndFetchWorkflow(t *testing.T) {
assert.Equal(t, docTitle, gjson.Get(result.Stdout, "data.title").String())
})
}
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
folderToken := drivee2e.CreateDriveFolder(t, parentT, ctx, folderName, "user", "")
t.Run("create as user", func(t *testing.T) {
docToken = createDocWithRetry(t, parentT, ctx, folderToken, docTitle, docContent, "user")
})
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},
DefaultAs: "user",
})
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

@@ -9,6 +9,7 @@ import (
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
drivee2e "github.com/larksuite/cli/tests/cli_e2e/drive"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
@@ -16,6 +17,7 @@ import (
// 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)
@@ -26,14 +28,14 @@ func TestDocs_UpdateWorkflow(t *testing.T) {
originalContent := "# Original\n\nThis is the original content."
updatedContent := "# Updated\n\nThis is the updated content."
folderToken := createDocsFolderWithRetry(t, ctx, folderName)
folderToken := drivee2e.CreateDriveFolder(t, parentT, ctx, folderName, "bot", "")
var docToken string
t.Run("create", func(t *testing.T) {
docToken = createDocWithRetry(t, ctx, folderToken, originalTitle, originalContent)
t.Run("create as bot", func(t *testing.T) {
docToken = createDocWithRetry(t, parentT, ctx, folderToken, originalTitle, originalContent, "bot")
})
t.Run("update-title-and-content", func(t *testing.T) {
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{
@@ -44,13 +46,14 @@ func TestDocs_UpdateWorkflow(t *testing.T) {
"--markdown", updatedContent,
"--new-title", updatedTitle,
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
})
t.Run("verify", func(t *testing.T) {
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{
@@ -58,6 +61,7 @@ func TestDocs_UpdateWorkflow(t *testing.T) {
"docs", "+fetch",
"--doc", docToken,
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)

View File

@@ -12,43 +12,42 @@ import (
"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 {
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.RunCmdWithRetry(ctx, clie2e.Request{
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"docs", "+create",
"--folder-token", folderToken,
"--title", title,
"--markdown", markdown,
},
}, clie2e.RetryOptions{})
DefaultAs: defaultAs,
})
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)
parentT.Cleanup(func() {
cleanupCtx, cancel := clie2e.CleanupContext()
defer cancel()
deleteResult, deleteErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{
Args: []string{
"drive", "+delete",
"--file-token", docToken,
"--type", "docx",
"--yes",
},
DefaultAs: defaultAs,
})
clie2e.ReportCleanupFailure(parentT, "delete doc "+docToken, deleteResult, deleteErr)
})
return docToken
}

View File

@@ -0,0 +1,44 @@
# Drive CLI E2E Coverage
## Metrics
- Denominator: 28 leaf commands
- Covered: 1
- Coverage: 3.6%
## 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`.
- Cleanup note: `drive files delete` is only exercised in cleanup and is intentionally left uncovered.
- Blocked area: upload, export, comment, permission, subscription, and reply flows still need deterministic remote fixtures and filesystem setup.
## Command Table
| Status | Cmd | Type | Testcase | Key parameter shapes | Notes / uncovered reason |
| --- | --- | --- | --- | --- | --- |
| ✕ | drive +add-comment | shortcut | | none | no comment workflow yet |
| ✕ | drive +delete | shortcut | | none | no primary delete workflow yet |
| ✕ | drive +download | shortcut | | none | no file fixture workflow yet |
| ✕ | drive +export | shortcut | | none | no 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 +task_result | shortcut | | none | no async task-result workflow yet |
| ✕ | drive +upload | shortcut | | none | no upload workflow yet |
| ✕ | 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 |

View File

@@ -18,10 +18,12 @@ func TestDrive_FilesCreateFolderWorkflow(t *testing.T) {
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", func(t *testing.T) {
folderToken := createDriveFolder(t, parentT, ctx, folderName)
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")
}

View File

@@ -0,0 +1,55 @@
// 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/tidwall/gjson"
)
// 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 := clie2e.RunCmdWithRetry(cleanupCtx, clie2e.Request{
Args: []string{"drive", "+delete", "--file-token", folderToken, "--type", "folder", "--yes"},
DefaultAs: defaultAs,
}, clie2e.RetryOptions{})
clie2e.ReportCleanupFailure(parentT, "delete drive folder "+folderToken, deleteResult, deleteErr)
})
return folderToken
}

View File

@@ -7,38 +7,12 @@ 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 {
func createDriveFolder(t *testing.T, parentT *testing.T, ctx context.Context, name string, parentFolderToken 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"},
})
})
folderToken := CreateDriveFolder(t, parentT, ctx, name, "bot", parentFolderToken)
require.NotEmpty(t, folderToken)
return folderToken
}

View File

@@ -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)
})
}

View File

@@ -27,34 +27,37 @@ func TestIM_ChatUpdateWorkflow(t *testing.T) {
chatID := createChat(t, parentT, ctx, originalName)
t.Run("update chat name", func(t *testing.T) {
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", func(t *testing.T) {
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", func(t *testing.T) {
t.Run("get updated chat as bot", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"im", "chats", "get"},
Params: map[string]any{"chat_id": chatID},
Args: []string{"im", "chats", "get"},
DefaultAs: "bot",
Params: map[string]any{"chat_id": chatID},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
@@ -76,10 +79,11 @@ func TestIM_ChatsGetWorkflow(t *testing.T) {
chatID := createChat(t, parentT, ctx, chatName)
t.Run("get chat info", func(t *testing.T) {
t.Run("get chat info as bot", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"im", "chats", "get"},
Params: map[string]any{"chat_id": chatID},
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)
@@ -105,10 +109,11 @@ func TestIM_ChatsLinkWorkflow(t *testing.T) {
chatID := createChat(t, parentT, ctx, chatName)
t.Run("get chat share link", func(t *testing.T) {
t.Run("get chat share link as bot", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"im", "chats", "link"},
Params: map[string]any{"chat_id": chatID},
Args: []string{"im", "chats", "link"},
DefaultAs: "bot",
Params: map[string]any{"chat_id": chatID},
Data: map[string]any{
"validity_period": "week",
},

View File

@@ -0,0 +1,49 @@
# IM CLI E2E Coverage
## Metrics
- Denominator: 29 leaf commands
- Covered: 9
- Coverage: 31.0%
## 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`.
- 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 | `--chat-id`; `--text` | covered where returned message IDs feed follow-up reads |
| ✓ | 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 | | none | no forward workflow yet |
| ✕ | im messages merge_forward | api | | none | no merge-forward workflow yet |
| ✕ | im messages read_users | api | | none | no read-user workflow yet |
| ✕ | 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 |

View File

@@ -17,12 +17,18 @@ import (
// 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)
@@ -40,7 +46,12 @@ func createChat(t *testing.T, parentT *testing.T, ctx context.Context, name stri
}
// 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 {
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{
@@ -48,6 +59,7 @@ func sendMessage(t *testing.T, parentT *testing.T, ctx context.Context, chatID s
"--chat-id", chatID,
"--text", text,
},
DefaultAs: defaultAs,
})
require.NoError(t, err)
result.AssertExitCode(t, 0)

View File

@@ -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)
})
}

View File

@@ -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)
})
}

View File

@@ -1,53 +0,0 @@
// 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,79 @@
# Mail CLI E2E Coverage
## Metrics
- Denominator: 62 leaf commands
- Covered: 13
- Coverage: 21.0%
## 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_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 +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 +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 |

View File

@@ -0,0 +1,211 @@
// 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,
},
})
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, 0)
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, 0)
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, 0)
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,
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
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)
})
}

View File

@@ -0,0 +1,340 @@
// 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,
},
})
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,
},
})
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, 0)
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)
})
}

View File

@@ -0,0 +1,44 @@
# Sheets CLI E2E Coverage
## Metrics
- Denominator: 26 leaf commands
- Covered: 14
- Coverage: 53.8%
## Summary
- TestSheets_CRUDE2EWorkflow: proves `+create`, `+info`, `+write`, `+read`, `+append`, `+find`, and `+export`; key `t.Run(...)` proof points are `create spreadsheet with +create as bot`, `read data with +read as bot`, `find cells with +find as bot`, and `export spreadsheet with +export as bot`.
- TestSheets_CreateWorkflowAsUser: proves the UAT path for `sheets +create` and `sheets +info` through `create spreadsheet with +create as user` and `get spreadsheet info with +info as user`.
- TestSheets_SpreadsheetsResource: proves direct `spreadsheets create`, `spreadsheets get`, and `spreadsheets patch`.
- TestSheets_FilterWorkflow: proves `spreadsheet.sheet.filters create`, `get`, `update`, and `delete`, with supporting sheet setup through `+create`, `+info`, and `+write`.
- Cleanup note: workflow-created spreadsheets are cleaned up via `drive +delete --type sheet`; those cleanup-only executions are not counted as command coverage because no testcase asserts delete behavior as the primary proof surface.
## Command Table
| Status | Cmd | Type | Testcase | Key parameter shapes | Notes / uncovered reason |
| --- | --- | --- | --- | --- | --- |
| ✕ | sheets +add-dimension | shortcut | | none | no dimension workflow yet |
| ✓ | sheets +append | shortcut | sheets_crud_workflow_test.go::TestSheets_CRUDE2EWorkflow/append rows with +append as bot | `--spreadsheet-token`; `--sheet-id`; `--range`; `--values` | |
| ✕ | sheets +batch-set-style | shortcut | | none | no style workflow yet |
| ✓ | sheets +create | shortcut | sheets_crud_workflow_test.go::TestSheets_CRUDE2EWorkflow/create spreadsheet with +create as bot; sheets_filter_workflow_test.go::TestSheets_FilterWorkflow/create spreadsheet with initial data as bot; sheets_create_workflow_test.go::TestSheets_CreateWorkflowAsUser/create spreadsheet with +create as user | `--title` | |
| ✕ | sheets +delete-dimension | shortcut | | none | no dimension workflow yet |
| ✓ | sheets +export | shortcut | sheets_crud_workflow_test.go::TestSheets_CRUDE2EWorkflow/export spreadsheet with +export as bot | `--spreadsheet-token`; `--file-extension` | |
| ✓ | sheets +find | shortcut | sheets_crud_workflow_test.go::TestSheets_CRUDE2EWorkflow/find cells with +find as bot | `--spreadsheet-token`; `--sheet-id`; `--find`; `--range` | |
| ✓ | sheets +info | shortcut | sheets_crud_workflow_test.go::TestSheets_CRUDE2EWorkflow/get spreadsheet info with +info as bot; sheets_filter_workflow_test.go::TestSheets_FilterWorkflow/get sheet info as bot; sheets_create_workflow_test.go::TestSheets_CreateWorkflowAsUser/get spreadsheet info with +info as user | `--spreadsheet-token` | |
| ✕ | sheets +insert-dimension | shortcut | | none | no dimension workflow yet |
| ✕ | sheets +merge-cells | shortcut | | none | no merge workflow yet |
| ✕ | sheets +move-dimension | shortcut | | none | no dimension workflow yet |
| ✓ | sheets +read | shortcut | sheets_crud_workflow_test.go::TestSheets_CRUDE2EWorkflow/read data with +read as bot | `--spreadsheet-token`; `--sheet-id`; `--range` | |
| ✕ | sheets +replace | shortcut | | none | no replace workflow yet |
| ✕ | sheets +set-style | shortcut | | none | no style workflow yet |
| ✕ | sheets +unmerge-cells | shortcut | | none | no merge workflow yet |
| ✕ | sheets +update-dimension | shortcut | | none | no dimension workflow yet |
| ✓ | sheets +write | shortcut | sheets_crud_workflow_test.go::TestSheets_CRUDE2EWorkflow/write data with +write as bot; sheets_filter_workflow_test.go::TestSheets_FilterWorkflow/write test data for filtering as bot | `--spreadsheet-token`; `--sheet-id`; `--range`; `--values` | |
| ✕ | sheets +write-image | shortcut | | none | no image workflow yet |
| ✓ | sheets spreadsheet.sheet.filters create | api | sheets_filter_workflow_test.go::TestSheets_FilterWorkflow/create filter with spreadsheet.sheet.filters create as bot | `spreadsheet_token`; `sheet_id` in `--params`; filter JSON in `--data` | |
| ✓ | sheets spreadsheet.sheet.filters delete | api | sheets_filter_workflow_test.go::TestSheets_FilterWorkflow/delete filter with spreadsheet.sheet.filters delete as bot | `spreadsheet_token`; `sheet_id` in `--params` | |
| ✓ | sheets spreadsheet.sheet.filters get | api | sheets_filter_workflow_test.go::TestSheets_FilterWorkflow/get filter with spreadsheet.sheet.filters get as bot | `spreadsheet_token`; `sheet_id` in `--params` | |
| ✓ | sheets spreadsheet.sheet.filters update | api | sheets_filter_workflow_test.go::TestSheets_FilterWorkflow/update filter with spreadsheet.sheet.filters update as bot | `spreadsheet_token`; `sheet_id` in `--params`; filter JSON in `--data` | |
| ✕ | sheets spreadsheet.sheets find | api | | none | no direct API workflow yet |
| ✓ | sheets spreadsheets create | api | sheets_crud_workflow_test.go::TestSheets_SpreadsheetsResource/create spreadsheet with spreadsheets create as bot | `title` in `--data` | |
| ✓ | sheets spreadsheets get | api | sheets_crud_workflow_test.go::TestSheets_SpreadsheetsResource/get spreadsheet with spreadsheets get as bot | `spreadsheet_token` in `--params` | |
| ✓ | sheets spreadsheets patch | api | sheets_crud_workflow_test.go::TestSheets_SpreadsheetsResource/patch spreadsheet with spreadsheets patch as bot | `spreadsheet_token` in `--params`; title patch in `--data` | |

View File

@@ -0,0 +1,53 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"context"
"testing"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
drivee2e "github.com/larksuite/cli/tests/cli_e2e/drive"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func createSpreadsheet(t *testing.T, parentT *testing.T, ctx context.Context, title string, defaultAs string) string {
t.Helper()
folderToken := drivee2e.CreateDriveFolder(t, parentT, ctx, title+"-folder", defaultAs, "")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"sheets", "+create",
"--title", title,
"--folder-token", folderToken,
},
DefaultAs: defaultAs,
})
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, "stdout:\n%s", result.Stdout)
parentT.Cleanup(func() {
cleanupCtx, cancel := clie2e.CleanupContext()
defer cancel()
deleteResult, deleteErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{
Args: []string{
"drive", "+delete",
"--file-token", spreadsheetToken,
"--type", "sheet",
"--yes",
},
DefaultAs: defaultAs,
})
clie2e.ReportCleanupFailure(parentT, "delete spreadsheet "+spreadsheetToken, deleteResult, deleteErr)
})
return spreadsheetToken
}

View File

@@ -0,0 +1,46 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
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 TestSheets_CreateWorkflowAsUser(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
clie2e.SkipWithoutUserToken(t)
parentT := t
suffix := clie2e.GenerateSuffix()
title := "lark-cli-e2e-user-sheets-" + suffix
var spreadsheetToken string
t.Run("create spreadsheet with +create as user", func(t *testing.T) {
spreadsheetToken = createSpreadsheet(t, parentT, ctx, title, "user")
})
t.Run("get spreadsheet info with +info as user", 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},
DefaultAs: "user",
})
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())
require.NotEmpty(t, gjson.Get(result.Stdout, "data.sheets.sheets.0.sheet_id").String(), "stdout:\n%s", result.Stdout)
})
}

View File

@@ -7,10 +7,12 @@ import (
"context"
"encoding/json"
"fmt"
"path/filepath"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
drivee2e "github.com/larksuite/cli/tests/cli_e2e/drive"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
@@ -27,27 +29,15 @@ func TestSheets_CRUDE2EWorkflow(t *testing.T) {
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("create spreadsheet with +create as bot", func(t *testing.T) {
spreadsheetToken = createSpreadsheet(t, parentT, ctx, "lark-cli-e2e-sheets-"+suffix, "bot")
})
t.Run("get spreadsheet info with +info", func(t *testing.T) {
t.Run("get spreadsheet info with +info as bot", 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},
Args: []string{"sheets", "+info", "--spreadsheet-token", spreadsheetToken},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
@@ -58,7 +48,7 @@ func TestSheets_CRUDE2EWorkflow(t *testing.T) {
require.NotEmpty(t, sheetID, "sheet_id should not be empty, stdout: %s", result.Stdout)
})
t.Run("write data with +write", func(t *testing.T) {
t.Run("write data with +write as bot", func(t *testing.T) {
require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required")
require.NotEmpty(t, sheetID, "sheet_id is required")
@@ -77,13 +67,14 @@ func TestSheets_CRUDE2EWorkflow(t *testing.T) {
"--range", "A1:C3",
"--values", string(valuesJSON),
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
})
t.Run("read data with +read", func(t *testing.T) {
t.Run("read data with +read as bot", func(t *testing.T) {
require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required")
require.NotEmpty(t, sheetID, "sheet_id is required")
@@ -94,6 +85,7 @@ func TestSheets_CRUDE2EWorkflow(t *testing.T) {
"--sheet-id", sheetID,
"--range", "A1:C3",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
@@ -106,7 +98,7 @@ func TestSheets_CRUDE2EWorkflow(t *testing.T) {
assert.Equal(t, "Alice", values.Array()[1].Array()[0].String())
})
t.Run("append rows with +append", func(t *testing.T) {
t.Run("append rows with +append as bot", func(t *testing.T) {
require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required")
require.NotEmpty(t, sheetID, "sheet_id is required")
@@ -121,13 +113,14 @@ func TestSheets_CRUDE2EWorkflow(t *testing.T) {
"--range", "A4:C4",
"--values", string(valuesJSON),
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
})
t.Run("find cells with +find", func(t *testing.T) {
t.Run("find cells with +find as bot", func(t *testing.T) {
require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required")
require.NotEmpty(t, sheetID, "sheet_id is required")
@@ -139,6 +132,7 @@ func TestSheets_CRUDE2EWorkflow(t *testing.T) {
"--find", "Alice",
"--range", fmt.Sprintf("%s!A1:C10", sheetID),
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
@@ -149,22 +143,32 @@ func TestSheets_CRUDE2EWorkflow(t *testing.T) {
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) {
t.Run("export spreadsheet with +export as bot", func(t *testing.T) {
require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required")
outputDir := t.TempDir()
outputPath := filepath.Join(outputDir, "export.xlsx")
// 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",
"--output-path", "./export.xlsx",
},
DefaultAs: "bot",
WorkDir: outputDir,
})
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)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
savedPath := gjson.Get(result.Stdout, "data.saved_path").String()
require.NotEmpty(t, savedPath, "stdout:\n%s", result.Stdout)
savedPathReal, err := filepath.EvalSymlinks(savedPath)
require.NoError(t, err, "stdout:\n%s", result.Stdout)
outputPathReal, err := filepath.EvalSymlinks(outputPath)
require.NoError(t, err, "stdout:\n%s", result.Stdout)
assert.Equal(t, outputPathReal, savedPathReal, "stdout:\n%s", result.Stdout)
assert.FileExists(t, outputPath, "stdout:\n%s", result.Stdout)
})
}
@@ -177,13 +181,16 @@ func TestSheets_SpreadsheetsResource(t *testing.T) {
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"},
t.Run("create spreadsheet with spreadsheets create as bot", func(t *testing.T) {
folderToken := drivee2e.CreateDriveFolder(t, parentT, ctx, "lark-cli-e2e-sheets-resource-folder-"+suffix, "bot", "")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"sheets", "spreadsheets", "create"},
DefaultAs: "bot",
Data: map[string]any{
"title": "lark-cli-e2e-sheets-resource-" + suffix,
"title": "lark-cli-e2e-sheets-resource-" + suffix,
"folder_token": folderToken,
},
}, clie2e.RetryOptions{})
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
@@ -192,16 +199,29 @@ func TestSheets_SpreadsheetsResource(t *testing.T) {
require.NotEmpty(t, spreadsheetToken, "spreadsheet token should not be empty, stdout: %s", result.Stdout)
parentT.Cleanup(func() {
// Best-effort cleanup
cleanupCtx, cancel := clie2e.CleanupContext()
defer cancel()
deleteResult, deleteErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{
Args: []string{
"drive", "+delete",
"--file-token", spreadsheetToken,
"--type", "sheet",
"--yes",
},
DefaultAs: "bot",
})
clie2e.ReportCleanupFailure(parentT, "delete spreadsheet "+spreadsheetToken, deleteResult, deleteErr)
})
})
t.Run("get spreadsheet with spreadsheets get", func(t *testing.T) {
t.Run("get spreadsheet with spreadsheets get as bot", 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},
Args: []string{"sheets", "spreadsheets", "get"},
DefaultAs: "bot",
Params: map[string]any{"spreadsheet_token": spreadsheetToken},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
@@ -211,14 +231,15 @@ func TestSheets_SpreadsheetsResource(t *testing.T) {
assert.NotEmpty(t, gjson.Get(result.Stdout, "data.spreadsheet.url").String())
})
t.Run("patch spreadsheet with spreadsheets patch", func(t *testing.T) {
t.Run("patch spreadsheet with spreadsheets patch as bot", 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},
Args: []string{"sheets", "spreadsheets", "patch"},
DefaultAs: "bot",
Params: map[string]any{"spreadsheet_token": spreadsheetToken},
Data: map[string]any{"title": updatedTitle},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
@@ -226,8 +247,9 @@ func TestSheets_SpreadsheetsResource(t *testing.T) {
// 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},
Args: []string{"sheets", "spreadsheets", "get"},
DefaultAs: "bot",
Params: map[string]any{"spreadsheet_token": spreadsheetToken},
})
require.NoError(t, err)
getResult.AssertExitCode(t, 0)

View File

@@ -25,28 +25,16 @@ func TestSheets_FilterWorkflow(t *testing.T) {
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("create spreadsheet with initial data as bot", func(t *testing.T) {
spreadsheetToken = createSpreadsheet(t, parentT, ctx, "lark-cli-e2e-sheets-filter-"+suffix, "bot")
})
t.Run("get sheet info", func(t *testing.T) {
t.Run("get sheet info as bot", 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},
Args: []string{"sheets", "+info", "--spreadsheet-token", spreadsheetToken},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
@@ -56,7 +44,7 @@ func TestSheets_FilterWorkflow(t *testing.T) {
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) {
t.Run("write test data for filtering as bot", func(t *testing.T) {
require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required")
require.NotEmpty(t, sheetID, "sheet_id is required")
@@ -77,13 +65,14 @@ func TestSheets_FilterWorkflow(t *testing.T) {
"--range", "A1:C5",
"--values", string(valuesJSON),
},
DefaultAs: "bot",
})
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) {
t.Run("create filter with spreadsheet.sheet.filters create as bot", func(t *testing.T) {
require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required")
require.NotEmpty(t, sheetID, "sheet_id is required")
@@ -98,7 +87,8 @@ func TestSheets_FilterWorkflow(t *testing.T) {
}
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"sheets", "spreadsheet.sheet.filters", "create"},
Args: []string{"sheets", "spreadsheet.sheet.filters", "create"},
DefaultAs: "bot",
Params: map[string]any{
"spreadsheet_token": spreadsheetToken,
"sheet_id": sheetID,
@@ -110,12 +100,13 @@ func TestSheets_FilterWorkflow(t *testing.T) {
result.AssertStdoutStatus(t, 0)
})
t.Run("get filter with spreadsheet.sheet.filters get", func(t *testing.T) {
t.Run("get filter with spreadsheet.sheet.filters get as bot", 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"},
Args: []string{"sheets", "spreadsheet.sheet.filters", "get"},
DefaultAs: "bot",
Params: map[string]any{
"spreadsheet_token": spreadsheetToken,
"sheet_id": sheetID,
@@ -129,22 +120,22 @@ func TestSheets_FilterWorkflow(t *testing.T) {
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) {
t.Run("update filter with spreadsheet.sheet.filters update as bot", 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",
"col": "C",
"filter_type": "multiValue",
"condition": map[string]any{
"filter_type": "number",
"compare_type": "greater",
"expected": []any{80},
"filter_type": "multiValue",
"expected": []any{"A"},
},
}
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"sheets", "spreadsheet.sheet.filters", "update"},
Args: []string{"sheets", "spreadsheet.sheet.filters", "update"},
DefaultAs: "bot",
Params: map[string]any{
"spreadsheet_token": spreadsheetToken,
"sheet_id": sheetID,
@@ -156,12 +147,13 @@ func TestSheets_FilterWorkflow(t *testing.T) {
result.AssertStdoutStatus(t, 0)
})
t.Run("delete filter with spreadsheet.sheet.filters delete", func(t *testing.T) {
t.Run("delete filter with spreadsheet.sheet.filters delete as bot", 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"},
Args: []string{"sheets", "spreadsheet.sheet.filters", "delete"},
DefaultAs: "bot",
Params: map[string]any{
"spreadsheet_token": spreadsheetToken,
"sheet_id": sheetID,

View File

@@ -0,0 +1,17 @@
# Slides CLI E2E Coverage
## Metrics
- Denominator: 2 leaf commands
- Covered: 1
- Coverage: 50.0%
## Summary
- TestSlides_CreateWorkflowAsUser: proves the user slides workflow through `create presentation with slide as user` and `get created presentation xml as user`; creates a fresh presentation, asserts returned IDs, then reads back the XML content to prove the title and slide body persisted.
- Blocked area: `slides +media-upload` is still uncovered because it needs a deterministic local image fixture plus XML follow-up proof that is separate from the base create/read workflow.
## Command Table
| Status | Cmd | Type | Testcase | Key parameter shapes | Notes / uncovered reason |
| --- | --- | --- | --- | --- | --- |
| ✓ | slides +create | shortcut | slides_create_workflow_test.go::TestSlides_CreateWorkflowAsUser/create presentation with slide as user | `--title`; `--slides ["<slide ...>"]` | read back through raw slides API to prove persisted XML |
| ✕ | slides +media-upload | shortcut | | none | needs a stable local image fixture plus follow-up slide XML proof |

View File

@@ -0,0 +1,86 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package slides
import (
"context"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestSlides_CreateWorkflowAsUser(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
clie2e.SkipWithoutUserToken(t)
parentT := t
suffix := clie2e.GenerateSuffix()
title := "slides-e2e-" + suffix
slideTitle := "Overview " + suffix
slideBody := "Body " + suffix
slideXML := `<slide xmlns="http://www.larkoffice.com/sml/2.0"><data><shape type="text" topLeftX="80" topLeftY="80" width="800" height="120"><content textType="title"><p>` + slideTitle + `</p></content></shape><shape type="text" topLeftX="80" topLeftY="200" width="800" height="180"><content textType="body"><p>` + slideBody + `</p></content></shape></data></slide>`
var presentationID string
t.Run("create presentation with slide as user", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"slides", "+create",
"--title", title,
"--slides", `["` + strings.ReplaceAll(slideXML, `"`, `\"`) + `"]`,
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
presentationID = gjson.Get(result.Stdout, "data.xml_presentation_id").String()
require.NotEmpty(t, presentationID, "stdout:\n%s", result.Stdout)
require.Equal(t, title, gjson.Get(result.Stdout, "data.title").String(), "stdout:\n%s", result.Stdout)
require.Equal(t, int64(1), gjson.Get(result.Stdout, "data.slides_added").Int(), "stdout:\n%s", result.Stdout)
require.Len(t, gjson.Get(result.Stdout, "data.slide_ids").Array(), 1, "stdout:\n%s", result.Stdout)
parentT.Cleanup(func() {
cleanupCtx, cancel := clie2e.CleanupContext()
defer cancel()
deleteResult, deleteErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{
Args: []string{
"drive", "+delete",
"--file-token", presentationID,
"--type", "slides",
"--yes",
},
DefaultAs: "user",
})
clie2e.ReportCleanupFailure(parentT, "delete presentation "+presentationID, deleteResult, deleteErr)
})
})
t.Run("get created presentation xml as user", func(t *testing.T) {
require.NotEmpty(t, presentationID, "presentation should be created before readback")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"api", "get", "/open-apis/slides_ai/v1/xml_presentations/" + presentationID},
DefaultAs: "user",
Params: map[string]any{"revision_id": -1},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
require.Equal(t, presentationID, gjson.Get(result.Stdout, "data.xml_presentation.presentation_id").String(), "stdout:\n%s", result.Stdout)
content := gjson.Get(result.Stdout, "data.xml_presentation.content").String()
require.Contains(t, content, "<title>"+title+"</title>", "stdout:\n%s", result.Stdout)
require.Contains(t, content, slideTitle, "stdout:\n%s", result.Stdout)
require.Contains(t, content, slideBody, "stdout:\n%s", result.Stdout)
})
}

View File

@@ -2,21 +2,25 @@
## Metrics
- Denominator: 29 leaf commands
- Covered: 10
- Coverage: 34.5%
- Covered: 14
- Coverage: 48.3%
## Summary
- TestTask_StatusWorkflow: creates a task via `task +create`, then proves `task +complete`, `task tasks get`, and `task +reopen` through `complete`, `get completed task`, `reopen`, and `get reopened task`; asserts `status` flips between `done` and `todo` and `completed_at` is set then cleared.
- TestTask_ReminderWorkflow: creates a task with a due time via `task +create`, then proves `task +reminder` and `task tasks get` through `set reminder`, `get task with reminder`, `remove reminder`, and `get task without reminder`; asserts `relative_fire_minute=30`, reminder id presence, and reminder removal.
- TestTask_CommentWorkflow: creates a task via `task +create`, runs `comment`, and asserts the returned comment id is non-empty; this is the direct proof for `task +comment`.
- TestTask_TasklistWorkflow: runs `create tasklist with task`, then `get tasklist`, `list tasklist tasks`, and `get task`; proves `task +tasklist-create`, `task tasklists get`, `task tasklists tasks`, and `task tasks get` with seeded task payload and task-to-tasklist linkage.
- TestTask_UpdateWorkflow: creates a task as `--as user`, proves `task +update` and `task tasks patch` through repeated read-after-write `task tasks get`, then completes it via `task +complete` and verifies the completed task state directly.
- TestTask_TasklistWorkflowAsBot: runs `create tasklist with task`, then `get tasklist`, `list tasklist tasks`, and `get task`; proves `task +tasklist-create`, `task tasklists get`, `task tasklists tasks`, and `task tasks get` with seeded task payload and task-to-tasklist linkage.
- TestTask_TasklistWorkflowAsUser: creates a tasklist as `--as user`, patches its name through `task tasklists patch`, then proves both `task tasklists get` and `task tasklists list` return the patched tasklist.
- TestTask_TasklistAddTaskWorkflow: creates a standalone tasklist and task, runs `add task to tasklist`, then `list tasklist tasks` and `get task with tasklist link`; proves `task +tasklist-task-add`, `task tasklists tasks`, and `task tasks get`, including no failed tasks in the add response.
- Cleanup path note: workflow-created tasks and tasklists are deleted through direct `task tasks delete` / `task tasklists delete` cleanup paths in `helpers_test.go::createTask`, `helpers_test.go::createTasklist`, and `tasklist_workflow_test.go::TestTask_TasklistWorkflow`, but those cleanup-only executions are not counted as command coverage because no testcase asserts delete behavior as the primary proof surface.
- Cleanup path note: workflow-created tasks and tasklists are deleted through direct `task tasks delete` / `task tasklists delete` cleanup paths in `helpers_test.go::createTask`, `helpers_test.go::createTasklist`, `tasklist_workflow_test.go::TestTask_TasklistWorkflowAsBot`, and `tasklist_workflow_test.go::TestTask_TasklistWorkflowAsUser`, but those cleanup-only executions are not counted as command coverage because no testcase asserts delete behavior as the primary proof surface.
- Blocked area: assignee, follower, and tasklist member mutations still require stable real-user `open_id` fixtures; the current suite is bot-safe only.
- Blocked area: `task +get-my-tasks` still depends on `--as user` identity plus deterministic user-scoped data.
- Blocked area: `task +get-my-tasks` and `task tasks list` did not return the workflow-created user task deterministically in UAT, so they are left uncovered instead of being counted from flaky list visibility.
- Blocked area: the remaining user-oriented shortcuts still need deterministic user-owned fixtures or collaborator fixtures beyond the self-owned task created inside the testcase.
- Gap pattern: direct `tasks create/delete/list/patch`, `tasklists create/delete/list/patch`, `members *`, and `subtasks *` APIs still lack deterministic direct-call workflows, so shortcut coverage does not count for those leaf commands.
## Command Table
| Status | Cmd | Type | Testcase | Key parameter shapes | Notes / uncovered reason |
| --- | --- | --- | --- | --- | --- |
| ✕ | task +assign | shortcut | | none | requires real assignee open_id fixtures; shortcut defaults to `--as user` |
@@ -24,13 +28,13 @@
| ✓ | task +complete | shortcut | task_status_workflow_test.go::TestTask_StatusWorkflow/complete | `--task-id` | |
| ✓ | task +create | shortcut | task_status_workflow_test.go::TestTask_StatusWorkflow; task_comment_workflow_test.go::TestTask_CommentWorkflow; task_reminder_workflow_test.go::TestTask_ReminderWorkflow; tasklist_add_task_workflow_test.go::TestTask_TasklistAddTaskWorkflow | `summary` + `description`; `due.timestamp` + `due.is_all_day` | |
| ✕ | task +followers | shortcut | | none | requires real follower open_id fixtures; shortcut defaults to `--as user` |
| ✕ | task +get-my-tasks | shortcut | | none | depends on `--as user` identity and deterministic user-scoped task data |
| ✕ | task +get-my-tasks | shortcut | | none | UAT did not return the workflow-created user task deterministically in my-tasks views |
| ✓ | task +reminder | shortcut | task_reminder_workflow_test.go::TestTask_ReminderWorkflow/set reminder; task_reminder_workflow_test.go::TestTask_ReminderWorkflow/remove reminder | `--task-id --set 30m`; `--task-id --remove` | |
| ✓ | task +reopen | shortcut | task_status_workflow_test.go::TestTask_StatusWorkflow/reopen | `--task-id` | |
| ✓ | task +tasklist-create | shortcut | tasklist_workflow_test.go::TestTask_TasklistWorkflow/create tasklist with task; tasklist_add_task_workflow_test.go::TestTask_TasklistAddTaskWorkflow | `--name` only; `--name` plus task array in `--data` | |
| ✓ | task +tasklist-create | shortcut | tasklist_workflow_test.go::TestTask_TasklistWorkflowAsBot/create tasklist with task as bot; tasklist_workflow_test.go::TestTask_TasklistWorkflowAsUser/create tasklist as user; tasklist_add_task_workflow_test.go::TestTask_TasklistAddTaskWorkflow | `--name` only; `--name` plus task array in `--data` | |
| ✕ | task +tasklist-members | shortcut | | none | requires real member open_id fixtures to add, remove, or set tasklist members |
| ✓ | task +tasklist-task-add | shortcut | tasklist_add_task_workflow_test.go::TestTask_TasklistAddTaskWorkflow/add task to tasklist | `--tasklist-id`; `--task-id` | |
| | task +update | shortcut | | none | no dedicated workflow yet for summary, description, or due-field mutation assertions |
| | task +update | shortcut | task_update_workflow_test.go::TestTask_UpdateWorkflow/update task with shortcut as user | `--task-id`; `--summary`; `--description` | verified by follow-up `task tasks get` |
| ✕ | task members add | api | | none | requires stable member fixtures and explicit direct API-body assertions |
| ✕ | task members remove | api | | none | requires stable member fixtures and explicit direct API-body assertions |
| ✕ | task subtasks create | api | | none | needs a parent-task workflow plus direct subtask payload assertions |
@@ -38,13 +42,13 @@
| ✕ | task tasklists add_members | api | | none | requires real member open_id fixtures and direct API coverage |
| ✕ | task tasklists create | api | | none | only covered indirectly through `task +tasklist-create`; no direct API invocation yet |
| ✕ | task tasklists delete | api | | none | only exercised in parent cleanup; no testcase asserts delete behavior or post-delete state as the primary proof |
| ✓ | task tasklists get | api | tasklist_workflow_test.go::TestTask_TasklistWorkflow/get tasklist | `tasklist_guid` in `--params` | |
| | task tasklists list | api | | none | needs isolated list or filter assertions against ambient tasklist data |
| | task tasklists patch | api | | none | no dedicated direct tasklist-update workflow yet |
| ✓ | task tasklists get | api | tasklist_workflow_test.go::TestTask_TasklistWorkflowAsBot/get tasklist as bot; tasklist_workflow_test.go::TestTask_TasklistWorkflowAsUser/get patched tasklist as user | `tasklist_guid` in `--params` | |
| | task tasklists list | api | tasklist_workflow_test.go::TestTask_TasklistWorkflowAsUser/list tasklists and find patched tasklist as user | `page_size` | asserts the workflow-created tasklist appears with patched name |
| | task tasklists patch | api | tasklist_workflow_test.go::TestTask_TasklistWorkflowAsUser/patch tasklist as user | `tasklist_guid` in `--params`; `name` in `--data` | verified by follow-up `task tasklists get` and `task tasklists list` |
| ✕ | task tasklists remove_members | api | | none | requires real member open_id fixtures and direct API coverage |
| ✓ | task tasklists tasks | api | tasklist_workflow_test.go::TestTask_TasklistWorkflow/list tasklist tasks; tasklist_add_task_workflow_test.go::TestTask_TasklistAddTaskWorkflow/list tasklist tasks | `tasklist_guid`; `page_size` | |
| ✓ | task tasklists tasks | api | tasklist_workflow_test.go::TestTask_TasklistWorkflowAsBot/list tasklist tasks as bot; tasklist_add_task_workflow_test.go::TestTask_TasklistAddTaskWorkflow/list tasklist tasks | `tasklist_guid`; `page_size` | |
| ✕ | task tasks create | api | | none | only covered indirectly through `task +create`; no direct API invocation yet |
| ✕ | task tasks delete | api | | none | only exercised in parent cleanup; no testcase asserts delete behavior or post-delete state as the primary proof |
| ✓ | task tasks get | api | task_status_workflow_test.go::TestTask_StatusWorkflow/get completed task; task_status_workflow_test.go::TestTask_StatusWorkflow/get reopened task; task_reminder_workflow_test.go::TestTask_ReminderWorkflow/get task with reminder; task_reminder_workflow_test.go::TestTask_ReminderWorkflow/get task without reminder; tasklist_workflow_test.go::TestTask_TasklistWorkflow/get task; tasklist_add_task_workflow_test.go::TestTask_TasklistAddTaskWorkflow/get task with tasklist link | `task_guid` in `--params`; assert status, reminders, summary, description, and tasklist link | |
| ✕ | task tasks list | api | | none | needs isolated list or filter assertions against ambient task data |
| | task tasks patch | api | | none | no dedicated direct task-update workflow yet |
| ✓ | task tasks get | api | task_status_workflow_test.go::TestTask_StatusWorkflow/get completed task; task_status_workflow_test.go::TestTask_StatusWorkflow/get reopened task; task_reminder_workflow_test.go::TestTask_ReminderWorkflow/get task with reminder; task_reminder_workflow_test.go::TestTask_ReminderWorkflow/get task without reminder; tasklist_workflow_test.go::TestTask_TasklistWorkflowAsBot/get task as bot; tasklist_add_task_workflow_test.go::TestTask_TasklistAddTaskWorkflow/get task with tasklist link; task_update_workflow_test.go::TestTask_UpdateWorkflow/get created task as user; task_update_workflow_test.go::TestTask_UpdateWorkflow/get task updated by shortcut as user; task_update_workflow_test.go::TestTask_UpdateWorkflow/get task patched by api as user; task_update_workflow_test.go::TestTask_UpdateWorkflow/get completed task as user | `task_guid` in `--params`; assert status, reminders, summary, description, and tasklist link | |
| ✕ | task tasks list | api | | none | UAT did not return the workflow-created user task deterministically in list views |
| | task tasks patch | api | task_update_workflow_test.go::TestTask_UpdateWorkflow/patch task with api as user | `task_guid` in `--params`; `summary` + `description` in `--data` | verified by follow-up `task tasks get` |

View File

@@ -15,6 +15,10 @@ import (
func createTask(t *testing.T, parentT *testing.T, ctx context.Context, req clie2e.Request) string {
t.Helper()
if req.DefaultAs == "" {
req.DefaultAs = "bot"
}
result, err := clie2e.RunCmd(ctx, req)
require.NoError(t, err)
result.AssertExitCode(t, 0)
@@ -24,17 +28,15 @@ func createTask(t *testing.T, parentT *testing.T, ctx context.Context, req clie2
require.NotEmpty(t, taskGUID, "stdout:\n%s", result.Stdout)
parentT.Cleanup(func() {
deleteResult, deleteErr := clie2e.RunCmd(context.Background(), clie2e.Request{
Args: []string{"task", "tasks", "delete"},
Params: map[string]any{"task_guid": taskGUID},
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},
})
if deleteErr != nil {
parentT.Errorf("delete task %s: %v", taskGUID, deleteErr)
return
}
if deleteResult.ExitCode != 0 {
parentT.Errorf("delete task %s failed: exit=%d stdout=%s stderr=%s", taskGUID, deleteResult.ExitCode, deleteResult.Stdout, deleteResult.Stderr)
}
clie2e.ReportCleanupFailure(parentT, "delete task "+taskGUID, deleteResult, deleteErr)
})
return taskGUID
@@ -43,6 +45,10 @@ func createTask(t *testing.T, parentT *testing.T, ctx context.Context, req clie2
func createTasklist(t *testing.T, parentT *testing.T, ctx context.Context, req clie2e.Request) string {
t.Helper()
if req.DefaultAs == "" {
req.DefaultAs = "bot"
}
result, err := clie2e.RunCmd(ctx, req)
require.NoError(t, err)
result.AssertExitCode(t, 0)
@@ -52,17 +58,15 @@ func createTasklist(t *testing.T, parentT *testing.T, ctx context.Context, req c
require.NotEmpty(t, tasklistGUID, "stdout:\n%s", result.Stdout)
parentT.Cleanup(func() {
deleteResult, deleteErr := clie2e.RunCmd(context.Background(), clie2e.Request{
Args: []string{"task", "tasklists", "delete"},
Params: map[string]any{"tasklist_guid": tasklistGUID},
cleanupCtx, cancel := clie2e.CleanupContext()
defer cancel()
deleteResult, deleteErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{
Args: []string{"task", "tasklists", "delete"},
DefaultAs: "bot",
Params: map[string]any{"tasklist_guid": tasklistGUID},
})
if deleteErr != nil {
parentT.Errorf("delete tasklist %s: %v", tasklistGUID, deleteErr)
return
}
if deleteResult.ExitCode != 0 {
parentT.Errorf("delete tasklist %s failed: exit=%d stdout=%s stderr=%s", tasklistGUID, deleteResult.ExitCode, deleteResult.Stdout, deleteResult.Stderr)
}
clie2e.ReportCleanupFailure(parentT, "delete tasklist "+tasklistGUID, deleteResult, deleteErr)
})
return tasklistGUID

View File

@@ -22,16 +22,18 @@ func TestTask_CommentWorkflow(t *testing.T) {
suffix := clie2e.GenerateSuffix()
commentContent := "lark-cli-e2e-comment-" + suffix
taskGUID := createTask(t, parentT, ctx, clie2e.Request{
Args: []string{"task", "+create"},
Args: []string{"task", "+create"},
DefaultAs: "bot",
Data: map[string]any{
"summary": "lark-cli-e2e-comment-task-" + suffix,
"description": "created by tests/cli_e2e/task comment workflow",
},
})
t.Run("comment", func(t *testing.T) {
t.Run("comment as bot", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"task", "+comment", "--task-id", taskGUID, "--content", commentContent},
Args: []string{"task", "+comment", "--task-id", taskGUID, "--content", commentContent},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)

View File

@@ -21,7 +21,8 @@ func TestTask_ReminderWorkflow(t *testing.T) {
suffix := clie2e.GenerateSuffix()
taskGUID := createTask(t, parentT, ctx, clie2e.Request{
Args: []string{"task", "+create"},
Args: []string{"task", "+create"},
DefaultAs: "bot",
Data: map[string]any{
"summary": "lark-cli-e2e-reminder-" + suffix,
"description": "created by tests/cli_e2e/task reminder workflow",
@@ -32,9 +33,10 @@ func TestTask_ReminderWorkflow(t *testing.T) {
},
})
t.Run("set reminder", func(t *testing.T) {
t.Run("set reminder as bot", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"task", "+reminder", "--task-id", taskGUID, "--set", "30m"},
Args: []string{"task", "+reminder", "--task-id", taskGUID, "--set", "30m"},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
@@ -42,10 +44,11 @@ func TestTask_ReminderWorkflow(t *testing.T) {
assert.Equal(t, taskGUID, gjson.Get(result.Stdout, "data.guid").String())
})
t.Run("get task with reminder", func(t *testing.T) {
t.Run("get task with reminder as bot", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"task", "tasks", "get"},
Params: map[string]any{"task_guid": taskGUID},
Args: []string{"task", "tasks", "get"},
DefaultAs: "bot",
Params: map[string]any{"task_guid": taskGUID},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
@@ -56,9 +59,10 @@ func TestTask_ReminderWorkflow(t *testing.T) {
assert.NotEmpty(t, gjson.Get(result.Stdout, "data.task.reminders.0.id").String(), "stdout:\n%s", result.Stdout)
})
t.Run("remove reminder", func(t *testing.T) {
t.Run("remove reminder as bot", func(t *testing.T) {
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{"task", "+reminder", "--task-id", taskGUID, "--remove"},
Args: []string{"task", "+reminder", "--task-id", taskGUID, "--remove"},
DefaultAs: "bot",
}, clie2e.RetryOptions{})
require.NoError(t, err)
result.AssertExitCode(t, 0)
@@ -66,10 +70,11 @@ func TestTask_ReminderWorkflow(t *testing.T) {
assert.Equal(t, taskGUID, gjson.Get(result.Stdout, "data.guid").String())
})
t.Run("get task without reminder", func(t *testing.T) {
t.Run("get task without reminder as bot", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"task", "tasks", "get"},
Params: map[string]any{"task_guid": taskGUID},
Args: []string{"task", "tasks", "get"},
DefaultAs: "bot",
Params: map[string]any{"task_guid": taskGUID},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)

View File

@@ -21,16 +21,18 @@ func TestTask_StatusWorkflow(t *testing.T) {
suffix := clie2e.GenerateSuffix()
taskGUID := createTask(t, parentT, ctx, clie2e.Request{
Args: []string{"task", "+create"},
Args: []string{"task", "+create"},
DefaultAs: "bot",
Data: map[string]any{
"summary": "lark-cli-e2e-summary-" + suffix,
"description": "created by tests/cli_e2e/task status workflow",
},
})
t.Run("complete", func(t *testing.T) {
t.Run("complete as bot", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"task", "+complete", "--task-id", taskGUID},
Args: []string{"task", "+complete", "--task-id", taskGUID},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
@@ -38,10 +40,11 @@ func TestTask_StatusWorkflow(t *testing.T) {
assert.Equal(t, taskGUID, gjson.Get(result.Stdout, "data.guid").String())
})
t.Run("get completed task", func(t *testing.T) {
t.Run("get completed task as bot", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"task", "tasks", "get"},
Params: map[string]any{"task_guid": taskGUID},
Args: []string{"task", "tasks", "get"},
DefaultAs: "bot",
Params: map[string]any{"task_guid": taskGUID},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
@@ -52,9 +55,10 @@ func TestTask_StatusWorkflow(t *testing.T) {
assert.NotZero(t, gjson.Get(result.Stdout, "data.task.completed_at").Int(), "stdout:\n%s", result.Stdout)
})
t.Run("reopen", func(t *testing.T) {
t.Run("reopen as bot", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"task", "+reopen", "--task-id", taskGUID},
Args: []string{"task", "+reopen", "--task-id", taskGUID},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
@@ -62,10 +66,11 @@ func TestTask_StatusWorkflow(t *testing.T) {
assert.Equal(t, taskGUID, gjson.Get(result.Stdout, "data.guid").String())
})
t.Run("get reopened task", func(t *testing.T) {
t.Run("get reopened task as bot", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"task", "tasks", "get"},
Params: map[string]any{"task_guid": taskGUID},
Args: []string{"task", "tasks", "get"},
DefaultAs: "bot",
Params: map[string]any{"task_guid": taskGUID},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)

View File

@@ -0,0 +1,189 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
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 TestTask_UpdateWorkflow(t *testing.T) {
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
suffix := clie2e.GenerateSuffix()
taskSummary := "lark-cli-e2e-user-my-task-" + suffix
taskDescription := "created by tests/cli_e2e/task user workflow"
updatedTaskSummary := "lark-cli-e2e-user-my-task-updated-" + suffix
updatedTaskDescription := "updated by task +update user workflow"
patchedTaskSummary := "lark-cli-e2e-user-my-task-patched-" + suffix
patchedTaskDescription := "patched by task tasks patch user workflow"
taskGUID := ""
clie2e.SkipWithoutUserToken(t)
parentT.Cleanup(func() {
if taskGUID == "" {
return
}
cleanupCtx, cancel := clie2e.CleanupContext()
defer cancel()
deleteResult, deleteErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{
Args: []string{"task", "tasks", "delete"},
DefaultAs: "user",
Params: map[string]any{"task_guid": taskGUID},
})
clie2e.ReportCleanupFailure(parentT, "delete user task "+taskGUID, deleteResult, deleteErr)
})
t.Run("create task as user", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"task", "+create"},
DefaultAs: "user",
Data: map[string]any{
"summary": taskSummary,
"description": taskDescription,
},
})
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)
assert.Equal(t, "user", gjson.Get(result.Stdout, "identity").String(), "stdout:\n%s", result.Stdout)
})
t.Run("get created task as user", 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: "user",
Params: map[string]any{"task_guid": taskGUID},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
assert.Equal(t, taskGUID, gjson.Get(result.Stdout, "data.task.guid").String(), "stdout:\n%s", result.Stdout)
assert.Equal(t, taskSummary, gjson.Get(result.Stdout, "data.task.summary").String(), "stdout:\n%s", result.Stdout)
assert.Equal(t, taskDescription, gjson.Get(result.Stdout, "data.task.description").String(), "stdout:\n%s", result.Stdout)
assert.Equal(t, "todo", gjson.Get(result.Stdout, "data.task.status").String(), "stdout:\n%s", result.Stdout)
})
t.Run("update task with shortcut as user", 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,
"--summary", updatedTaskSummary,
"--description", updatedTaskDescription,
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
assert.Equal(t, taskGUID, gjson.Get(result.Stdout, "data.tasks.0.guid").String(), "stdout:\n%s", result.Stdout)
})
t.Run("get task updated by shortcut as user", func(t *testing.T) {
require.NotEmpty(t, taskGUID, "task GUID should be updated before get")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"task", "tasks", "get"},
DefaultAs: "user",
Params: map[string]any{"task_guid": taskGUID},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
assert.Equal(t, updatedTaskSummary, gjson.Get(result.Stdout, "data.task.summary").String(), "stdout:\n%s", result.Stdout)
assert.Equal(t, updatedTaskDescription, gjson.Get(result.Stdout, "data.task.description").String(), "stdout:\n%s", result.Stdout)
})
t.Run("patch task with api as user", func(t *testing.T) {
require.NotEmpty(t, taskGUID, "task GUID should be updated before patch")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"task", "tasks", "patch"},
DefaultAs: "user",
Params: map[string]any{"task_guid": taskGUID},
Data: map[string]any{
"task": map[string]any{
"summary": patchedTaskSummary,
"description": patchedTaskDescription,
},
"update_fields": []string{"summary", "description"},
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
assert.Equal(t, taskGUID, gjson.Get(result.Stdout, "data.task.guid").String(), "stdout:\n%s", result.Stdout)
})
t.Run("get task patched by api as user", func(t *testing.T) {
require.NotEmpty(t, taskGUID, "task GUID should be patched before get")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"task", "tasks", "get"},
DefaultAs: "user",
Params: map[string]any{"task_guid": taskGUID},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
assert.Equal(t, taskGUID, gjson.Get(result.Stdout, "data.task.guid").String(), "stdout:\n%s", result.Stdout)
assert.Equal(t, patchedTaskSummary, gjson.Get(result.Stdout, "data.task.summary").String(), "stdout:\n%s", result.Stdout)
assert.Equal(t, patchedTaskDescription, gjson.Get(result.Stdout, "data.task.description").String(), "stdout:\n%s", result.Stdout)
})
t.Run("complete task as user", func(t *testing.T) {
require.NotEmpty(t, taskGUID, "task GUID should be created before complete")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"task", "+complete", "--task-id", taskGUID},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
assert.Equal(t, taskGUID, gjson.Get(result.Stdout, "data.guid").String(), "stdout:\n%s", result.Stdout)
})
t.Run("get completed task as user", func(t *testing.T) {
require.NotEmpty(t, taskGUID, "task GUID should be completed before get")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"task", "tasks", "get"},
DefaultAs: "user",
Params: map[string]any{"task_guid": taskGUID},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
assert.Equal(t, taskGUID, gjson.Get(result.Stdout, "data.task.guid").String(), "stdout:\n%s", result.Stdout)
assert.Equal(t, patchedTaskSummary, gjson.Get(result.Stdout, "data.task.summary").String(), "stdout:\n%s", result.Stdout)
assert.Equal(t, "done", gjson.Get(result.Stdout, "data.task.status").String(), "stdout:\n%s", result.Stdout)
assert.NotZero(t, gjson.Get(result.Stdout, "data.task.completed_at").Int(), "stdout:\n%s", result.Stdout)
})
}

View File

@@ -24,19 +24,22 @@ func TestTask_TasklistAddTaskWorkflow(t *testing.T) {
taskSummary := "lark-cli-e2e-tasklist-add-task-" + suffix
tasklistGUID := createTasklist(t, parentT, ctx, clie2e.Request{
Args: []string{"task", "+tasklist-create", "--name", tasklistName},
Args: []string{"task", "+tasklist-create", "--name", tasklistName},
DefaultAs: "bot",
})
taskGUID := createTask(t, parentT, ctx, clie2e.Request{
Args: []string{"task", "+create"},
Args: []string{"task", "+create"},
DefaultAs: "bot",
Data: map[string]any{
"summary": taskSummary,
"description": "created by tests/cli_e2e/task tasklist add workflow",
},
})
t.Run("add task to tasklist", func(t *testing.T) {
t.Run("add task to tasklist as bot", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"task", "+tasklist-task-add", "--tasklist-id", tasklistGUID, "--task-id", taskGUID},
Args: []string{"task", "+tasklist-task-add", "--tasklist-id", tasklistGUID, "--task-id", taskGUID},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
@@ -47,9 +50,10 @@ func TestTask_TasklistAddTaskWorkflow(t *testing.T) {
assert.False(t, gjson.Get(result.Stdout, "data.failed_tasks.0").Exists(), "stdout:\n%s", result.Stdout)
})
t.Run("list tasklist tasks", func(t *testing.T) {
t.Run("list tasklist tasks as bot", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"task", "tasklists", "tasks"},
Args: []string{"task", "tasklists", "tasks"},
DefaultAs: "bot",
Params: map[string]any{
"tasklist_guid": tasklistGUID,
"page_size": 50,
@@ -64,10 +68,11 @@ func TestTask_TasklistAddTaskWorkflow(t *testing.T) {
assert.Equal(t, taskSummary, taskItem.Get("summary").String())
})
t.Run("get task with tasklist link", func(t *testing.T) {
t.Run("get task with tasklist link as bot", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"task", "tasks", "get"},
Params: map[string]any{"task_guid": taskGUID},
Args: []string{"task", "tasks", "get"},
DefaultAs: "bot",
Params: map[string]any{"task_guid": taskGUID},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)

View File

@@ -14,7 +14,7 @@ import (
"github.com/tidwall/gjson"
)
func TestTask_TasklistWorkflow(t *testing.T) {
func TestTask_TasklistWorkflowAsBot(t *testing.T) {
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
@@ -27,9 +27,10 @@ func TestTask_TasklistWorkflow(t *testing.T) {
var tasklistGUID string
var taskGUID string
t.Run("create tasklist with task", func(t *testing.T) {
t.Run("create tasklist with task as bot", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"task", "+tasklist-create", "--name", tasklistName},
Args: []string{"task", "+tasklist-create", "--name", tasklistName},
DefaultAs: "bot",
Data: []map[string]any{
{
"summary": taskSummary,
@@ -47,40 +48,37 @@ func TestTask_TasklistWorkflow(t *testing.T) {
require.NotEmpty(t, taskGUID, "stdout:\n%s", result.Stdout)
parentT.Cleanup(func() {
deleteResult, deleteErr := clie2e.RunCmd(context.Background(), clie2e.Request{
Args: []string{"task", "tasks", "delete"},
Params: map[string]any{"task_guid": taskGUID},
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},
})
if deleteErr != nil {
parentT.Errorf("delete task %s: %v", taskGUID, deleteErr)
return
}
if deleteResult.ExitCode != 0 {
parentT.Errorf("delete task %s failed: exit=%d stdout=%s stderr=%s", taskGUID, deleteResult.ExitCode, deleteResult.Stdout, deleteResult.Stderr)
}
clie2e.ReportCleanupFailure(parentT, "delete task "+taskGUID, deleteResult, deleteErr)
})
parentT.Cleanup(func() {
deleteResult, deleteErr := clie2e.RunCmd(context.Background(), clie2e.Request{
Args: []string{"task", "tasklists", "delete"},
Params: map[string]any{"tasklist_guid": tasklistGUID},
cleanupCtx, cancel := clie2e.CleanupContext()
defer cancel()
deleteResult, deleteErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{
Args: []string{"task", "tasklists", "delete"},
DefaultAs: "bot",
Params: map[string]any{"tasklist_guid": tasklistGUID},
})
if deleteErr != nil {
parentT.Errorf("delete tasklist %s: %v", tasklistGUID, deleteErr)
return
}
if deleteResult.ExitCode != 0 {
parentT.Errorf("delete tasklist %s failed: exit=%d stdout=%s stderr=%s", tasklistGUID, deleteResult.ExitCode, deleteResult.Stdout, deleteResult.Stderr)
}
clie2e.ReportCleanupFailure(parentT, "delete tasklist "+tasklistGUID, deleteResult, deleteErr)
})
})
t.Run("get tasklist", func(t *testing.T) {
t.Run("get tasklist as bot", func(t *testing.T) {
require.NotEmpty(t, tasklistGUID, "tasklist GUID should be created before get")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"task", "tasklists", "get"},
Params: map[string]any{"tasklist_guid": tasklistGUID},
Args: []string{"task", "tasklists", "get"},
DefaultAs: "bot",
Params: map[string]any{"tasklist_guid": tasklistGUID},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
@@ -89,12 +87,13 @@ func TestTask_TasklistWorkflow(t *testing.T) {
assert.Equal(t, tasklistName, gjson.Get(result.Stdout, "data.tasklist.name").String())
})
t.Run("list tasklist tasks", func(t *testing.T) {
t.Run("list tasklist tasks as bot", func(t *testing.T) {
require.NotEmpty(t, tasklistGUID, "tasklist GUID should be created before listing tasks")
require.NotEmpty(t, taskGUID, "task GUID should be created before listing tasks")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"task", "tasklists", "tasks"},
Args: []string{"task", "tasklists", "tasks"},
DefaultAs: "bot",
Params: map[string]any{
"tasklist_guid": tasklistGUID,
"page_size": 50,
@@ -109,12 +108,13 @@ func TestTask_TasklistWorkflow(t *testing.T) {
assert.Equal(t, taskSummary, taskItem.Get("summary").String())
})
t.Run("get task", func(t *testing.T) {
t.Run("get task 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"},
Params: map[string]any{"task_guid": taskGUID},
Args: []string{"task", "tasks", "get"},
DefaultAs: "bot",
Params: map[string]any{"task_guid": taskGUID},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
@@ -126,3 +126,96 @@ func TestTask_TasklistWorkflow(t *testing.T) {
assert.Equal(t, tasklistGUID, gjson.Get(result.Stdout, "data.task.tasklists.0.tasklist_guid").String())
})
}
func TestTask_TasklistWorkflowAsUser(t *testing.T) {
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
clie2e.SkipWithoutUserToken(t)
suffix := clie2e.GenerateSuffix()
tasklistName := "lark-cli-e2e-user-tasklist-" + suffix
patchedTasklistName := "lark-cli-e2e-user-tasklist-patched-" + suffix
tasklistGUID := ""
parentT.Cleanup(func() {
if tasklistGUID == "" {
return
}
cleanupCtx, cancel := clie2e.CleanupContext()
defer cancel()
deleteResult, deleteErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{
Args: []string{"task", "tasklists", "delete"},
DefaultAs: "user",
Params: map[string]any{"tasklist_guid": tasklistGUID},
})
clie2e.ReportCleanupFailure(parentT, "delete user tasklist "+tasklistGUID, deleteResult, deleteErr)
})
t.Run("create tasklist as user", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"task", "+tasklist-create", "--name", tasklistName},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
tasklistGUID = gjson.Get(result.Stdout, "data.guid").String()
require.NotEmpty(t, tasklistGUID, "stdout:\n%s", result.Stdout)
assert.Equal(t, "user", gjson.Get(result.Stdout, "identity").String(), "stdout:\n%s", result.Stdout)
})
t.Run("patch tasklist as user", func(t *testing.T) {
require.NotEmpty(t, tasklistGUID, "tasklist GUID should be created before patch")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"task", "tasklists", "patch"},
DefaultAs: "user",
Params: map[string]any{"tasklist_guid": tasklistGUID},
Data: map[string]any{
"tasklist": map[string]any{"name": patchedTasklistName},
"update_fields": []string{"name"},
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
assert.Equal(t, tasklistGUID, gjson.Get(result.Stdout, "data.tasklist.guid").String(), "stdout:\n%s", result.Stdout)
})
t.Run("get patched tasklist as user", func(t *testing.T) {
require.NotEmpty(t, tasklistGUID, "tasklist GUID should be patched before get")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"task", "tasklists", "get"},
DefaultAs: "user",
Params: map[string]any{"tasklist_guid": tasklistGUID},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
assert.Equal(t, tasklistGUID, gjson.Get(result.Stdout, "data.tasklist.guid").String(), "stdout:\n%s", result.Stdout)
assert.Equal(t, patchedTasklistName, gjson.Get(result.Stdout, "data.tasklist.name").String(), "stdout:\n%s", result.Stdout)
})
t.Run("list tasklists and find patched tasklist as user", func(t *testing.T) {
require.NotEmpty(t, tasklistGUID, "tasklist GUID should be patched before list")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"task", "tasklists", "list"},
DefaultAs: "user",
Params: map[string]any{"page_size": 50},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
tasklistItem := gjson.Get(result.Stdout, `data.items.#(guid=="`+tasklistGUID+`")`)
require.True(t, tasklistItem.Exists(), "stdout:\n%s", result.Stdout)
assert.Equal(t, patchedTasklistName, tasklistItem.Get("name").String(), "stdout:\n%s", result.Stdout)
})
}

View File

@@ -0,0 +1,21 @@
# Wiki CLI E2E Coverage
## Metrics
- Denominator: 6 leaf commands
- Covered: 6
- Coverage: 100.0%
## Summary
- TestWiki_NodeWorkflow: proves the full currently-tested wiki domain surface; key `t.Run(...)` proof points are `create node as bot`, `get created node as bot`, `get space as bot`, `list spaces as bot`, `list nodes and find created node as bot`, `copy node as bot`, and `list nodes and find copied node as bot`.
- The workflow covers both node creation/copy/listing and space lookup/listing with persisted token assertions.
## Command Table
| Status | Cmd | Type | Testcase | Key parameter shapes | Notes / uncovered reason |
| --- | --- | --- | --- | --- | --- |
| ✓ | wiki nodes copy | api | wiki_workflow_test.go::TestWiki_NodeWorkflow/copy node as bot | `space_id`; `node_token` in `--params`; target/title in `--data` | |
| ✓ | wiki nodes create | api | wiki_workflow_test.go::TestWiki_NodeWorkflow/create node as bot | `space_id` in `--params`; `node_type`; `obj_type`; `title` in `--data` | |
| ✓ | wiki nodes list | api | wiki/helpers_test.go::findWikiNodeByToken; wiki_workflow_test.go::TestWiki_NodeWorkflow/list nodes and find created node as bot; wiki_workflow_test.go::TestWiki_NodeWorkflow/list nodes and find copied node as bot | `space_id`; `page_size`; optional `page_token` | |
| ✓ | wiki spaces get | api | wiki_workflow_test.go::TestWiki_NodeWorkflow/get space as bot | `space_id` in `--params` | |
| ✓ | wiki spaces get_node | api | wiki_workflow_test.go::TestWiki_NodeWorkflow/get created node as bot | `token`; `obj_type` in `--params` | |
| ✓ | wiki spaces list | api | wiki_workflow_test.go::TestWiki_NodeWorkflow/list spaces as bot | `page_size` in `--params` | |

View File

@@ -12,10 +12,14 @@ import (
"github.com/tidwall/gjson"
)
func createWikiNode(t *testing.T, ctx context.Context, req clie2e.Request) gjson.Result {
func createWikiNode(t *testing.T, parentT *testing.T, ctx context.Context, spaceID string, data map[string]any) gjson.Result {
t.Helper()
result, err := clie2e.RunCmdWithRetry(ctx, req, clie2e.RetryOptions{})
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"api", "post", "/open-apis/wiki/v2/spaces/" + spaceID + "/nodes"},
DefaultAs: "bot",
Data: data,
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
@@ -23,32 +27,87 @@ func createWikiNode(t *testing.T, ctx context.Context, req clie2e.Request) gjson
node := gjson.Get(result.Stdout, "data.node")
require.True(t, node.Exists(), "stdout:\n%s", result.Stdout)
nodeToken := node.Get("node_token").String()
require.NotEmpty(t, nodeToken, "stdout:\n%s", result.Stdout)
parentT.Cleanup(func() {
cleanupCtx, cancel := clie2e.CleanupContext()
defer cancel()
deleteResult, deleteErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{
Args: []string{"api", "delete", "/open-apis/wiki/v2/spaces/" + spaceID + "/nodes/" + nodeToken},
DefaultAs: "bot",
})
clie2e.ReportCleanupFailure(parentT, "delete wiki node "+nodeToken, deleteResult, deleteErr)
})
return node
}
func getWikiNode(t *testing.T, ctx context.Context, nodeToken string) gjson.Result {
t.Helper()
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"api", "get", "/open-apis/wiki/v2/spaces/get_node"},
DefaultAs: "bot",
Params: map[string]any{"token": nodeToken},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
node := gjson.Get(result.Stdout, "data.node")
require.True(t, node.Exists(), "stdout:\n%s", result.Stdout)
return node
}
func getWikiSpace(t *testing.T, ctx context.Context, spaceID string) gjson.Result {
t.Helper()
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"api", "get", "/open-apis/wiki/v2/spaces/" + spaceID},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
space := gjson.Get(result.Stdout, "data.space")
require.True(t, space.Exists(), "stdout:\n%s", result.Stdout)
return space
}
func listWikiSpaces(t *testing.T, ctx context.Context, pageSize int) gjson.Result {
t.Helper()
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"api", "get", "/open-apis/wiki/v2/spaces"},
DefaultAs: "bot",
Params: map[string]any{"page_size": pageSize},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
return gjson.Parse(result.Stdout)
}
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 := ""
lastStdout := ""
seenPageTokens := map[string]struct{}{}
for {
params := map[string]any{
"space_id": spaceID,
"page_size": 50,
}
params := map[string]any{"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)
if _, exists := seenPageTokens[pageToken]; exists {
t.Fatalf("wiki list pagination loop detected for page_token %q, last stdout:\n%s", pageToken, lastStdout)
}
seenPageTokens[pageToken] = struct{}{}
params["page_token"] = pageToken
}
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"wiki", "nodes", "list"},
Args: []string{"api", "get", "/open-apis/wiki/v2/spaces/" + spaceID + "/nodes"},
DefaultAs: "bot",
Params: params,
})
@@ -56,15 +115,16 @@ func findWikiNodeByToken(t *testing.T, ctx context.Context, spaceID string, node
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
node := gjson.Get(result.Stdout, `data.items.#(node_token=="`+nodeToken+`")`)
lastStdout = result.Stdout
parsed := gjson.Parse(result.Stdout)
node := parsed.Get(`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)
pageToken = parsed.Get("data.page_token").String()
if pageToken == "" || !parsed.Get("data.has_more").Bool() {
t.Fatalf("wiki node %q not found in listed pages, last stdout:\n%s", nodeToken, lastStdout)
}
}
}

View File

@@ -15,136 +15,126 @@ import (
)
func TestWiki_NodeWorkflow(t *testing.T) {
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
suffix := clie2e.GenerateSuffix()
parentTitle := "lark-cli-e2e-wiki-parent-" + suffix
createdTitle := "lark-cli-e2e-wiki-create-" + suffix
copiedTitle := "lark-cli-e2e-wiki-copy-" + suffix
var spaceID string
var parentNodeToken string
var createdNodeToken string
var createdObjToken string
var copiedNodeToken string
var copiedSpaceID 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,
},
t.Run("create isolated parent node as bot", func(t *testing.T) {
parentNode := createWikiNode(t, parentT, ctx, "my_library", map[string]any{
"node_type": "origin",
"obj_type": "docx",
"title": parentTitle,
})
spaceID = parentNode.Get("space_id").String()
parentNodeToken = parentNode.Get("node_token").String()
require.NotEmpty(t, spaceID)
require.NotEmpty(t, parentNodeToken)
assert.Equal(t, parentTitle, parentNode.Get("title").String())
})
t.Run("create node as bot", func(t *testing.T) {
require.NotEmpty(t, parentNodeToken, "parent node token should be created before child node")
node := createWikiNode(t, parentT, ctx, spaceID, map[string]any{
"node_type": "origin",
"obj_type": "docx",
"title": createdTitle,
"parent_node_token": parentNodeToken,
})
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())
assert.Equal(t, parentNodeToken, node.Get("parent_node_token").String())
})
t.Run("get created node", func(t *testing.T) {
t.Run("get created node as bot", 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())
node := getWikiNode(t, ctx, createdNodeToken)
assert.Equal(t, createdNodeToken, node.Get("node_token").String())
assert.Equal(t, createdObjToken, node.Get("obj_token").String())
assert.Equal(t, createdTitle, node.Get("title").String())
assert.Equal(t, spaceID, node.Get("space_id").String())
})
t.Run("get space", func(t *testing.T) {
t.Run("get isolated parent node as bot", func(t *testing.T) {
require.NotEmpty(t, parentNodeToken, "parent node token should be created before get_node")
node := getWikiNode(t, ctx, parentNodeToken)
assert.Equal(t, parentNodeToken, node.Get("node_token").String())
assert.Equal(t, parentTitle, node.Get("title").String())
})
t.Run("get space as bot", func(t *testing.T) {
require.NotEmpty(t, spaceID, "space ID should be available before get")
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)
space := getWikiSpace(t, ctx, spaceID)
assert.Equal(t, spaceID, space.Get("space_id").String())
assert.NotEmpty(t, space.Get("name").String())
})
t.Run("list spaces", 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 spaces as bot", func(t *testing.T) {
result := listWikiSpaces(t, ctx, 1)
assert.True(t, result.Get("data.items").Exists(), "stdout:\n%s", result.Raw)
})
t.Run("list nodes and find created node", func(t *testing.T) {
t.Run("list nodes and find isolated parent node as bot", func(t *testing.T) {
require.NotEmpty(t, spaceID, "space ID should be available before list")
require.NotEmpty(t, createdNodeToken, "node token should be available before list")
require.NotEmpty(t, parentNodeToken, "parent 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())
nodeItem := findWikiNodeByToken(t, ctx, spaceID, parentNodeToken)
assert.Equal(t, parentTitle, nodeItem.Get("title").String())
})
t.Run("copy node", func(t *testing.T) {
t.Run("copy node as bot", func(t *testing.T) {
require.NotEmpty(t, spaceID, "space ID should be available before copy")
require.NotEmpty(t, createdNodeToken, "node token should be available before copy")
copiedNode := createWikiNode(t, ctx, clie2e.Request{
Args: []string{"wiki", "nodes", "copy"},
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"api", "post", "/open-apis/wiki/v2/spaces/" + spaceID + "/nodes/" + createdNodeToken + "/copy"},
DefaultAs: "bot",
Params: map[string]any{
"space_id": spaceID,
"node_token": createdNodeToken,
},
Data: map[string]any{
"target_space_id": spaceID,
"title": copiedTitle,
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
copiedNodeToken = copiedNode.Get("node_token").String()
copiedNodeToken = gjson.Get(result.Stdout, "data.node.node_token").String()
copiedSpaceID = gjson.Get(result.Stdout, "data.node.space_id").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)
require.NotEmpty(t, copiedSpaceID)
parentT.Cleanup(func() {
cleanupCtx, cancel := clie2e.CleanupContext()
defer cancel()
deleteResult, deleteErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{
Args: []string{"api", "delete", "/open-apis/wiki/v2/spaces/" + copiedSpaceID + "/nodes/" + copiedNodeToken},
DefaultAs: "bot",
})
clie2e.ReportCleanupFailure(parentT, "delete copied wiki node "+copiedNodeToken, deleteResult, deleteErr)
})
})
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())
t.Run("get copied node as bot", func(t *testing.T) {
require.NotEmpty(t, copiedNodeToken, "copied node token should be available before verification")
node := getWikiNode(t, ctx, copiedNodeToken)
assert.Equal(t, copiedTitle, node.Get("title").String())
})
}