Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,4 @@ tests/mail/reports/
# Generated / test artifacts
internal/registry/meta_data.json
cmd/api/download.bin
app.log
94 changes: 92 additions & 2 deletions tests/cli_e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,96 @@ Put them under tests/cli_e2e/xxx.
## Run

```bash
make build
go test ./tests/cli_e2e/... -count=1
make e2e-test
```

JUnit report output:

```text
tests/cli_e2e/.artifacts/cli-e2e-report.xml
```

## Local User E2E Credentials

For `--as user` E2E runs, you can inject a portable credential file instead of
going through browser login.

Set `LARK_CLI_CREDENTIALS_FILE` to a JSON file like this:

```json
{
"appId": "cli_xxx",
"appSecret": "xxxx",
"brand": "lark",
"userOpenId": "ou_xxx",
"userName": "e2e user",
"accessToken": "",
"refreshToken": "u-xxxx",
"expiresAt": 0,
"refreshExpiresAt": 0,
"scope": "task:task:readonly",
"grantedAt": 0
}
```

When this env var is present, the CLI E2E harness will:

- create an isolated temporary HOME + `config.json`
- point child `lark-cli` processes at that temp config directory
- let the CLI read refresh/access token data from the same credentials file
- remove the temporary files after each command run

Example:

```bash
export LARK_CLI_CREDENTIALS_FILE=/tmp/lark-cli-user-creds.json
go test ./tests/cli_e2e/task -count=1
```

For GitHub Actions, store the same JSON content as a base64-encoded repository
secret such as `TEST_USER_CREDENTIALS_B64`, decode it into a temporary file at
runtime, export `LARK_CLI_CREDENTIALS_FILE`, and remove the file in an
`if: always()` cleanup step.

## Browser Auth E2E (Playwright)

`tests/cli_e2e/auth` contains config/auth entry-chain tests:

- `auth login --no-wait --json` -> browser authorization -> `auth login --device-code`
- `config init --new` -> parse verification URL from process output -> browser authorization -> `config show`

Playwright files live in `tests/cli_e2e/browser`.

Run locally:

```bash
cd tests/cli_e2e/browser
npm install

cd ../../..
export LARK_E2E_ENABLE_BROWSER_AUTH=1
go test ./tests/cli_e2e/auth -count=1 -v
```

If your OAuth page redirects to Feishu login (QR/password), provide an
authenticated Playwright storage state:

```bash
cd tests/cli_e2e/browser
npx playwright codegen https://open.feishu.cn --save-storage=.auth/state.json
```

Then run E2E with:

```bash
export PLAYWRIGHT_STORAGE_STATE=/Users/bytedance/cli/tests/cli_e2e/browser/.auth/state.json
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Replace hardcoded absolute path with a relative or placeholder path.

The path /Users/bytedance/cli/tests/cli_e2e/browser/.auth/state.json is user-specific and won't work for other contributors. Consider using a relative path or a placeholder.

📝 Suggested fix
-export PLAYWRIGHT_STORAGE_STATE=/Users/bytedance/cli/tests/cli_e2e/browser/.auth/state.json
+export PLAYWRIGHT_STORAGE_STATE=$(pwd)/tests/cli_e2e/browser/.auth/state.json

Or use a placeholder:

-export PLAYWRIGHT_STORAGE_STATE=/Users/bytedance/cli/tests/cli_e2e/browser/.auth/state.json
+export PLAYWRIGHT_STORAGE_STATE=/path/to/cli/tests/cli_e2e/browser/.auth/state.json
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export PLAYWRIGHT_STORAGE_STATE=/Users/bytedance/cli/tests/cli_e2e/browser/.auth/state.json
export PLAYWRIGHT_STORAGE_STATE=$(pwd)/tests/cli_e2e/browser/.auth/state.json
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/cli_e2e/README.md` at line 115, Replace the hardcoded absolute path in
the PLAYWRIGHT_STORAGE_STATE export with a relative path or placeholder; update
the export line that sets PLAYWRIGHT_STORAGE_STATE so it points to a
repo-relative location (e.g., a ./tests/... path) or a placeholder like
${PLAYWRIGHT_STORAGE_STATE_PATH} so other contributors can set their own value,
and document the expected default in the README.

