chore: import upstream snapshot with attribution
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / results (push) Has been cancelled
CI / deadcode (push) Has been cancelled
CI / e2e-live (push) Has been cancelled
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / results (push) Has been cancelled
CI / deadcode (push) Has been cancelled
CI / e2e-live (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
# Sheets CLI E2E Coverage
|
||||
|
||||
## Metrics
|
||||
- Denominator: 30 leaf commands
|
||||
- Covered: 18
|
||||
- Coverage: 60.0%
|
||||
|
||||
## 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`.
|
||||
- TestSheets_SheetShortcutsDryRun: proves request shapes for `+create-sheet`, `+copy-sheet`, `+delete-sheet`, and `+update-sheet` without hitting live APIs.
|
||||
- TestSheets_SheetShortcutsWorkflow: proves live `+create-sheet`, `+copy-sheet`, `+update-sheet`, and `+delete-sheet` flows against a real spreadsheet, with verification through `+info`.
|
||||
- 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 +create-sheet | shortcut | sheets_sheet_shortcuts_workflow_test.go::TestSheets_SheetShortcutsWorkflow/create sheet with +create-sheet as bot | `--spreadsheet-token`; optional `--title`; optional `--index` | dry-run shape also covered by sheets_sheet_shortcuts_dryrun_test.go |
|
||||
| ✓ | sheets +copy-sheet | shortcut | sheets_sheet_shortcuts_workflow_test.go::TestSheets_SheetShortcutsWorkflow/copy sheet with +copy-sheet as bot | `--spreadsheet-token`; `--sheet-id`; optional `--title`; optional `--index` | dry-run shape also covered by sheets_sheet_shortcuts_dryrun_test.go |
|
||||
| ✕ | sheets +delete-dimension | shortcut | | none | no dimension workflow yet |
|
||||
| ✓ | sheets +delete-sheet | shortcut | sheets_sheet_shortcuts_workflow_test.go::TestSheets_SheetShortcutsWorkflow/delete sheet with +delete-sheet as bot | `--spreadsheet-token`; `--sheet-id` | dry-run shape also covered by sheets_sheet_shortcuts_dryrun_test.go |
|
||||
| ✓ | 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 +update-sheet | shortcut | sheets_sheet_shortcuts_workflow_test.go::TestSheets_SheetShortcutsWorkflow/update sheet with +update-sheet as bot | `--spreadsheet-token`; `--sheet-id`; scalar update flags; optional protect fields | dry-run shape also covered by sheets_sheet_shortcuts_dryrun_test.go |
|
||||
| ✓ | 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` | |
|
||||
@@ -0,0 +1,45 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/larksuite/cli/tests/cli_e2e/drive"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func createSpreadsheet(t *testing.T, parentT *testing.T, ctx context.Context, title string, defaultAs string) string {
|
||||
t.Helper()
|
||||
|
||||
folderToken := drive.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 := drive.DeleteDriveResourceAndVerify(cleanupCtx, spreadsheetToken, "sheet", defaultAs)
|
||||
clie2e.ReportCleanupFailure(parentT, "delete spreadsheet "+spreadsheetToken, deleteResult, deleteErr)
|
||||
})
|
||||
|
||||
return spreadsheetToken
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/larksuite/cli/tests/cli_e2e/drive"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// TestSheets_CRUDE2EWorkflow tests the full lifecycle of spreadsheet operations
|
||||
// using all shortcut methods: +create, +read, +write, +append, +find, +info, +export
|
||||
func TestSheets_CRUDE2EWorkflow(t *testing.T) {
|
||||
parentT := t
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
suffix := clie2e.GenerateSuffix()
|
||||
spreadsheetToken := ""
|
||||
sheetID := ""
|
||||
|
||||
t.Run("create spreadsheet with +create as bot", func(t *testing.T) {
|
||||
spreadsheetToken = createSpreadsheet(t, parentT, ctx, "lark-cli-e2e-sheets-"+suffix, "bot")
|
||||
})
|
||||
|
||||
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},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
assert.Equal(t, spreadsheetToken, gjson.Get(result.Stdout, "data.spreadsheet.spreadsheet.token").String())
|
||||
sheetID = gjson.Get(result.Stdout, "data.sheets.sheets.0.sheet_id").String()
|
||||
require.NotEmpty(t, sheetID, "sheet_id should not be empty, stdout: %s", result.Stdout)
|
||||
})
|
||||
|
||||
t.Run("write data with +write as bot", func(t *testing.T) {
|
||||
require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required")
|
||||
require.NotEmpty(t, sheetID, "sheet_id is required")
|
||||
|
||||
values := [][]any{
|
||||
{"Name", "Age", "City"},
|
||||
{"Alice", 25, "Beijing"},
|
||||
{"Bob", 30, "Shanghai"},
|
||||
}
|
||||
valuesJSON, _ := json.Marshal(values)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"sheets", "+write",
|
||||
"--spreadsheet-token", spreadsheetToken,
|
||||
"--sheet-id", sheetID,
|
||||
"--range", "A1:C3",
|
||||
"--values", string(valuesJSON),
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
})
|
||||
|
||||
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")
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"sheets", "+read",
|
||||
"--spreadsheet-token", spreadsheetToken,
|
||||
"--sheet-id", sheetID,
|
||||
"--range", "A1:C3",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
// Verify the data was written correctly
|
||||
values := gjson.Get(result.Stdout, "data.valueRange.values")
|
||||
require.True(t, values.IsArray(), "values should be an array, stdout: %s", result.Stdout)
|
||||
assert.Equal(t, "Name", values.Array()[0].Array()[0].String())
|
||||
assert.Equal(t, "Alice", values.Array()[1].Array()[0].String())
|
||||
})
|
||||
|
||||
t.Run("append rows with +append as bot", func(t *testing.T) {
|
||||
require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required")
|
||||
require.NotEmpty(t, sheetID, "sheet_id is required")
|
||||
|
||||
values := [][]any{{"Charlie", 28, "Guangzhou"}}
|
||||
valuesJSON, _ := json.Marshal(values)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"sheets", "+append",
|
||||
"--spreadsheet-token", spreadsheetToken,
|
||||
"--sheet-id", sheetID,
|
||||
"--range", "A4:C4",
|
||||
"--values", string(valuesJSON),
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
})
|
||||
|
||||
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")
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"sheets", "+find",
|
||||
"--spreadsheet-token", spreadsheetToken,
|
||||
"--sheet-id", sheetID,
|
||||
"--find", "Alice",
|
||||
"--range", fmt.Sprintf("%s!A1:C10", sheetID),
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
assert.Equal(t, true, gjson.Get(result.Stdout, "ok").Bool(), "stdout:\n%s", result.Stdout)
|
||||
|
||||
matchedCells := gjson.Get(result.Stdout, "data.find_result.matched_cells")
|
||||
require.True(t, matchedCells.IsArray(), "matched_cells should be an array, stdout: %s", result.Stdout)
|
||||
assert.True(t, len(matchedCells.Array()) > 0, "should find at least one cell containing 'Alice'")
|
||||
})
|
||||
|
||||
t.Run("export spreadsheet with +workbook-export as bot", func(t *testing.T) {
|
||||
require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required")
|
||||
outputDir := t.TempDir()
|
||||
outputPath := filepath.Join(outputDir, "export.xlsx")
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"sheets", "+workbook-export",
|
||||
"--spreadsheet-token", spreadsheetToken,
|
||||
"--file-extension", "xlsx",
|
||||
"--output-path", "./export.xlsx",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
WorkDir: outputDir,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
// TestSheets_SpreadsheetsResource tests the spreadsheets resource methods
|
||||
func TestSheets_SpreadsheetsResource(t *testing.T) {
|
||||
parentT := t
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
suffix := clie2e.GenerateSuffix()
|
||||
spreadsheetToken := ""
|
||||
const defaultAs = "bot"
|
||||
|
||||
t.Run("create spreadsheet with spreadsheets create as bot", func(t *testing.T) {
|
||||
folderToken := drive.CreateDriveFolder(t, parentT, ctx, "lark-cli-e2e-sheets-resource-folder-"+suffix, defaultAs, "")
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"sheets", "spreadsheets", "create"},
|
||||
DefaultAs: defaultAs,
|
||||
Data: map[string]any{
|
||||
"title": "lark-cli-e2e-sheets-resource-" + suffix,
|
||||
"folder_token": folderToken,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
spreadsheetToken = gjson.Get(result.Stdout, "data.spreadsheet.spreadsheet_token").String()
|
||||
require.NotEmpty(t, spreadsheetToken, "spreadsheet token should not be empty, stdout: %s", result.Stdout)
|
||||
|
||||
parentT.Cleanup(func() {
|
||||
cleanupCtx, cancel := clie2e.CleanupContext()
|
||||
defer cancel()
|
||||
|
||||
deleteResult, deleteErr := drive.DeleteDriveResourceAndVerify(cleanupCtx, spreadsheetToken, "sheet", defaultAs)
|
||||
clie2e.ReportCleanupFailure(parentT, "delete spreadsheet "+spreadsheetToken, deleteResult, deleteErr)
|
||||
})
|
||||
})
|
||||
|
||||
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"},
|
||||
DefaultAs: "bot",
|
||||
Params: map[string]any{"spreadsheet_token": spreadsheetToken},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
assert.Equal(t, spreadsheetToken, gjson.Get(result.Stdout, "data.spreadsheet.token").String())
|
||||
assert.NotEmpty(t, gjson.Get(result.Stdout, "data.spreadsheet.url").String())
|
||||
})
|
||||
|
||||
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"},
|
||||
DefaultAs: "bot",
|
||||
Params: map[string]any{"spreadsheet_token": spreadsheetToken},
|
||||
Data: map[string]any{"title": updatedTitle},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
// Verify the title was updated by fetching the spreadsheet
|
||||
getResult, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"sheets", "spreadsheets", "get"},
|
||||
DefaultAs: "bot",
|
||||
Params: map[string]any{"spreadsheet_token": spreadsheetToken},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
getResult.AssertExitCode(t, 0)
|
||||
getResult.AssertStdoutStatus(t, true)
|
||||
|
||||
// Verify the title was actually updated
|
||||
assert.Equal(t, updatedTitle, gjson.Get(getResult.Stdout, "data.spreadsheet.title").String())
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// TestSheets_FilterWorkflow tests the spreadsheet sheet filter operations
|
||||
func TestSheets_FilterWorkflow(t *testing.T) {
|
||||
parentT := t
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
suffix := clie2e.GenerateSuffix()
|
||||
spreadsheetToken := ""
|
||||
sheetID := ""
|
||||
|
||||
t.Run("create spreadsheet with initial data as bot", func(t *testing.T) {
|
||||
spreadsheetToken = createSpreadsheet(t, parentT, ctx, "lark-cli-e2e-sheets-filter-"+suffix, "bot")
|
||||
})
|
||||
|
||||
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},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
sheetID = gjson.Get(result.Stdout, "data.sheets.sheets.0.sheet_id").String()
|
||||
require.NotEmpty(t, sheetID, "sheet_id should not be empty, stdout: %s", result.Stdout)
|
||||
})
|
||||
|
||||
t.Run("write test data for filtering as bot", func(t *testing.T) {
|
||||
require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required")
|
||||
require.NotEmpty(t, sheetID, "sheet_id is required")
|
||||
|
||||
values := [][]any{
|
||||
{"Name", "Score", "Grade"},
|
||||
{"Alice", 85, "B"},
|
||||
{"Bob", 92, "A"},
|
||||
{"Charlie", 78, "C"},
|
||||
{"Diana", 95, "A"},
|
||||
}
|
||||
valuesJSON, _ := json.Marshal(values)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"sheets", "+write",
|
||||
"--spreadsheet-token", spreadsheetToken,
|
||||
"--sheet-id", sheetID,
|
||||
"--range", "A1:C5",
|
||||
"--values", string(valuesJSON),
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
})
|
||||
|
||||
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")
|
||||
|
||||
filterData := map[string]any{
|
||||
"range": fmt.Sprintf("%s!A1:D5", sheetID),
|
||||
"col": "C",
|
||||
"filter_type": "multiValue",
|
||||
"condition": map[string]any{
|
||||
"filter_type": "multiValue",
|
||||
"expected": []any{"A", "B"},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"sheets", "spreadsheet.sheet.filters", "create"},
|
||||
DefaultAs: "bot",
|
||||
Params: map[string]any{
|
||||
"spreadsheet_token": spreadsheetToken,
|
||||
"sheet_id": sheetID,
|
||||
},
|
||||
Data: filterData,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
})
|
||||
|
||||
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"},
|
||||
DefaultAs: "bot",
|
||||
Params: map[string]any{
|
||||
"spreadsheet_token": spreadsheetToken,
|
||||
"sheet_id": sheetID,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
filterInfo := gjson.Get(result.Stdout, "data.sheet_filter_info")
|
||||
require.True(t, filterInfo.Exists(), "filter info should exist, stdout: %s", result.Stdout)
|
||||
})
|
||||
|
||||
t.Run("update filter with spreadsheet.sheet.filters update 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": "C",
|
||||
"filter_type": "multiValue",
|
||||
"condition": map[string]any{
|
||||
"filter_type": "multiValue",
|
||||
"expected": []any{"A"},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"sheets", "spreadsheet.sheet.filters", "update"},
|
||||
DefaultAs: "bot",
|
||||
Params: map[string]any{
|
||||
"spreadsheet_token": spreadsheetToken,
|
||||
"sheet_id": sheetID,
|
||||
},
|
||||
Data: filterData,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
})
|
||||
|
||||
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"},
|
||||
DefaultAs: "bot",
|
||||
Params: map[string]any{
|
||||
"spreadsheet_token": spreadsheetToken,
|
||||
"sheet_id": sheetID,
|
||||
},
|
||||
Yes: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// 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/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// TestSheets_GridlineDryRun pins the +sheet-show-gridline / +sheet-hide-gridline
|
||||
// dry-run shape: each emits a single modify_workbook_structure invoke_write with
|
||||
// the correct operation name. These are the shortcuts added in this branch, so
|
||||
// AGENTS.md requires a dry-run E2E to catch a request-shape regression early
|
||||
// (before the live call hits a real spreadsheet).
|
||||
func TestSheets_GridlineDryRun(t *testing.T) {
|
||||
setSheetsDryRunEnv(t)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
shortcut string
|
||||
wantOpName string
|
||||
}{
|
||||
{name: "show", shortcut: "+sheet-show-gridline", wantOpName: "show_gridline"},
|
||||
{name: "hide", shortcut: "+sheet-hide-gridline", wantOpName: "hide_gridline"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"sheets", tt.shortcut,
|
||||
"--spreadsheet-token", "shtDryRun",
|
||||
"--sheet-id", "sheet1",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
|
||||
require.Equal(t, "POST", gjson.Get(out, "api.0.method").String(), "stdout:\n%s", out)
|
||||
require.Equal(t, "/open-apis/sheet_ai/v2/spreadsheets/shtDryRun/tools/invoke_write",
|
||||
gjson.Get(out, "api.0.url").String(), "stdout:\n%s", out)
|
||||
require.Equal(t, "modify_workbook_structure",
|
||||
gjson.Get(out, "api.0.body.tool_name").String(), "stdout:\n%s", out)
|
||||
input := gjson.Get(out, "api.0.body.input").String()
|
||||
require.Contains(t, input, `"operation":"`+tt.wantOpName+`"`, "stdout:\n%s", out)
|
||||
require.Contains(t, input, `"sheet_id":"sheet1"`, "stdout:\n%s", out)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// 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/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// TestSheets_GridlineWorkflow round-trips +sheet-show-gridline and
|
||||
// +sheet-hide-gridline against a real spreadsheet. The dry-run E2E pins the
|
||||
// wire shape; this live test validates the backend accepts both operations
|
||||
// end-to-end (the gridline state itself is write-only — there is no read
|
||||
// field exposed via +sheet-info / +workbook-info — so success here is the
|
||||
// ok=true envelope, not a value comparison).
|
||||
func TestSheets_GridlineWorkflow(t *testing.T) {
|
||||
parentT := t
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
suffix := clie2e.GenerateSuffix()
|
||||
spreadsheetToken := createSpreadsheet(t, parentT, ctx, "lark-cli-e2e-sheets-gridline-"+suffix, "bot")
|
||||
|
||||
infoResult, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"sheets", "+info", "--spreadsheet-token", spreadsheetToken},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
infoResult.AssertExitCode(t, 0)
|
||||
infoResult.AssertStdoutStatus(t, true)
|
||||
sheetID := gjson.Get(infoResult.Stdout, "data.sheets.sheets.0.sheet_id").String()
|
||||
require.NotEmpty(t, sheetID, "sheet_id should not be empty, stdout: %s", infoResult.Stdout)
|
||||
|
||||
for _, shortcut := range []string{"+sheet-hide-gridline", "+sheet-show-gridline"} {
|
||||
t.Run(shortcut+" as bot", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"sheets", shortcut,
|
||||
"--spreadsheet-token", spreadsheetToken,
|
||||
"--sheet-id", sheetID,
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// TestSheets_ImageUploadDryRunParentType pins the parent_type the sheets
|
||||
// image-upload shortcuts emit in --dry-run output for native vs. imported
|
||||
// "office" spreadsheets. For native tokens parent_type must be "sheet_image";
|
||||
// for tokens prefixed with "fake_office_" (the synthetic token an imported
|
||||
// office spreadsheet carries) the backend requires "office_sheet_file". The
|
||||
// three covered entries — sheets +media-upload (backward), sheets
|
||||
// +cells-set-image, and sheets +create-float-image — are every image-upload
|
||||
// surface that the office/native split fans out to.
|
||||
func TestSheets_ImageUploadDryRunParentType(t *testing.T) {
|
||||
setSheetsDryRunEnv(t)
|
||||
|
||||
workDir := t.TempDir()
|
||||
require.NoError(t, os.WriteFile(filepath.Join(workDir, "img.png"), []byte("png-bytes"), 0o600))
|
||||
|
||||
type tc struct {
|
||||
name string
|
||||
args []string
|
||||
token string
|
||||
wantParentType string
|
||||
}
|
||||
tests := []tc{
|
||||
{
|
||||
name: "media-upload native",
|
||||
args: []string{
|
||||
"sheets", "+media-upload",
|
||||
"--spreadsheet-token", "shtDryRunNative",
|
||||
"--file", "img.png",
|
||||
"--dry-run",
|
||||
},
|
||||
token: "shtDryRunNative",
|
||||
wantParentType: "sheet_image",
|
||||
},
|
||||
{
|
||||
name: "media-upload office",
|
||||
args: []string{
|
||||
"sheets", "+media-upload",
|
||||
"--spreadsheet-token", "fake_office_dryrun",
|
||||
"--file", "img.png",
|
||||
"--dry-run",
|
||||
},
|
||||
token: "fake_office_dryrun",
|
||||
wantParentType: "office_sheet_file",
|
||||
},
|
||||
{
|
||||
name: "cells-set-image native",
|
||||
args: []string{
|
||||
"sheets", "+cells-set-image",
|
||||
"--spreadsheet-token", "shtDryRunNative",
|
||||
"--sheet-id", "sheet1",
|
||||
"--range", "A1",
|
||||
"--image", "img.png",
|
||||
"--dry-run",
|
||||
},
|
||||
token: "shtDryRunNative",
|
||||
wantParentType: "sheet_image",
|
||||
},
|
||||
{
|
||||
name: "cells-set-image office",
|
||||
args: []string{
|
||||
"sheets", "+cells-set-image",
|
||||
"--spreadsheet-token", "fake_office_dryrun",
|
||||
"--sheet-id", "sheet1",
|
||||
"--range", "A1",
|
||||
"--image", "img.png",
|
||||
"--dry-run",
|
||||
},
|
||||
token: "fake_office_dryrun",
|
||||
wantParentType: "office_sheet_file",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: tt.args,
|
||||
DefaultAs: "user",
|
||||
WorkDir: workDir,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
require.Equal(t, "POST", gjson.Get(out, "api.0.method").String(), "api.0 must be the drive upload; stdout:\n%s", out)
|
||||
require.Equal(t, "/open-apis/drive/v1/medias/upload_all",
|
||||
gjson.Get(out, "api.0.url").String(), "stdout:\n%s", out)
|
||||
require.Equal(t, tt.wantParentType, gjson.Get(out, "api.0.body.parent_type").String(),
|
||||
"parent_type for token %q must be %q; stdout:\n%s", tt.token, tt.wantParentType, out)
|
||||
require.Equal(t, tt.token, gjson.Get(out, "api.0.body.parent_node").String(),
|
||||
"parent_node must equal the spreadsheet token; stdout:\n%s", out)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func setSheetsDryRunEnv(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
}
|
||||
|
||||
func TestSheets_SheetShortcutsDryRunRejectsURLAndTokenTogether(t *testing.T) {
|
||||
setSheetsDryRunEnv(t)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{
|
||||
name: "create-sheet",
|
||||
args: []string{
|
||||
"sheets", "+create-sheet",
|
||||
"--url", "https://example.feishu.cn/sheets/shtFromURL",
|
||||
"--spreadsheet-token", "shtTOKEN",
|
||||
"--title", "Data",
|
||||
"--dry-run",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "copy-sheet",
|
||||
args: []string{
|
||||
"sheets", "+copy-sheet",
|
||||
"--url", "https://example.feishu.cn/sheets/shtFromURL",
|
||||
"--spreadsheet-token", "shtTOKEN",
|
||||
"--sheet-id", "sheet1",
|
||||
"--title", "Copy",
|
||||
"--dry-run",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "delete-sheet",
|
||||
args: []string{
|
||||
"sheets", "+delete-sheet",
|
||||
"--url", "https://example.feishu.cn/sheets/shtFromURL",
|
||||
"--spreadsheet-token", "shtTOKEN",
|
||||
"--sheet-id", "sheet1",
|
||||
"--dry-run",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "update-sheet",
|
||||
args: []string{
|
||||
"sheets", "+update-sheet",
|
||||
"--url", "https://example.feishu.cn/sheets/shtFromURL",
|
||||
"--spreadsheet-token", "shtTOKEN",
|
||||
"--sheet-id", "sheet1",
|
||||
"--title", "Renamed",
|
||||
"--dry-run",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: tt.args,
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 2)
|
||||
combined := result.Stdout + "\n" + result.Stderr
|
||||
if !strings.Contains(combined, "mutually exclusive") {
|
||||
t.Fatalf("expected mutual exclusivity error, got:\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheets_SheetShortcutsDryRunRejectsEmptyTitle(t *testing.T) {
|
||||
setSheetsDryRunEnv(t)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{
|
||||
name: "create-sheet",
|
||||
args: []string{
|
||||
"sheets", "+create-sheet",
|
||||
"--spreadsheet-token", "shtDryRun",
|
||||
"--title", "",
|
||||
"--dry-run",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "copy-sheet",
|
||||
args: []string{
|
||||
"sheets", "+copy-sheet",
|
||||
"--spreadsheet-token", "shtDryRun",
|
||||
"--sheet-id", "sheet1",
|
||||
"--title", "",
|
||||
"--dry-run",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "update-sheet",
|
||||
args: []string{
|
||||
"sheets", "+update-sheet",
|
||||
"--spreadsheet-token", "shtDryRun",
|
||||
"--sheet-id", "sheet1",
|
||||
"--title", "",
|
||||
"--dry-run",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: tt.args,
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 2)
|
||||
combined := result.Stdout + "\n" + result.Stderr
|
||||
if !strings.Contains(combined, "must not be empty") {
|
||||
t.Fatalf("expected empty-title error, got:\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheets_SheetShortcutsDryRun(t *testing.T) {
|
||||
setSheetsDryRunEnv(t)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantURL string
|
||||
wantFn func(t *testing.T, out string)
|
||||
}{
|
||||
{
|
||||
name: "create-sheet",
|
||||
args: []string{
|
||||
"sheets", "+create-sheet",
|
||||
"--spreadsheet-token", "shtDryRun",
|
||||
"--title", "Data",
|
||||
"--index", "0",
|
||||
"--dry-run",
|
||||
},
|
||||
wantURL: "/open-apis/sheets/v2/spreadsheets/shtDryRun/sheets_batch_update",
|
||||
wantFn: func(t *testing.T, out string) {
|
||||
require.Equal(t, "POST", gjson.Get(out, "api.0.method").String(), "stdout:\n%s", out)
|
||||
require.Equal(t, "Data", gjson.Get(out, "api.0.body.requests.0.addSheet.properties.title").String(), "stdout:\n%s", out)
|
||||
require.Equal(t, int64(0), gjson.Get(out, "api.0.body.requests.0.addSheet.properties.index").Int(), "stdout:\n%s", out)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "copy-sheet",
|
||||
args: []string{
|
||||
"sheets", "+copy-sheet",
|
||||
"--spreadsheet-token", "shtDryRun",
|
||||
"--sheet-id", "sheet1",
|
||||
"--title", "Copy",
|
||||
"--index", "2",
|
||||
"--dry-run",
|
||||
},
|
||||
wantURL: "/open-apis/sheets/v2/spreadsheets/shtDryRun/sheets_batch_update",
|
||||
wantFn: func(t *testing.T, out string) {
|
||||
require.Equal(t, "POST", gjson.Get(out, "api.0.method").String(), "stdout:\n%s", out)
|
||||
require.Equal(t, "sheet1", gjson.Get(out, "api.0.body.requests.0.copySheet.source.sheetId").String(), "stdout:\n%s", out)
|
||||
require.Equal(t, "Copy", gjson.Get(out, "api.0.body.requests.0.copySheet.destination.title").String(), "stdout:\n%s", out)
|
||||
require.Equal(t, "POST", gjson.Get(out, "api.1.method").String(), "stdout:\n%s", out)
|
||||
require.Equal(t, "<copied_sheet_id>", gjson.Get(out, "api.1.body.requests.0.updateSheet.properties.sheetId").String(), "stdout:\n%s", out)
|
||||
require.Equal(t, int64(2), gjson.Get(out, "api.1.body.requests.0.updateSheet.properties.index").Int(), "stdout:\n%s", out)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "delete-sheet",
|
||||
args: []string{
|
||||
"sheets", "+delete-sheet",
|
||||
"--spreadsheet-token", "shtDryRun",
|
||||
"--sheet-id", "sheet1",
|
||||
"--dry-run",
|
||||
},
|
||||
wantURL: "/open-apis/sheets/v2/spreadsheets/shtDryRun/sheets_batch_update",
|
||||
wantFn: func(t *testing.T, out string) {
|
||||
require.Equal(t, "POST", gjson.Get(out, "api.0.method").String(), "stdout:\n%s", out)
|
||||
require.Equal(t, "sheet1", gjson.Get(out, "api.0.body.requests.0.deleteSheet.sheetId").String(), "stdout:\n%s", out)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "update-sheet",
|
||||
args: []string{
|
||||
"sheets", "+update-sheet",
|
||||
"--spreadsheet-token", "shtDryRun",
|
||||
"--sheet-id", "sheet1",
|
||||
"--title", "Renamed",
|
||||
"--hidden=false",
|
||||
"--frozen-row-count", "2",
|
||||
"--frozen-col-count", "1",
|
||||
"--lock", "LOCK",
|
||||
"--lock-info", "private",
|
||||
"--user-ids", `["ou_1"]`,
|
||||
"--user-id-type", "open_id",
|
||||
"--dry-run",
|
||||
},
|
||||
wantURL: "/open-apis/sheets/v2/spreadsheets/shtDryRun/sheets_batch_update",
|
||||
wantFn: func(t *testing.T, out string) {
|
||||
require.Equal(t, "POST", gjson.Get(out, "api.0.method").String(), "stdout:\n%s", out)
|
||||
require.Equal(t, "open_id", gjson.Get(out, "api.0.params.user_id_type").String(), "stdout:\n%s", out)
|
||||
require.Equal(t, "sheet1", gjson.Get(out, "api.0.body.requests.0.updateSheet.properties.sheetId").String(), "stdout:\n%s", out)
|
||||
require.Equal(t, "Renamed", gjson.Get(out, "api.0.body.requests.0.updateSheet.properties.title").String(), "stdout:\n%s", out)
|
||||
require.Equal(t, false, gjson.Get(out, "api.0.body.requests.0.updateSheet.properties.hidden").Bool(), "stdout:\n%s", out)
|
||||
require.Equal(t, int64(2), gjson.Get(out, "api.0.body.requests.0.updateSheet.properties.frozenRowCount").Int(), "stdout:\n%s", out)
|
||||
require.Equal(t, int64(1), gjson.Get(out, "api.0.body.requests.0.updateSheet.properties.frozenColCount").Int(), "stdout:\n%s", out)
|
||||
require.Equal(t, "LOCK", gjson.Get(out, "api.0.body.requests.0.updateSheet.properties.protect.lock").String(), "stdout:\n%s", out)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: tt.args,
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
require.Equal(t, tt.wantURL, gjson.Get(out, "api.0.url").String(), "stdout:\n%s", out)
|
||||
tt.wantFn(t, out)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
// 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_SheetShortcutsWorkflow(t *testing.T) {
|
||||
parentT := t
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
suffix := clie2e.GenerateSuffix()
|
||||
spreadsheetToken := ""
|
||||
originalSheetID := ""
|
||||
createdSheetID := ""
|
||||
copiedSheetID := ""
|
||||
|
||||
t.Run("create spreadsheet with +create as bot", func(t *testing.T) {
|
||||
spreadsheetToken = createSpreadsheet(t, parentT, ctx, "lark-cli-e2e-sheet-shortcuts-"+suffix, "bot")
|
||||
})
|
||||
|
||||
t.Run("get initial 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},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
originalSheetID = gjson.Get(result.Stdout, "data.sheets.sheets.0.sheet_id").String()
|
||||
require.NotEmpty(t, originalSheetID, "sheet_id should not be empty, stdout: %s", result.Stdout)
|
||||
})
|
||||
|
||||
t.Run("create sheet with +create-sheet as bot", func(t *testing.T) {
|
||||
require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required")
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"sheets", "+create-sheet",
|
||||
"--spreadsheet-token", spreadsheetToken,
|
||||
"--title", "data-" + suffix,
|
||||
"--index", "1",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
createdSheetID = gjson.Get(result.Stdout, "data.sheet_id").String()
|
||||
require.NotEmpty(t, createdSheetID, "created sheet_id should not be empty, stdout: %s", result.Stdout)
|
||||
assert.Equal(t, "data-"+suffix, gjson.Get(result.Stdout, "data.sheet.title").String())
|
||||
})
|
||||
|
||||
t.Run("copy sheet with +copy-sheet as bot", func(t *testing.T) {
|
||||
require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required")
|
||||
require.NotEmpty(t, createdSheetID, "created sheet_id is required")
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"sheets", "+copy-sheet",
|
||||
"--spreadsheet-token", spreadsheetToken,
|
||||
"--sheet-id", createdSheetID,
|
||||
"--title", "copy-" + suffix,
|
||||
"--index", "2",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
copiedSheetID = gjson.Get(result.Stdout, "data.sheet_id").String()
|
||||
require.NotEmpty(t, copiedSheetID, "copied sheet_id should not be empty, stdout: %s", result.Stdout)
|
||||
assert.NotEqual(t, createdSheetID, copiedSheetID)
|
||||
assert.Equal(t, "copy-"+suffix, gjson.Get(result.Stdout, "data.sheet.title").String())
|
||||
})
|
||||
|
||||
t.Run("update sheet with +update-sheet as bot", func(t *testing.T) {
|
||||
require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required")
|
||||
require.NotEmpty(t, createdSheetID, "created sheet_id is required")
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"sheets", "+update-sheet",
|
||||
"--spreadsheet-token", spreadsheetToken,
|
||||
"--sheet-id", createdSheetID,
|
||||
"--title", "renamed-" + suffix,
|
||||
"--hidden=true",
|
||||
"--frozen-row-count", "2",
|
||||
"--frozen-col-count", "1",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
assert.Equal(t, createdSheetID, gjson.Get(result.Stdout, "data.sheet_id").String())
|
||||
assert.Equal(t, "renamed-"+suffix, gjson.Get(result.Stdout, "data.sheet.title").String())
|
||||
assert.Equal(t, true, gjson.Get(result.Stdout, "data.sheet.hidden").Bool())
|
||||
})
|
||||
|
||||
t.Run("verify updated sheet through +info as bot", func(t *testing.T) {
|
||||
require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required")
|
||||
require.NotEmpty(t, createdSheetID, "created sheet_id is required")
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"sheets", "+info", "--spreadsheet-token", spreadsheetToken},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
var matched gjson.Result
|
||||
for _, item := range gjson.Get(result.Stdout, "data.sheets.sheets").Array() {
|
||||
if gjson.Get(item.Raw, "sheet_id").String() == createdSheetID {
|
||||
matched = item
|
||||
break
|
||||
}
|
||||
}
|
||||
require.True(t, matched.Exists(), "updated sheet %s should exist, stdout: %s", createdSheetID, result.Stdout)
|
||||
assert.Equal(t, "renamed-"+suffix, gjson.Get(matched.Raw, "title").String())
|
||||
assert.Equal(t, true, gjson.Get(matched.Raw, "hidden").Bool())
|
||||
assert.Equal(t, int64(2), gjson.Get(matched.Raw, "grid_properties.frozen_row_count").Int())
|
||||
assert.Equal(t, int64(1), gjson.Get(matched.Raw, "grid_properties.frozen_column_count").Int())
|
||||
})
|
||||
|
||||
t.Run("delete sheet with +delete-sheet as bot", func(t *testing.T) {
|
||||
require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required")
|
||||
require.NotEmpty(t, copiedSheetID, "copied sheet_id is required")
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"sheets", "+delete-sheet",
|
||||
"--spreadsheet-token", spreadsheetToken,
|
||||
"--sheet-id", copiedSheetID,
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
Yes: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
assert.Equal(t, true, gjson.Get(result.Stdout, "data.deleted").Bool())
|
||||
assert.Equal(t, copiedSheetID, gjson.Get(result.Stdout, "data.sheet_id").String())
|
||||
})
|
||||
|
||||
t.Run("verify deleted sheet through +info as bot", func(t *testing.T) {
|
||||
require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required")
|
||||
require.NotEmpty(t, copiedSheetID, "copied sheet_id is required")
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"sheets", "+info", "--spreadsheet-token", spreadsheetToken},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
for _, item := range gjson.Get(result.Stdout, "data.sheets.sheets").Array() {
|
||||
if gjson.Get(item.Raw, "sheet_id").String() == copiedSheetID {
|
||||
t.Fatalf("deleted sheet %s should not exist, stdout: %s", copiedSheetID, result.Stdout)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// 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/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// TestSheets_TableGetDefaultDryRun pins the request structure +table-get emits
|
||||
// when no --range is given: it must first read get_workbook_structure (to learn
|
||||
// each sheet's grid dimensions, which anchor the used-range probe over the full
|
||||
// grid) and then read cells via get_cell_ranges. This guards the pro016 / pro025
|
||||
// fix — the default read must span internal blank rows/columns, not stop at the
|
||||
// A1 current region.
|
||||
func TestSheets_TableGetDefaultDryRun(t *testing.T) {
|
||||
setSheetsDryRunEnv(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"sheets", "+table-get",
|
||||
"--spreadsheet-token", "shtDryRun",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
|
||||
// api.0 — the structure read that supplies the grid dimensions.
|
||||
require.Equal(t, "get_workbook_structure", gjson.Get(out, "api.0.body.tool_name").String(), "stdout:\n%s", out)
|
||||
require.Equal(t, "/open-apis/sheet_ai/v2/spreadsheets/shtDryRun/tools/invoke_read",
|
||||
gjson.Get(out, "api.0.url").String(), "stdout:\n%s", out)
|
||||
|
||||
// api.1 — the cells read.
|
||||
require.Equal(t, "get_cell_ranges", gjson.Get(out, "api.1.body.tool_name").String(), "stdout:\n%s", out)
|
||||
}
|
||||
|
||||
// TestSheets_TableGetSingleSheetDryRun confirms the single-sheet selector path
|
||||
// also reads get_workbook_structure now (previously it did not): the grid
|
||||
// dimensions are needed even when only one sheet is read, so the used-range
|
||||
// probe can anchor over the full grid.
|
||||
func TestSheets_TableGetSingleSheetDryRun(t *testing.T) {
|
||||
setSheetsDryRunEnv(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"sheets", "+table-get",
|
||||
"--spreadsheet-token", "shtDryRun",
|
||||
"--sheet-name", "Sheet1",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
require.Equal(t, "get_workbook_structure", gjson.Get(out, "api.0.body.tool_name").String(),
|
||||
"single-sheet path must still read the structure for grid dimensions; stdout:\n%s", out)
|
||||
require.Equal(t, "get_cell_ranges", gjson.Get(out, "api.1.body.tool_name").String(), "stdout:\n%s", out)
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// TestSheets_TableGetUsedRangeWorkflow is the live regression for the pro016 /
|
||||
// pro025 incident: data with an internal blank row (and a blank separator
|
||||
// column) must read back in full when +table-get is run without --range. Before
|
||||
// the fix, the default read used the A1 current region, which stopped at the
|
||||
// first blank row/column and silently truncated everything past it.
|
||||
//
|
||||
// Layout written (header + 9 data rows, blank row at sheet row 6, blank column
|
||||
// D between the A:C block and the E:F block):
|
||||
//
|
||||
// row 1: name age city <blank> x1 x2
|
||||
// rows 2-5: data
|
||||
// row 6: <entirely blank>
|
||||
// rows 7-10: data
|
||||
//
|
||||
// The true used range is A1:F10. The default +table-get must return all 9 data
|
||||
// rows and 6 columns and report a range covering row 10 / column F.
|
||||
func TestSheets_TableGetUsedRangeWorkflow(t *testing.T) {
|
||||
parentT := t
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
suffix := clie2e.GenerateSuffix()
|
||||
spreadsheetToken := createSpreadsheet(t, parentT, ctx, "lark-cli-e2e-tableget-"+suffix, "bot")
|
||||
|
||||
infoRes, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"sheets", "+info", "--spreadsheet-token", spreadsheetToken},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
infoRes.AssertExitCode(t, 0)
|
||||
sheetID := gjson.Get(infoRes.Stdout, "data.sheets.sheets.0.sheet_id").String()
|
||||
require.NotEmpty(t, sheetID, "sheet_id should not be empty, stdout: %s", infoRes.Stdout)
|
||||
|
||||
// Write the full A1:F10 block in one shot. Column D and row 6 are left blank
|
||||
// (empty strings) so the A1 current region would truncate at them.
|
||||
blank := ""
|
||||
values := [][]any{
|
||||
{"name", "age", "city", blank, "x1", "x2"},
|
||||
{"Alice", 30, "NY", blank, "p", "q"},
|
||||
{"Bob", 25, "LA", blank, "r", "s"},
|
||||
{"Carol", 40, "SF", blank, "t", "u"},
|
||||
{"Dave", 22, "TX", blank, "v", "w"},
|
||||
{blank, blank, blank, blank, blank, blank}, // row 6: entirely blank
|
||||
{"Eve", 33, "BOS", blank, "a", "b"},
|
||||
{"Frank", 28, "SEA", blank, "c", "d"},
|
||||
{"Grace", 45, "DEN", blank, "e", "f"},
|
||||
{"Hank", 50, "PHX", blank, "g", "h"},
|
||||
}
|
||||
valuesJSON, _ := json.Marshal(values)
|
||||
|
||||
writeRes, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"sheets", "+write",
|
||||
"--spreadsheet-token", spreadsheetToken,
|
||||
"--sheet-id", sheetID,
|
||||
"--range", "A1:F10",
|
||||
"--values", string(valuesJSON),
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
writeRes.AssertExitCode(t, 0)
|
||||
writeRes.AssertStdoutStatus(t, true)
|
||||
|
||||
t.Run("default table-get spans the internal blank row/column", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"sheets", "+table-get",
|
||||
"--spreadsheet-token", spreadsheetToken,
|
||||
"--sheet-id", sheetID,
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
sheet := gjson.Get(result.Stdout, "data.sheets.0")
|
||||
rows := sheet.Get("data").Array()
|
||||
require.Equal(t, 9, len(rows),
|
||||
"default table-get must return all 9 data rows (not truncate at the blank row 6); stdout:\n%s", result.Stdout)
|
||||
|
||||
// The last data row must be present — the regression dropped everything
|
||||
// after the blank row.
|
||||
lastRow := rows[len(rows)-1].Array()
|
||||
assert.Equal(t, "Hank", lastRow[0].String(), "last data row should be Hank; stdout:\n%s", result.Stdout)
|
||||
|
||||
// Columns must span past the blank separator column D to reach x1 / x2.
|
||||
cols := sheet.Get("columns").Array()
|
||||
require.Equal(t, 6, len(cols), "must read all 6 columns across the blank column D; stdout:\n%s", result.Stdout)
|
||||
assert.Equal(t, "x1", cols[4].String())
|
||||
assert.Equal(t, "x2", cols[5].String())
|
||||
|
||||
// The reported range must cover the true used range (row 10, column F),
|
||||
// so a caller can detect truncation by inspecting it.
|
||||
rng := sheet.Get("range").String()
|
||||
require.NotEmpty(t, rng, "table-get output must report the range actually read; stdout:\n%s", result.Stdout)
|
||||
assert.Contains(t, rng, "10", "reported range should reach row 10; got %q", rng)
|
||||
assert.Contains(t, rng, "F", "reported range should reach column F; got %q", rng)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// TestSheets_TablePutStylesDryRun pins the request structure +table-put emits
|
||||
// when --styles is supplied: the set_cell_range write carries cell_styles merged
|
||||
// into the matrix, and the structural styles (merge / resize) render as their
|
||||
// own invoke_write tool calls afterward — the same shape +workbook-create uses.
|
||||
func TestSheets_TablePutStylesDryRun(t *testing.T) {
|
||||
setSheetsDryRunEnv(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"sheets", "+table-put",
|
||||
"--spreadsheet-token", "shtDryRun",
|
||||
"--sheets", `{"sheets":[{"name":"数据","columns":["a","b"],"data":[["x","y"]]}]}`,
|
||||
"--styles", `{"styles":[{"name":"数据","cell_styles":[{"range":"A1:B1","font_weight":"bold"}],"cell_merges":[{"range":"A1:B1"}],"col_sizes":[{"range":"A:A","type":"pixel","size":120}]}]}`,
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
|
||||
// api.0 — the typed write, with cell_styles merged into the cells matrix.
|
||||
require.Equal(t, "POST", gjson.Get(out, "api.0.method").String(), "stdout:\n%s", out)
|
||||
require.Equal(t, "/open-apis/sheet_ai/v2/spreadsheets/shtDryRun/tools/invoke_write",
|
||||
gjson.Get(out, "api.0.url").String(), "stdout:\n%s", out)
|
||||
require.Equal(t, "set_cell_range", gjson.Get(out, "api.0.body.tool_name").String(), "stdout:\n%s", out)
|
||||
firstInput := gjson.Get(out, "api.0.body.input").String()
|
||||
require.Contains(t, firstInput, `"font_weight":"bold"`, "cell_styles should merge into the matrix; input:\n%s", firstInput)
|
||||
require.Contains(t, firstInput, `"range":"A1:B2"`, "write range should cover header + data; input:\n%s", firstInput)
|
||||
|
||||
// api.1 — the merge op.
|
||||
require.Equal(t, "merge_cells", gjson.Get(out, "api.1.body.tool_name").String(), "stdout:\n%s", out)
|
||||
require.Contains(t, gjson.Get(out, "api.1.body.input").String(), `"range":"A1:B1"`, "stdout:\n%s", out)
|
||||
|
||||
// api.2 — the column resize.
|
||||
require.Equal(t, "resize_range", gjson.Get(out, "api.2.body.tool_name").String(), "stdout:\n%s", out)
|
||||
require.Contains(t, gjson.Get(out, "api.2.body.input").String(), `"type":"pixel"`, "stdout:\n%s", out)
|
||||
}
|
||||
|
||||
// TestSheets_TablePutStylesNameMismatchRejected confirms a --styles item whose
|
||||
// name does not match the --sheets payload sheet is rejected up front (no write
|
||||
// lands), so a typo surfaces as a validation error rather than a silent skip.
|
||||
func TestSheets_TablePutStylesNameMismatchRejected(t *testing.T) {
|
||||
setSheetsDryRunEnv(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"sheets", "+table-put",
|
||||
"--spreadsheet-token", "shtDryRun",
|
||||
"--sheets", `{"sheets":[{"name":"数据","columns":["a"],"data":[["x"]]}]}`,
|
||||
"--styles", `{"styles":[{"name":"其他","cell_styles":[{"range":"A1","font_weight":"bold"}]}]}`,
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 2)
|
||||
combined := result.Stdout + "\n" + result.Stderr
|
||||
if !strings.Contains(combined, "must match") {
|
||||
t.Fatalf("expected name-mismatch error, got:\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/larksuite/cli/tests/cli_e2e/drive"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// TestSheets_TablePutTypedWorkflow is the live regression for the typed
|
||||
// +table-put write path added in this branch. AGENTS.md requires a live E2E
|
||||
// for new flows; this one writes a small typed payload (date / int / string
|
||||
// columns + number_format) to a real spreadsheet and verifies +table-get reads
|
||||
// it back as the same typed shape, locking the dtype + format contract that
|
||||
// makes round-trip (pipe +table-get into +table-put) work.
|
||||
func TestSheets_TablePutTypedWorkflow(t *testing.T) {
|
||||
parentT := t
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
suffix := clie2e.GenerateSuffix()
|
||||
spreadsheetToken := createSpreadsheet(t, parentT, ctx, "lark-cli-e2e-tableput-typed-"+suffix, "bot")
|
||||
|
||||
// Write a 3-row typed table whose first column is a date, second is an
|
||||
// int64 numeric column with a custom number_format, third is a plain
|
||||
// string. The "Sheet1" name comes from createSpreadsheet's default sheet.
|
||||
putRes, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"sheets", "+table-put",
|
||||
"--spreadsheet-token", spreadsheetToken,
|
||||
"--sheets", `{"sheets":[{"name":"Sheet1","columns":["日期","数量","备注"],"dtypes":{"日期":"datetime64[ns]","数量":"int64","备注":"object"},"formats":{"数量":"#,##0","日期":"yyyy-mm-dd"},"data":[["2024-01-15",1500,"开张"],["2024-02-02",2300,"补货"],["2024-03-10",4200,"促销"]]}]}`,
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
putRes.AssertExitCode(t, 0)
|
||||
putRes.AssertStdoutStatus(t, true)
|
||||
require.Equal(t, int64(3), gjson.Get(putRes.Stdout, "data.sheets.0.data_rows").Int(),
|
||||
"data_rows should reflect the 3-row payload; stdout:\n%s", putRes.Stdout)
|
||||
|
||||
// Read it back via +table-get and confirm the typed contract held: the
|
||||
// date column became a real date (datetime64[ns]) with the format we
|
||||
// asked for, the numeric column kept its int64 dtype and #,##0 format,
|
||||
// and the string column landed as object. Numeric values must come back
|
||||
// as numbers (not the formatted display string).
|
||||
getRes, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"sheets", "+table-get",
|
||||
"--spreadsheet-token", spreadsheetToken,
|
||||
"--sheet-name", "Sheet1",
|
||||
"--range", "A1:C4",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
getRes.AssertExitCode(t, 0)
|
||||
|
||||
out := getRes.Stdout
|
||||
require.Equal(t, "datetime64[ns]", gjson.Get(out, "data.sheets.0.dtypes.日期").String(),
|
||||
"date column should round-trip as datetime64[ns]; stdout:\n%s", out)
|
||||
// The backend does not distinguish int64 from float64 in the typed wire,
|
||||
// so an int column written as int64 reads back as float64. Both are
|
||||
// numeric and that's the only contract a CLI agent should rely on.
|
||||
numericDtype := gjson.Get(out, "data.sheets.0.dtypes.数量").String()
|
||||
require.Regexp(t, `^(int|float)\d+$|^Int64$`, numericDtype,
|
||||
"numeric column should round-trip with a numeric dtype (int*/float*/Int64), got %q; stdout:\n%s", numericDtype, out)
|
||||
require.Equal(t, "object", gjson.Get(out, "data.sheets.0.dtypes.备注").String(),
|
||||
"string column should round-trip as object; stdout:\n%s", out)
|
||||
require.Equal(t, "#,##0", gjson.Get(out, "data.sheets.0.formats.数量").String(),
|
||||
"numeric column's number_format should round-trip; stdout:\n%s", out)
|
||||
require.Equal(t, "2024-01-15", gjson.Get(out, "data.sheets.0.data.0.0").String(),
|
||||
"first date should come back as the ISO string written; stdout:\n%s", out)
|
||||
require.Equal(t, int64(1500), gjson.Get(out, "data.sheets.0.data.0.1").Int(),
|
||||
"numeric value should round-trip as a number, not the formatted display; stdout:\n%s", out)
|
||||
}
|
||||
|
||||
// TestSheets_WorkbookCreateTypedWorkflow is the live regression for the typed
|
||||
// +workbook-create --sheets path added in this branch: it bundles "create
|
||||
// spreadsheet" + "write typed data" into one shortcut, adopting the new
|
||||
// workbook's default sheet as the first payload sheet. The test confirms the
|
||||
// adopted sheet carries the typed data we sent (no empty "Sheet1" remains)
|
||||
// and that --sheets's typed contract holds end-to-end, not just on +table-put.
|
||||
func TestSheets_WorkbookCreateTypedWorkflow(t *testing.T) {
|
||||
parentT := t
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
suffix := clie2e.GenerateSuffix()
|
||||
title := "lark-cli-e2e-wb-create-typed-" + suffix
|
||||
|
||||
// One-shot: create workbook + write typed payload (date + int + string).
|
||||
// --folder-token is optional; omit it so the test does not depend on drive:drive
|
||||
// (CreateDriveFolder) when validating the typed --sheets path.
|
||||
createRes, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"sheets", "+workbook-create",
|
||||
"--title", title,
|
||||
"--sheets", `{"sheets":[{"name":"销售","columns":["日期","金额","渠道"],"dtypes":{"日期":"datetime64[ns]","金额":"float64","渠道":"object"},"formats":{"金额":"$#,##0.00","日期":"yyyy-mm-dd"},"data":[["2024-01-15",1500.5,"门店"],["2024-02-02",2300.75,"线上"]]}]}`,
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
if createRes.ExitCode != 0 {
|
||||
combined := strings.ToLower(createRes.Stdout + "\n" + createRes.Stderr)
|
||||
if strings.Contains(combined, "app_scope_not_applied") ||
|
||||
strings.Contains(combined, "missing_scopes") ||
|
||||
strings.Contains(combined, "99991672") {
|
||||
t.Skipf("skip workbook-create typed workflow due to missing bot scope: %s", strings.TrimSpace(createRes.Stdout+"\n"+createRes.Stderr))
|
||||
}
|
||||
}
|
||||
createRes.AssertExitCode(t, 0)
|
||||
createRes.AssertStdoutStatus(t, true)
|
||||
|
||||
spreadsheetToken := gjson.Get(createRes.Stdout, "data.spreadsheet.spreadsheet_token").String()
|
||||
require.NotEmpty(t, spreadsheetToken, "workbook-create should return a spreadsheet_token; stdout:\n%s", createRes.Stdout)
|
||||
|
||||
parentT.Cleanup(func() {
|
||||
cleanupCtx, cancelCleanup := clie2e.CleanupContext()
|
||||
defer cancelCleanup()
|
||||
deleteResult, deleteErr := drive.DeleteDriveResourceAndVerify(cleanupCtx, spreadsheetToken, "sheet", "bot")
|
||||
clie2e.ReportCleanupFailure(parentT, "delete spreadsheet "+spreadsheetToken, deleteResult, deleteErr)
|
||||
})
|
||||
|
||||
// Adopted sheet must be the one we named in the payload (NOT an empty
|
||||
// default Sheet1), and it must carry our 2 typed rows.
|
||||
require.Equal(t, "销售", gjson.Get(createRes.Stdout, "data.sheets.0.name").String(),
|
||||
"workbook-create should adopt the default sheet under the payload sheet name; stdout:\n%s", createRes.Stdout)
|
||||
|
||||
// Round-trip read confirms the typed contract held through create+write.
|
||||
getRes, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"sheets", "+table-get",
|
||||
"--spreadsheet-token", spreadsheetToken,
|
||||
"--sheet-name", "销售",
|
||||
"--range", "A1:C3",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
getRes.AssertExitCode(t, 0)
|
||||
|
||||
out := getRes.Stdout
|
||||
require.Equal(t, "datetime64[ns]", gjson.Get(out, "data.sheets.0.dtypes.日期").String(),
|
||||
"date dtype should survive workbook-create + read-back; stdout:\n%s", out)
|
||||
require.Equal(t, "float64", gjson.Get(out, "data.sheets.0.dtypes.金额").String(),
|
||||
"numeric dtype should survive workbook-create + read-back; stdout:\n%s", out)
|
||||
require.Equal(t, "$#,##0.00", gjson.Get(out, "data.sheets.0.formats.金额").String(),
|
||||
"currency format should survive workbook-create + read-back; stdout:\n%s", out)
|
||||
require.Equal(t, "2024-01-15", gjson.Get(out, "data.sheets.0.data.0.0").String(),
|
||||
"first date should come back as ISO string; stdout:\n%s", out)
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
// 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/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// TestSheets_WorkbookExportDryRun pins the +workbook-export dry-run shape. It
|
||||
// delegates to the shared drive export core but adds three sheet-specific
|
||||
// guarantees that downstream agents rely on:
|
||||
//
|
||||
// 1. The doc type is hard-coded to "sheet" (drive +export would require
|
||||
// --doc-type sheet explicitly).
|
||||
// 2. csv mode routes the --sheet-id flag onto the export_tasks body as
|
||||
// sub_id; xlsx mode omits sub_id.
|
||||
// 3. The single --output-path flag collapses drive +export's --output-dir +
|
||||
// --file-name pair onto the dry-run plan's output_dir / file_name extras.
|
||||
func TestSheets_WorkbookExportDryRun(t *testing.T) {
|
||||
t.Run("xlsx", func(t *testing.T) {
|
||||
setSheetsDryRunEnv(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"sheets", "+workbook-export",
|
||||
"--spreadsheet-token", "shtDryRunExport",
|
||||
"--file-extension", "xlsx",
|
||||
"--output-path", "./out.xlsx",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
require.Equal(t, "POST", gjson.Get(out, "api.0.method").String(), "stdout:\n%s", out)
|
||||
require.Equal(t, "/open-apis/drive/v1/export_tasks",
|
||||
gjson.Get(out, "api.0.url").String(), "stdout:\n%s", out)
|
||||
require.Equal(t, "shtDryRunExport", gjson.Get(out, "api.0.body.token").String(), "stdout:\n%s", out)
|
||||
require.Equal(t, "sheet", gjson.Get(out, "api.0.body.type").String(),
|
||||
"workbook-export must hard-code type=sheet; stdout:\n%s", out)
|
||||
require.Equal(t, "xlsx", gjson.Get(out, "api.0.body.file_extension").String(), "stdout:\n%s", out)
|
||||
require.False(t, gjson.Get(out, "api.0.body.sub_id").Exists(),
|
||||
"sub_id should be absent in xlsx mode; stdout:\n%s", out)
|
||||
require.Equal(t, "./out.xlsx", gjson.Get(out, "output_dir").String(),
|
||||
"--output-path carries through to the dry-run plan's top-level output_dir; stdout:\n%s", out)
|
||||
})
|
||||
|
||||
t.Run("csv requires sheet-id and emits sub_id", func(t *testing.T) {
|
||||
setSheetsDryRunEnv(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"sheets", "+workbook-export",
|
||||
"--spreadsheet-token", "shtDryRunExport",
|
||||
"--file-extension", "csv",
|
||||
"--sheet-id", "sheet1",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
require.Equal(t, "csv", gjson.Get(out, "api.0.body.file_extension").String(), "stdout:\n%s", out)
|
||||
require.Equal(t, "sheet1", gjson.Get(out, "api.0.body.sub_id").String(),
|
||||
"--sheet-id must reach sub_id in csv mode; stdout:\n%s", out)
|
||||
})
|
||||
|
||||
t.Run("csv without sheet-id is rejected", func(t *testing.T) {
|
||||
setSheetsDryRunEnv(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"sheets", "+workbook-export",
|
||||
"--spreadsheet-token", "shtDryRunExport",
|
||||
"--file-extension", "csv",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotEqual(t, 0, result.ExitCode,
|
||||
"csv export without --sheet-id should surface a validation error; stdout:\n%s\nstderr:\n%s",
|
||||
result.Stdout, result.Stderr)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// TestSheets_WorkbookImportDryRun pins the +workbook-import dry-run shape: a
|
||||
// two-step plan that uploads the local file (drive media upload) and creates
|
||||
// an import task with the doc type pinned to "sheet". This is the new shortcut
|
||||
// added in this branch — distinct from generic drive +import because it
|
||||
// hard-codes type=sheet and uses --name instead of --file-name. AGENTS.md
|
||||
// requires a dry-run E2E to lock the request shape before a live run.
|
||||
func TestSheets_WorkbookImportDryRun(t *testing.T) {
|
||||
setSheetsDryRunEnv(t)
|
||||
|
||||
// CLI sandbox only accepts relative file paths under cwd; write the CSV
|
||||
// into a TempDir and hand RunCmd that as WorkDir so --file resolves.
|
||||
dir := t.TempDir()
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "data.csv"), []byte("a,b\n1,2\n"), 0o644))
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"sheets", "+workbook-import",
|
||||
"--file", "data.csv",
|
||||
"--name", "imported",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
WorkDir: dir,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
|
||||
// api.0 — upload file to obtain the file_token; the wrapper sets
|
||||
// obj_type=sheet in extra so the upload is scoped for sheet import.
|
||||
require.Equal(t, "POST", gjson.Get(out, "api.0.method").String(), "stdout:\n%s", out)
|
||||
require.Equal(t, "/open-apis/drive/v1/medias/upload_all",
|
||||
gjson.Get(out, "api.0.url").String(), "stdout:\n%s", out)
|
||||
require.Contains(t, gjson.Get(out, "api.0.body.extra").String(), `"obj_type":"sheet"`,
|
||||
"upload extra should pin obj_type=sheet; stdout:\n%s", out)
|
||||
require.Equal(t, "ccm_import_open", gjson.Get(out, "api.0.body.parent_type").String(),
|
||||
"stdout:\n%s", out)
|
||||
|
||||
// api.1 — create import task. type=sheet is the wrapper's whole reason for
|
||||
// existing (drive +import would require --doc-type sheet explicitly);
|
||||
// --name reaches the wire as file_name; file_extension is sniffed from
|
||||
// the local file (.csv).
|
||||
require.Equal(t, "POST", gjson.Get(out, "api.1.method").String(), "stdout:\n%s", out)
|
||||
require.Equal(t, "/open-apis/drive/v1/import_tasks",
|
||||
gjson.Get(out, "api.1.url").String(), "stdout:\n%s", out)
|
||||
require.Equal(t, "sheet", gjson.Get(out, "api.1.body.type").String(),
|
||||
"workbook-import must hard-code type=sheet; stdout:\n%s", out)
|
||||
require.Equal(t, "imported", gjson.Get(out, "api.1.body.file_name").String(),
|
||||
"--name should reach file_name; stdout:\n%s", out)
|
||||
require.Equal(t, "csv", gjson.Get(out, "api.1.body.file_extension").String(),
|
||||
"file_extension sniffed from .csv; stdout:\n%s", out)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/larksuite/cli/tests/cli_e2e/drive"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// TestSheets_WorkbookImportWorkflow round-trips +workbook-import end to end:
|
||||
// write a local CSV → import as a new Feishu spreadsheet → assert the import
|
||||
// task finished (ready=true) with a sheet token → +info confirms the new
|
||||
// workbook is reachable → cleanup deletes the spreadsheet.
|
||||
//
|
||||
// The dry-run E2E in sheets_workbook_import_dryrun_test.go pins the two-step
|
||||
// request shape (media upload + import task with type=sheet); this live test
|
||||
// validates the full flow including the async poll and that the resulting
|
||||
// token is a usable sheet token.
|
||||
func TestSheets_WorkbookImportWorkflow(t *testing.T) {
|
||||
parentT := t
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
// CLI sandbox only accepts relative file paths under cwd; write the CSV
|
||||
// into a TempDir and hand RunCmd that as WorkDir so --file resolves.
|
||||
dir := t.TempDir()
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "data.csv"),
|
||||
[]byte("Name,Age,City\nAlice,25,Beijing\nBob,30,Shanghai\n"), 0o644))
|
||||
|
||||
suffix := clie2e.GenerateSuffix()
|
||||
title := "lark-cli-e2e-sheets-import-" + suffix
|
||||
folderToken := drive.CreateDriveFolder(t, parentT, ctx, title+"-folder", "bot", "")
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"sheets", "+workbook-import",
|
||||
"--file", "data.csv",
|
||||
"--name", title,
|
||||
"--folder-token", folderToken,
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
WorkDir: dir,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
require.True(t, gjson.Get(result.Stdout, "data.ready").Bool(),
|
||||
"import task should be ready within poll window; stdout:\n%s", result.Stdout)
|
||||
assert.Equal(t, "sheet", gjson.Get(result.Stdout, "data.type").String(),
|
||||
"workbook-import hard-codes type=sheet; stdout:\n%s", result.Stdout)
|
||||
spreadsheetToken := gjson.Get(result.Stdout, "data.token").String()
|
||||
require.NotEmpty(t, spreadsheetToken, "spreadsheet token should not be empty; stdout:\n%s", result.Stdout)
|
||||
|
||||
parentT.Cleanup(func() {
|
||||
cleanupCtx, cleanupCancel := clie2e.CleanupContext()
|
||||
defer cleanupCancel()
|
||||
deleteResult, deleteErr := drive.DeleteDriveResourceAndVerify(cleanupCtx, spreadsheetToken, "sheet", "bot")
|
||||
clie2e.ReportCleanupFailure(parentT, "delete imported spreadsheet "+spreadsheetToken, deleteResult, deleteErr)
|
||||
})
|
||||
|
||||
// Sanity: the imported token resolves through the sheets read path.
|
||||
infoResult, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"sheets", "+info", "--spreadsheet-token", spreadsheetToken},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
infoResult.AssertExitCode(t, 0)
|
||||
infoResult.AssertStdoutStatus(t, true)
|
||||
assert.True(t, gjson.Get(infoResult.Stdout, "data.sheets.sheets.0.sheet_id").Exists(),
|
||||
"imported workbook should expose at least one sub-sheet; stdout:\n%s", infoResult.Stdout)
|
||||
}
|
||||
Reference in New Issue
Block a user