From ca8bf48851f7dd04db357ec6d6e99cd05eafbbfc Mon Sep 17 00:00:00 2001 From: xiongyuanwen-byted Date: Wed, 24 Jun 2026 16:03:21 +0800 Subject: [PATCH] test(sheets/e2e): add E2E coverage for new shortcuts + typed workbook-create MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../sheets/sheets_gridline_dryrun_test.go | 62 ++++++++ .../sheets_table_put_typed_workflow_test.go | 149 ++++++++++++++++++ .../sheets_workbook_import_dryrun_test.go | 73 +++++++++ 3 files changed, 284 insertions(+) create mode 100644 tests/cli_e2e/sheets/sheets_gridline_dryrun_test.go create mode 100644 tests/cli_e2e/sheets/sheets_table_put_typed_workflow_test.go create mode 100644 tests/cli_e2e/sheets/sheets_workbook_import_dryrun_test.go diff --git a/tests/cli_e2e/sheets/sheets_gridline_dryrun_test.go b/tests/cli_e2e/sheets/sheets_gridline_dryrun_test.go new file mode 100644 index 00000000..4541c15c --- /dev/null +++ b/tests/cli_e2e/sheets/sheets_gridline_dryrun_test.go @@ -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) + }) + } +} diff --git a/tests/cli_e2e/sheets/sheets_table_put_typed_workflow_test.go b/tests/cli_e2e/sheets/sheets_table_put_typed_workflow_test.go new file mode 100644 index 00000000..9bf23583 --- /dev/null +++ b/tests/cli_e2e/sheets/sheets_table_put_typed_workflow_test.go @@ -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) +} diff --git a/tests/cli_e2e/sheets/sheets_workbook_import_dryrun_test.go b/tests/cli_e2e/sheets/sheets_workbook_import_dryrun_test.go new file mode 100644 index 00000000..92bc32d6 --- /dev/null +++ b/tests/cli_e2e/sheets/sheets_workbook_import_dryrun_test.go @@ -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) +}