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:
xiongyuanwen-byted
2026-06-24 16:03:21 +08:00
parent 79362a8fe8
commit ca8bf48851
3 changed files with 284 additions and 0 deletions

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

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

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