export LARK_E2E_ENABLE_BROWSER_AUTH=1
go test ./tests/cli_e2e/auth -count=1 -v
```

When enabled, tests write artifacts to a temporary directory and print its path:

- `cli.stdout.log`
- `cli.stderr.log`
- `playwright.stdout.log`
- `playwright.stderr.log`
86 changes: 86 additions & 0 deletions tests/cli_e2e/base/base_basic_workflow_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT

package base

import (
"context"
"testing"
"time"

clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)

func TestBase_BasicWorkflow(t *testing.T) {
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute)
t.Cleanup(cancel)

baseName := "lark-cli-e2e-base-basic-" + testSuffix()
baseToken := createBase(t, ctx, baseName)

t.Run("get base", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"base", "+base-get", "--base-token", baseToken},
DefaultAs: "bot",
})
require.NoError(t, err)
if result.ExitCode != 0 {
skipIfBaseUnavailable(t, result, "requires bot base get capability")
}
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
returnedBaseToken := gjson.Get(result.Stdout, "data.base.app_token").String()
if returnedBaseToken == "" {
returnedBaseToken = gjson.Get(result.Stdout, "data.base.base_token").String()
}
assert.Equal(t, baseToken, returnedBaseToken, "stdout:\n%s", result.Stdout)
assert.NotEmpty(t, gjson.Get(result.Stdout, "data.base.name").String(), "stdout:\n%s", result.Stdout)
})

tableName := "lark-cli-e2e-table-basic-" + testSuffix()
tableID, primaryFieldID, primaryViewID := createTable(
t,
parentT,
ctx,
baseToken,
tableName,
`[{"name":"Name","type":"text"}]`,
`{"name":"Main","type":"grid"}`,
)

t.Run("get table", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"base", "+table-get", "--base-token", baseToken, "--table-id", tableID},
DefaultAs: "bot",
})
require.NoError(t, err)
if result.ExitCode != 0 {
skipIfBaseUnavailable(t, result, "requires bot table get capability")
}
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
assert.Equal(t, tableID, gjson.Get(result.Stdout, "data.table.id").String())
assert.Equal(t, tableName, gjson.Get(result.Stdout, "data.table.name").String())
})

t.Run("list tables and find created table", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"base", "+table-list", "--base-token", baseToken},
DefaultAs: "bot",
})
require.NoError(t, err)
if result.ExitCode != 0 {
skipIfBaseUnavailable(t, result, "requires bot table list capability")
}
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
assert.True(t, gjson.Get(result.Stdout, `data.items.#(table_id=="`+tableID+`")`).Exists(), "stdout:\n%s", result.Stdout)
})

require.NotEmpty(t, primaryFieldID)
require.NotEmpty(t, primaryViewID)
}
130 changes: 130 additions & 0 deletions tests/cli_e2e/base/base_role_workflow_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT

package base

import (
"context"
"testing"
"time"

clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)

func TestBase_RoleWorkflow(t *testing.T) {
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute)
t.Cleanup(cancel)
Comment on lines +17 to +20
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Isolate CLI config per test run.

This test does not isolate LARKSUITE_CLI_CONFIG_DIR, so state can leak across parallel/previous runs and make e2e results flaky.

