mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
test(sheets/e2e): add E2E coverage for new shortcuts + typed workbook-create
AGENTS.md requires a dry-run E2E for every new shortcut and a live E2E for new flows. Three new files cover the four shortcuts this branch adds or materially changes: - sheets_gridline_dryrun_test.go — pins +sheet-show-gridline / +sheet-hide-gridline as a single modify_workbook_structure call with the right operation name (show_gridline / hide_gridline) and sheet_id, so an op-name typo would trip CI before any live run. - sheets_workbook_import_dryrun_test.go — pins +workbook-import as a two-step plan (drive media upload + drive import-task create) with the doc type hard-coded to "sheet" — the wrapper's whole reason for existing on top of generic drive +import. --name reaches file_name on the wire; file_extension is sniffed from the local file. - sheets_table_put_typed_workflow_test.go — two live workflows running against a freshly created spreadsheet. The first runs the full typed +table-put → +table-get round-trip (date / numeric / object columns with custom number_format) and asserts the dtype + format contract holds end-to-end. The second exercises the typed +workbook-create --sheets path: create + write in one shortcut, the payload sheet name adopts the workbook's default sheet (no empty "Sheet1" left behind), and the typed contract still survives the read-back. End-to-end verified locally (user identity): typed put round-trips preserve dtypes (date → datetime64[ns], numeric → float64, object → object) + formats verbatim; workbook-create adopts the named sheet as the first sheet with the same typed shape intact.
This commit is contained in:
62
tests/cli_e2e/sheets/sheets_gridline_dryrun_test.go
Normal file
62
tests/cli_e2e/sheets/sheets_gridline_dryrun_test.go
Normal file
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
149
tests/cli_e2e/sheets/sheets_table_put_typed_workflow_test.go
Normal file
149
tests/cli_e2e/sheets/sheets_table_put_typed_workflow_test.go
Normal file
@@ -0,0 +1,149 @@
|
||||
// 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/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()
|
||||
folderToken := drive.CreateDriveFolder(t, parentT, ctx, "lark-cli-e2e-wb-create-typed-"+suffix+"-folder", "bot", "")
|
||||
|
||||
// One-shot: create workbook + write typed payload (date + int + string).
|
||||
createRes, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"sheets", "+workbook-create",
|
||||
"--title", "lark-cli-e2e-wb-create-typed-" + suffix,
|
||||
"--folder-token", folderToken,
|
||||
"--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)
|
||||
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)
|
||||
}
|
||||
73
tests/cli_e2e/sheets/sheets_workbook_import_dryrun_test.go
Normal file
73
tests/cli_e2e/sheets/sheets_workbook_import_dryrun_test.go
Normal file
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user