As per coding guidelines **/*_test.go: “Isolate config state in Go tests by using t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())”.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/cli_e2e/base/base_role_workflow_test.go` around lines 17 - 20, The test
TestBase_RoleWorkflow currently doesn't isolate CLI config and can leak state;
update the test setup to set a unique config directory by calling
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) early in TestBase_RoleWorkflow
(before any CLI operations or goroutines) so each run uses an isolated config
dir; ensure this is added alongside the existing context/cancel setup using the
test's t variable.


baseToken := createBase(t, ctx, "lark-cli-e2e-base-role-"+testSuffix())
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"base", "+advperm-enable", "--base-token", baseToken},
DefaultAs: "bot",
})
require.NoError(t, err)
if result.ExitCode != 0 {
skipIfBaseUnavailable(t, result, "requires bot advanced permission enable capability")
}
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)

roleName := "Reviewer-" + testSuffix()
roleID := createRole(t, parentT, ctx, baseToken, `{"role_name":"`+roleName+`","role_type":"custom_role"}`)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Avoid deleting the same role twice (workflow delete + helper cleanup).

createRole(...) already registers a parentT.Cleanup delete in tests/cli_e2e/base/helpers_test.go:428-489. Running +role-delete again in this test can cause cleanup failure/noise when cleanup executes.

Consider either:

  1. keep the explicit delete subtest and create the role without auto-cleanup helper, or
  2. keep helper cleanup and drop the explicit delete subtest.

Also applies to: 118-129

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/cli_e2e/base/base_role_workflow_test.go` at line 35, The test creates a
role via createRole(...) which already registers a parentT.Cleanup delete (see
parentT.Cleanup in helpers_test.go), so avoid double-deleting by either removing
the explicit "+role-delete" subtest or changing the create call to a non-cleanup
variant; locate the role creation line where roleID := createRole(t, parentT,
ctx, baseToken, `{"role_name":"`+roleName+`","role_type":"custom_role"}`) and
either (A) delete the subtest that calls the workflow "+role-delete" later in
this test, or (B) replace createRole with a helper that does not register
parentT.Cleanup so the explicit delete remains — apply the same change for the
other occurrence around lines 118-129.


t.Run("list", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"base", "+role-list", "--base-token", baseToken},
DefaultAs: "bot",
})
require.NoError(t, err)
if result.ExitCode != 0 {
skipIfBaseUnavailable(t, result, "requires bot role list capability")
}
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)

roleListPayload := gjson.Get(result.Stdout, "data.data").String()
require.NotEmpty(t, roleListPayload, "stdout:\n%s", result.Stdout)
assert.True(t, gjson.Valid(roleListPayload), "role list payload should be valid JSON: %s", roleListPayload)

roleItems := gjson.Get(roleListPayload, "base_roles").Array()
assert.NotEmpty(t, roleItems, "role list should contain at least one role: %s", roleListPayload)

found := false
for _, item := range roleItems {
rolePayload := item.String()
if !gjson.Valid(rolePayload) {
continue
}
if gjson.Get(rolePayload, "role_id").String() == roleID {
found = true
break
}
}
assert.True(t, found, "stdout:\n%s", result.Stdout)
})

t.Run("get", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"base", "+role-get", "--base-token", baseToken, "--role-id", roleID},
DefaultAs: "bot",
})
require.NoError(t, err)
if result.ExitCode != 0 {
skipIfBaseUnavailable(t, result, "requires bot role get capability")
}
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)

rolePayload := gjson.Get(result.Stdout, "data.data").String()
require.NotEmpty(t, rolePayload, "stdout:\n%s", result.Stdout)
require.True(t, gjson.Valid(rolePayload), "stdout:\n%s", result.Stdout)
assert.Equal(t, roleID, gjson.Get(rolePayload, "role_id").String())
})

t.Run("update", func(t *testing.T) {
updatedRoleName := roleName + " Updated"
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"base", "+role-update", "--base-token", baseToken, "--role-id", roleID, "--json", `{"role_name":"` + updatedRoleName + `","role_type":"custom_role"}`, "--yes"},
DefaultAs: "bot",
})
require.NoError(t, err)
if result.ExitCode != 0 {
skipIfBaseUnavailable(t, result, "requires bot role update capability")
}
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)

getResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"base", "+role-get", "--base-token", baseToken, "--role-id", roleID},
DefaultAs: "bot",
})
require.NoError(t, err)
if getResult.ExitCode != 0 {
skipIfBaseUnavailable(t, getResult, "requires bot role get capability")
}
getResult.AssertExitCode(t, 0)
getResult.AssertStdoutStatus(t, true)

rolePayload := gjson.Get(getResult.Stdout, "data.data").String()
require.NotEmpty(t, rolePayload, "stdout:\n%s", getResult.Stdout)
require.True(t, gjson.Valid(rolePayload), "stdout:\n%s", getResult.Stdout)
assert.Equal(t, updatedRoleName, gjson.Get(rolePayload, "role_name").String())
})

t.Run("delete", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"base", "+role-delete", "--base-token", baseToken, "--role-id", roleID, "--yes"},
DefaultAs: "bot",
})
require.NoError(t, err)
if result.ExitCode != 0 {
skipIfBaseUnavailable(t, result, "requires bot role delete capability")
}
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
})
}
Loading
Loading