Files
dingtalk-workspace-cli/internal/helpers/aitable_commands_test.go
修雨 4c5f1faeb1 feat: align open CLI with dws-wukong via cedar discovery version code (#509)
Switches the discovery version code bamboo -> cedar and aligns the open edition CLI with dws-wukong across communication (calendar book/acl/attendee, minutes tag, mail folder/template/contact, chat file upload, todo add-attachment, attendance transforms) and structured-office (aitable advperm/view/section/workflow/record, sheet/drive/wiki/doc) domains. Includes output-envelope parity, parse_bool/attendance_class_check_time transforms, --calendar-id support, CHANGELOG 1.0.43 and README command-index refresh. cedar config validated on pre and prod endpoints.
2026-06-26 21:49:38 +08:00

1448 lines
41 KiB
Go

package helpers
import (
"bytes"
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/asynctask"
"github.com/spf13/cobra"
)
type aitableCommandRunner struct {
last executor.Invocation
}
func (r *aitableCommandRunner) Run(_ context.Context, invocation executor.Invocation) (executor.Result, error) {
r.last = invocation
return executor.Result{Invocation: invocation}, nil
}
type aitableRetryProbeRunner struct {
calls int
result executor.Result
err error
}
func (r *aitableRetryProbeRunner) Run(_ context.Context, invocation executor.Invocation) (executor.Result, error) {
r.calls++
r.result.Invocation = invocation
return r.result, r.err
}
func newAitableRetryTestCommand() *cobra.Command {
var out, errOut bytes.Buffer
cmd := &cobra.Command{Use: "test"}
cmd.SetOut(&out)
cmd.SetErr(&errOut)
return cmd
}
func TestAitableBaseGetAcceptsCompatibilityAliases(t *testing.T) {
t.Parallel()
cases := []struct {
name string
args []string
wantID string
}{
{name: "base", args: []string{"--base", "BASE_001"}, wantID: "BASE_001"},
{name: "id", args: []string{"--id", "BASE_002"}, wantID: "BASE_002"},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
runner := &aitableCommandRunner{}
cmd := newAitableBaseGetCommand(runner)
var out, errOut bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetArgs(tc.args)
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
}
if runner.last.Tool != "get_base" {
t.Fatalf("tool = %q, want get_base", runner.last.Tool)
}
if got := runner.last.Params["baseId"]; got != tc.wantID {
t.Fatalf("baseId = %#v, want %q", got, tc.wantID)
}
})
}
}
func TestAitableBaseSearchWithoutQueryListsBases(t *testing.T) {
t.Parallel()
runner := &aitableCommandRunner{}
cmd := newAitableBaseSearchCommand(runner)
var out, errOut bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetArgs([]string{"--cursor", "CURSOR_001"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
}
if runner.last.Tool != "list_bases" {
t.Fatalf("tool = %q, want list_bases", runner.last.Tool)
}
if got := runner.last.Params["cursor"]; got != "CURSOR_001" {
t.Fatalf("cursor = %#v, want CURSOR_001", got)
}
if _, ok := runner.last.Params["query"]; ok {
t.Fatalf("unexpected query param in %#v", runner.last.Params)
}
}
func TestAitableBaseCopyBuildsWukongPayload(t *testing.T) {
t.Parallel()
runner := &aitableCommandRunner{}
cmd := newAitableBaseCopyCommand(runner)
var out, errOut bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetArgs([]string{
"--base-id", "BASE_001",
"--target-folder-id", "FOLDER_001",
"--only-struct",
})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
}
if runner.last.Tool != "copy_base" {
t.Fatalf("tool = %q, want copy_base", runner.last.Tool)
}
if got := runner.last.Params["baseId"]; got != "BASE_001" {
t.Fatalf("baseId = %#v, want BASE_001", got)
}
if got := runner.last.Params["targetFolderId"]; got != "FOLDER_001" {
t.Fatalf("targetFolderId = %#v, want FOLDER_001", got)
}
if got := runner.last.Params["onlyCopyMeta"]; got != true {
t.Fatalf("onlyCopyMeta = %#v, want true", got)
}
}
func TestAitableBusinessErrorDoesNotRetry(t *testing.T) {
t.Parallel()
runner := &aitableRetryProbeRunner{err: errors.New("invalid params")}
cmd := newAitableRetryTestCommand()
err := runAitableProductTool(cmd, runner, "aitable", "create_view", map[string]any{"baseId": "BASE"})
if err == nil {
t.Fatal("runAitableProductTool error = nil, want invalid params")
}
if runner.calls != 1 {
t.Fatalf("runner calls = %d, want 1", runner.calls)
}
}
func TestAitableSuccessResponseRetryableFieldDoesNotRetry(t *testing.T) {
t.Parallel()
runner := &aitableRetryProbeRunner{
result: executor.Result{
Response: map[string]any{
"result": map[string]any{
"retryable": true,
},
},
},
}
cmd := newAitableRetryTestCommand()
if err := runAitableProductTool(cmd, runner, "aitable", "query_records", map[string]any{"baseId": "BASE"}); err != nil {
t.Fatalf("runAitableProductTool error = %v", err)
}
if runner.calls != 1 {
t.Fatalf("runner calls = %d, want 1", runner.calls)
}
}
func TestAitableExportDataDryRunUsesWukongFormatFlags(t *testing.T) {
t.Parallel()
cmd := newAitableExportDataCommand(nil)
cmd.Flags().Bool("dry-run", true, "dry run")
var out, errOut bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetArgs([]string{
"--base-id", "BASE_001",
"--scope", "table",
"--table-id", "TBL_001",
"--format", "excel",
"--timeout-ms", "1000",
})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
}
var inv executor.Invocation
if err := json.Unmarshal(out.Bytes(), &inv); err != nil {
t.Fatalf("output JSON parse error = %v\nstdout:\n%s", err, out.String())
}
if inv.Tool != "export_data" {
t.Fatalf("tool = %q, want export_data", inv.Tool)
}
if got := inv.Params["format"]; got != "excel" {
t.Fatalf("format param = %#v, want excel", got)
}
if got := inv.Params["tableId"]; got != "TBL_001" {
t.Fatalf("tableId param = %#v, want TBL_001", got)
}
if got := inv.Params["timeoutMs"]; got != float64(1000) {
t.Fatalf("timeoutMs param = %#v, want 1000", got)
}
if _, ok := inv.Params["exportFormat"]; ok {
t.Fatalf("unexpected exportFormat param in %#v", inv.Params)
}
if _, ok := inv.Params["__async__"]; ok {
t.Fatalf("unexpected __async__ param in %#v", inv.Params)
}
if _, ok := inv.Params["__output__"]; ok {
t.Fatalf("unexpected __output__ param in %#v", inv.Params)
}
}
func TestAitableExportDataRequiresFormatWhenCreatingTask(t *testing.T) {
t.Parallel()
runner := &aitableCommandRunner{}
cmd := newAitableExportDataCommand(runner)
var out, errOut bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetArgs([]string{
"--base-id", "BASE_001",
"--scope", "table",
"--table-id", "TBL_001",
})
if err := cmd.Execute(); err == nil {
t.Fatal("Execute() error = nil, want missing --format validation")
}
if runner.last.Tool != "" {
t.Fatalf("tool = %q, want no invocation", runner.last.Tool)
}
}
func TestParseAitableExportQueryResultUnwrapsResultPayload(t *testing.T) {
t.Parallel()
res := parseAitableExportQueryResult(map[string]any{
"content": map[string]any{
"success": true,
"result": map[string]any{
"status": "SUCCESS",
"downloadUrl": "https://example.com/export.xlsx",
"fileName": "export.xlsx",
},
},
})
if res.Status != asynctask.StatusSuccess {
t.Fatalf("status = %s, want SUCCESS", res.Status)
}
if res.DownloadURL != "https://example.com/export.xlsx" {
t.Fatalf("download url = %q", res.DownloadURL)
}
if got := firstAitableString(res.Raw, "fileName"); got != "export.xlsx" {
t.Fatalf("fileName = %q, want export.xlsx", got)
}
}
func TestParseAitableExportQueryResultRequiresDownloadURLForSuccess(t *testing.T) {
t.Parallel()
res := parseAitableExportQueryResult(map[string]any{
"result": map[string]any{
"status": "SUCCESS",
"taskId": "TASK_001",
},
})
if res.Status != asynctask.StatusProcessing {
t.Fatalf("status = %s, want PROCESSING", res.Status)
}
}
func TestAitableExportDataLiveCallsExportDataOnce(t *testing.T) {
t.Parallel()
runner := &aitableRetryProbeRunner{
result: executor.Result{
Response: map[string]any{
"content": map[string]any{
"result": map[string]any{
"status": "PROCESSING",
"taskId": "TASK_001",
},
},
},
},
}
cmd := newAitableExportDataCommand(runner)
var out, errOut bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetArgs([]string{
"--base-id", "BASE_001",
"--scope", "table",
"--table-id", "TBL_001",
"--format", "excel",
"--timeout-ms", "1000",
})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
}
if runner.calls != 1 {
t.Fatalf("runner calls = %d, want 1", runner.calls)
}
if got := runner.result.Invocation.Params["format"]; got != "excel" {
t.Fatalf("format param = %#v, want excel", got)
}
if got := runner.result.Invocation.Params["timeoutMs"]; got != 1000 {
t.Fatalf("timeoutMs param = %#v, want 1000", got)
}
if got := runner.result.Invocation.Tool; got != "export_data" {
t.Fatalf("tool = %q, want export_data", got)
}
}
func TestAitableImportUploadBuildsWukongPayload(t *testing.T) {
t.Parallel()
runner := &aitableCommandRunner{}
cmd := newAitableImportUploadCommand(runner)
var out, errOut bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetArgs([]string{
"--base-id", "BASE_001",
"--file-name", "data.csv",
"--file-size", "12",
})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
}
if runner.last.Tool != "prepare_import_upload" {
t.Fatalf("tool = %q, want prepare_import_upload", runner.last.Tool)
}
if got := runner.last.Params["baseId"]; got != "BASE_001" {
t.Fatalf("baseId = %#v, want BASE_001", got)
}
if got := runner.last.Params["fileName"]; got != "data.csv" {
t.Fatalf("fileName = %#v, want data.csv", got)
}
if got := runner.last.Params["fileSize"]; got != int64(12) {
t.Fatalf("fileSize = %#v, want int64(12)", got)
}
}
func TestAitableImportDataDryRunAcceptsWukongTimeout(t *testing.T) {
t.Parallel()
cmd := newAitableImportDataCommand(nil)
cmd.Flags().Bool("dry-run", true, "dry run")
var out, errOut bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetArgs([]string{
"--import-id", "IMPORT_001",
"--timeout", "30",
})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
}
var inv executor.Invocation
if err := json.Unmarshal(out.Bytes(), &inv); err != nil {
t.Fatalf("output JSON parse error = %v\nstdout:\n%s", err, out.String())
}
if inv.Tool != "import_data" {
t.Fatalf("tool = %q, want import_data", inv.Tool)
}
if got := inv.Params["timeout"]; got != float64(30) {
t.Fatalf("timeout param = %#v, want 30", got)
}
if _, ok := inv.Params["__async__"]; ok {
t.Fatalf("unexpected __async__ param in %#v", inv.Params)
}
}
func TestAitableImportDataDryRunPassesWukongImportOptions(t *testing.T) {
t.Parallel()
cmd := newAitableImportDataCommand(nil)
cmd.Flags().Bool("dry-run", true, "dry run")
var out, errOut bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetArgs([]string{
"--import-id", "IMPORT_001",
"--table-id", "TBL_001",
"--field-mapping", `{"目标":"源"}`,
"--header-row", "2",
"--src-sheet-name", "Sheet1",
})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
}
var inv executor.Invocation
if err := json.Unmarshal(out.Bytes(), &inv); err != nil {
t.Fatalf("output JSON parse error = %v\nstdout:\n%s", err, out.String())
}
if _, ok := inv.Params["timeout"]; ok {
t.Fatalf("unexpected default timeout param in %#v", inv.Params)
}
if _, ok := inv.Params["__async__"]; ok {
t.Fatalf("unexpected __async__ param in %#v", inv.Params)
}
if got := inv.Params["tableId"]; got != "TBL_001" {
t.Fatalf("tableId param = %#v, want TBL_001", got)
}
if got := inv.Params["headerRow"]; got != float64(2) {
t.Fatalf("headerRow param = %#v, want 2", got)
}
if got := inv.Params["srcSheetName"]; got != "Sheet1" {
t.Fatalf("srcSheetName param = %#v, want Sheet1", got)
}
mapping, ok := inv.Params["fieldMapping"].(map[string]any)
if !ok {
t.Fatalf("fieldMapping = %#v, want object", inv.Params["fieldMapping"])
}
if got := mapping["目标"]; got != "源" {
t.Fatalf("fieldMapping value = %#v, want 源", got)
}
}
func TestAitableImportDataLiveCallsImportDataOnce(t *testing.T) {
runner := &aitableRetryProbeRunner{
result: executor.Result{
Response: map[string]any{
"status": "PROCESSING",
},
},
}
cmd := newAitableImportDataCommand(runner)
var out, errOut bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetArgs([]string{
"--import-id", "IMPORT_001",
"--timeout", "30",
})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
}
if runner.calls != 1 {
t.Fatalf("runner calls = %d, want 1", runner.calls)
}
if got := runner.result.Invocation.Params["timeout"]; got != 30 {
t.Fatalf("timeout param = %#v, want 30", got)
}
if got := runner.result.Invocation.Tool; got != "import_data" {
t.Fatalf("tool = %q, want import_data", got)
}
}
func TestAitableTopLevelAliases(t *testing.T) {
t.Parallel()
cases := []struct {
name string
args []string
wantTool string
wantParam string
wantValue any
}{
{
name: "search",
args: []string{"search", "--query", "测试"},
wantTool: "search_bases",
wantParam: "query",
wantValue: "测试",
},
{
name: "search keyword",
args: []string{"search", "--keyword", "测试"},
wantTool: "search_bases",
wantParam: "query",
wantValue: "测试",
},
{
name: "create",
args: []string{"create", "--name", "项目跟踪"},
wantTool: "create_base",
wantParam: "baseName",
wantValue: "项目跟踪",
},
{
name: "info",
args: []string{"info", "--base-id", "BASE_001"},
wantTool: "get_base",
wantParam: "baseId",
wantValue: "BASE_001",
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
runner := &aitableCommandRunner{}
cmd := aitableHandler{}.Command(runner)
var out, errOut bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetArgs(tc.args)
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
}
if runner.last.Tool != tc.wantTool {
t.Fatalf("tool = %q, want %q", runner.last.Tool, tc.wantTool)
}
if got := runner.last.Params[tc.wantParam]; got != tc.wantValue {
t.Fatalf("%s = %#v, want %#v", tc.wantParam, got, tc.wantValue)
}
})
}
}
func TestAitableListAliases(t *testing.T) {
t.Parallel()
cases := []struct {
name string
args []string
wantTool string
wantParam string
wantValue any
}{
{
name: "table list",
args: []string{"table", "list", "--base-id", "BASE_001", "--table-ids", "TABLE_001"},
wantTool: "get_tables",
wantParam: "baseId",
wantValue: "BASE_001",
},
{
name: "field list",
args: []string{"field", "list", "--base-id", "BASE_001", "--table-id", "TABLE_001", "--field-ids", "FIELD_001"},
wantTool: "get_fields",
wantParam: "tableId",
wantValue: "TABLE_001",
},
{
name: "record list",
args: []string{"record", "list", "--base-id", "BASE_001", "--table-id", "TABLE_001", "--page-size", "2"},
wantTool: "query_records",
wantParam: "limit",
wantValue: 2,
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
runner := &aitableCommandRunner{}
cmd := aitableHandler{}.Command(runner)
var out, errOut bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetArgs(tc.args)
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
}
if runner.last.Tool != tc.wantTool {
t.Fatalf("tool = %q, want %q", runner.last.Tool, tc.wantTool)
}
if got := runner.last.Params[tc.wantParam]; got != tc.wantValue {
t.Fatalf("%s = %#v, want %#v", tc.wantParam, got, tc.wantValue)
}
})
}
}
func TestAitableFieldCreateAcceptsCompatibilityFlags(t *testing.T) {
t.Parallel()
runner := &aitableCommandRunner{}
cmd := newAitableFieldCreateCommand(runner)
var out, errOut bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetArgs([]string{
"--base-id", "BASE_001",
"--table-id", "TABLE_001",
"--field-name", "AI 摘要",
"--field-type", "text",
"--ai-config", `{"outputType":"text","prompt":[{"type":"fieldRef","fieldId":"fld1"}]}`,
})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
}
if runner.last.Tool != "create_fields" {
t.Fatalf("tool = %q, want create_fields", runner.last.Tool)
}
field := singleAitableRecord(t, runner.last.Params["fields"])
if got := field["fieldName"]; got != "AI 摘要" {
t.Fatalf("fieldName = %#v, want AI 摘要", got)
}
if got := field["type"]; got != "text" {
t.Fatalf("type = %#v, want text", got)
}
aiConfig, ok := field["aiConfig"].(map[string]any)
if !ok {
t.Fatalf("aiConfig type = %T, want map[string]any", field["aiConfig"])
}
if got := aiConfig["outputType"]; got != "text" {
t.Fatalf("aiConfig.outputType = %#v, want text", got)
}
}
func TestAitableFieldCreateNormalizesFieldsJSON(t *testing.T) {
t.Parallel()
runner := &aitableCommandRunner{}
cmd := newAitableFieldCreateCommand(runner)
var out, errOut bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetArgs([]string{
"--base-id", "BASE_001",
"--table-id", "TABLE_001",
"--fields", `[{"name":"授课老师","type":"Text"},{"fieldName":"执行人","type":"member"}]`,
})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
}
fields, ok := runner.last.Params["fields"].([]any)
if !ok {
t.Fatalf("fields type = %T, want []any", runner.last.Params["fields"])
}
if len(fields) != 2 {
t.Fatalf("fields len = %d, want 2", len(fields))
}
first, ok := fields[0].(map[string]any)
if !ok {
t.Fatalf("field[0] type = %T, want map[string]any", fields[0])
}
if got := first["fieldName"]; got != "授课老师" {
t.Fatalf("fieldName = %#v, want 授课老师", got)
}
if _, ok := first["name"]; ok {
t.Fatalf("unexpected name key in normalized field: %#v", first)
}
if got := first["type"]; got != "text" {
t.Fatalf("type = %#v, want text", got)
}
second, ok := fields[1].(map[string]any)
if !ok {
t.Fatalf("field[1] type = %T, want map[string]any", fields[1])
}
if got := second["type"]; got != "user" {
t.Fatalf("member alias type = %#v, want user", got)
}
}
func TestAitableTableCreateNormalizesWrappedFieldsJSON(t *testing.T) {
t.Parallel()
runner := &aitableCommandRunner{}
cmd := newAitableTableCreateCommand(runner)
var out, errOut bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetArgs([]string{
"--base-id", "BASE_001",
"--name", "课程列表",
"--fields", `{"fields":[{"name":"课程名称","type":"TEXT"},{"name":"课时数","type":"Number"}]}`,
})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
}
fields, ok := runner.last.Params["fields"].([]any)
if !ok {
t.Fatalf("fields type = %T, want []any", runner.last.Params["fields"])
}
first, ok := fields[0].(map[string]any)
if !ok {
t.Fatalf("field[0] type = %T, want map[string]any", fields[0])
}
if got := first["fieldName"]; got != "课程名称" {
t.Fatalf("fieldName = %#v, want 课程名称", got)
}
if got := first["type"]; got != "text" {
t.Fatalf("type = %#v, want text", got)
}
second, ok := fields[1].(map[string]any)
if !ok {
t.Fatalf("field[1] type = %T, want map[string]any", fields[1])
}
if got := second["type"]; got != "number" {
t.Fatalf("type = %#v, want number", got)
}
}
func TestAitableTableCreateDefaultsFieldsToEmptyArray(t *testing.T) {
t.Parallel()
runner := &aitableCommandRunner{}
cmd := newAitableTableCreateCommand(runner)
var out, errOut bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetArgs([]string{
"--base-id", "BASE_001",
"--name", "空表",
})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
}
fields, ok := runner.last.Params["fields"].([]any)
if !ok {
t.Fatalf("fields type = %T, want []any", runner.last.Params["fields"])
}
if len(fields) != 0 {
t.Fatalf("fields len = %d, want 0", len(fields))
}
}
func TestAitableFieldUpdatePassesAIConfig(t *testing.T) {
t.Parallel()
runner := &aitableCommandRunner{}
cmd := newAitableFieldUpdateCommand(runner)
var out, errOut bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetArgs([]string{
"--base-id", "BASE_001",
"--table-id", "TABLE_001",
"--field-id", "FIELD_001",
"--ai-config", `{"outputType":"text"}`,
})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
}
if runner.last.Tool != "update_field" {
t.Fatalf("tool = %q, want update_field", runner.last.Tool)
}
aiConfig, ok := runner.last.Params["aiConfig"].(map[string]any)
if !ok {
t.Fatalf("aiConfig = %#v, want object", runner.last.Params["aiConfig"])
}
if got := aiConfig["outputType"]; got != "text" {
t.Fatalf("outputType = %#v, want text", got)
}
}
func TestAitableRecordCreateAcceptsBaseFieldsAndRecordsFile(t *testing.T) {
t.Parallel()
recordsFile := filepath.Join(t.TempDir(), "records.json")
if err := os.WriteFile(recordsFile, []byte(`[{"cells":{"fldX":"from-file"}}]`), 0600); err != nil {
t.Fatal(err)
}
runner := &aitableCommandRunner{}
cmd := newAitableRecordCreateCommand(runner)
var out, errOut bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetArgs([]string{
"--base", "BASE_001",
"--table-id", "TABLE_001",
"--fields", `[{"cells":{"fldX":"from-fields"}}]`,
"--records-file", recordsFile,
})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
}
if got := runner.last.Params["baseId"]; got != "BASE_001" {
t.Fatalf("baseId = %#v, want BASE_001", got)
}
record := singleAitableRecord(t, runner.last.Params["records"])
cells, ok := record["cells"].(map[string]any)
if !ok {
t.Fatalf("cells type = %T, want map[string]any", record["cells"])
}
if got := cells["fldX"]; got != "from-file" {
t.Fatalf("cells[fldX] = %#v, want from-file", got)
}
}
func TestAitableRecordGetQueriesRecordIDs(t *testing.T) {
t.Parallel()
runner := &aitableCommandRunner{}
cmd := newAitableRecordGetCommand(runner)
var out, errOut bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetArgs([]string{
"--base-id", "BASE_001",
"--table-id", "TABLE_001",
"--record-ids", "REC_001, REC_002",
"--field-ids", "FIELD_001",
})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
}
if runner.last.Tool != "query_records" {
t.Fatalf("tool = %q, want query_records", runner.last.Tool)
}
if got := runner.last.Params["baseId"]; got != "BASE_001" {
t.Fatalf("baseId = %#v, want BASE_001", got)
}
if got := runner.last.Params["tableId"]; got != "TABLE_001" {
t.Fatalf("tableId = %#v, want TABLE_001", got)
}
recordIDs, ok := runner.last.Params["recordIds"].([]string)
if !ok {
t.Fatalf("recordIds type = %T, want []string", runner.last.Params["recordIds"])
}
if strings.Join(recordIDs, ",") != "REC_001,REC_002" {
t.Fatalf("recordIds = %#v, want REC_001,REC_002", recordIDs)
}
fieldIDs, ok := runner.last.Params["fieldIds"].([]string)
if !ok {
t.Fatalf("fieldIds type = %T, want []string", runner.last.Params["fieldIds"])
}
if strings.Join(fieldIDs, ",") != "FIELD_001" {
t.Fatalf("fieldIds = %#v, want FIELD_001", fieldIDs)
}
}
func TestAitableRecordQueryAcceptsPageTokenAlias(t *testing.T) {
t.Parallel()
runner := &aitableCommandRunner{}
cmd := newAitableRecordQueryCommand(runner)
var out, errOut bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetArgs([]string{
"--base-id", "BASE_001",
"--table-id", "TABLE_001",
"--page-token", "CURSOR_001",
})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
}
if got := runner.last.Params["cursor"]; got != "CURSOR_001" {
t.Fatalf("cursor = %#v, want CURSOR_001", got)
}
}
func TestAitableRecordQueryNormalizesFiltersAndSortOrder(t *testing.T) {
t.Parallel()
runner := &aitableCommandRunner{}
cmd := newAitableRecordQueryCommand(runner)
var out, errOut bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetArgs([]string{
"--base-id", "BASE_001",
"--table-id", "TABLE_001",
"--filters", `{"operator":"and","operands":[{"fieldId":"FIELD_001","operator":"eq","value":"张三"}]}`,
"--sort", `[{"fieldId":"FIELD_001","order":"desc"}]`,
})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
}
filters, ok := runner.last.Params["filters"].(map[string]any)
if !ok {
t.Fatalf("filters = %#v, want object", runner.last.Params["filters"])
}
operands, ok := filters["operands"].([]any)
if !ok || len(operands) != 1 {
t.Fatalf("filters.operands = %#v, want single operand", filters["operands"])
}
first, ok := operands[0].(map[string]any)
if !ok {
t.Fatalf("operand type = %T, want object", operands[0])
}
if _, ok := first["fieldId"]; ok {
t.Fatalf("unexpected fieldId in normalized operand: %#v", first)
}
operandValues, ok := first["operands"].([]any)
if !ok || len(operandValues) != 2 {
t.Fatalf("operand values = %#v, want [field value]", first["operands"])
}
sortItems, ok := runner.last.Params["sort"].([]any)
if !ok || len(sortItems) != 1 {
t.Fatalf("sort = %#v, want single item", runner.last.Params["sort"])
}
sortFirst, ok := sortItems[0].(map[string]any)
if !ok {
t.Fatalf("sort item type = %T, want object", sortItems[0])
}
if got := sortFirst["direction"]; got != "desc" {
t.Fatalf("direction = %#v, want desc", got)
}
if _, ok := sortFirst["order"]; ok {
t.Fatalf("unexpected order key in sort: %#v", sortFirst)
}
}
func TestAitableRecordGetRequiresRecordIDs(t *testing.T) {
t.Parallel()
runner := &aitableCommandRunner{}
cmd := newAitableRecordGetCommand(runner)
var out, errOut bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetArgs([]string{
"--base-id", "BASE_001",
"--table-id", "TABLE_001",
})
err := cmd.Execute()
if err == nil {
t.Fatal("Execute() error = nil, want missing --record-ids validation")
}
if !strings.Contains(err.Error(), "record-ids") || !strings.Contains(err.Error(), "required") {
t.Fatalf("error = %q, want record-ids required", err.Error())
}
if runner.last.Tool != "" {
t.Fatalf("tool = %q, want no invocation", runner.last.Tool)
}
}
func TestAitableRecordGetRejectsQueryFlags(t *testing.T) {
t.Parallel()
queryOnlyFlags := []string{"filters", "sort", "query", "cursor", "limit"}
for _, flag := range queryOnlyFlags {
flag := flag
t.Run(flag, func(t *testing.T) {
t.Parallel()
runner := &aitableCommandRunner{}
cmd := newAitableRecordGetCommand(runner)
var out, errOut bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetArgs([]string{
"--base-id", "BASE_001",
"--table-id", "TABLE_001",
"--record-ids", "REC_001",
"--" + flag, "x",
})
err := cmd.Execute()
if err == nil {
t.Fatalf("Execute() error = nil, want unknown flag --%s", flag)
}
if !strings.Contains(err.Error(), "unknown flag") || !strings.Contains(err.Error(), "--"+flag) {
t.Fatalf("error = %q, want unknown flag --%s", err.Error(), flag)
}
if runner.last.Tool != "" {
t.Fatalf("tool = %q, want no invocation", runner.last.Tool)
}
})
}
}
func TestAitableRecordUpdateAcceptsRecordsFile(t *testing.T) {
t.Parallel()
recordsFile := filepath.Join(t.TempDir(), "records.json")
if err := os.WriteFile(recordsFile, []byte(`[{"recordId":"REC_FILE","cells":{"fldX":"from-file"}}]`), 0600); err != nil {
t.Fatal(err)
}
runner := &aitableCommandRunner{}
cmd := newAitableRecordUpdateCommand(runner)
var out, errOut bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetArgs([]string{
"--base-id", "BASE_001",
"--table-id", "TABLE_001",
"--records-file", recordsFile,
})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
}
record := singleAitableUpdateRecord(t, runner.last.Params["records"])
if got := record["recordId"]; got != "REC_FILE" {
t.Fatalf("recordId = %#v, want REC_FILE", got)
}
}
func TestAitableAttachmentUploadRequiresSize(t *testing.T) {
t.Parallel()
runner := &aitableCommandRunner{}
cmd := newAITableAttachmentUploadCommand(runner)
var out, errOut bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetArgs([]string{
"--base-id", "BASE_001",
"--file-name", "test.png",
})
if err := cmd.Execute(); err == nil {
t.Fatal("Execute() error = nil, want missing --size validation")
}
if runner.last.Tool != "" {
t.Fatalf("tool = %q, want no invocation", runner.last.Tool)
}
}
func TestAitableChartCreateRequiresConfigAndLayout(t *testing.T) {
t.Parallel()
runner := &aitableCommandRunner{}
cmd := newAitableChartCreateCommand(runner)
var out, errOut bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetArgs([]string{
"--base-id", "BASE_001",
"--dashboard-id", "DASHBOARD_001",
})
if err := cmd.Execute(); err == nil {
t.Fatal("Execute() error = nil, want missing config/layout validation")
}
if runner.last.Tool != "" {
t.Fatalf("tool = %q, want no invocation", runner.last.Tool)
}
}
func TestAitableViewCreatePassesWukongDescriptionAndSubtype(t *testing.T) {
t.Parallel()
runner := &aitableCommandRunner{}
cmd := newAitableViewCreateCommand(runner)
var out, errOut bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetArgs([]string{
"--base-id", "BASE_001",
"--table-id", "TABLE_001",
"--view-type", "Grid",
"--view-sub-type", "default",
"--desc", `{"text":"描述"}`,
})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
}
if runner.last.Tool != "create_view" {
t.Fatalf("tool = %q, want create_view", runner.last.Tool)
}
if got := runner.last.Params["viewSubType"]; got != "default" {
t.Fatalf("viewSubType = %#v, want default", got)
}
desc, ok := runner.last.Params["viewDescription"].(map[string]any)
if !ok {
t.Fatalf("viewDescription = %#v, want object", runner.last.Params["viewDescription"])
}
if got := desc["text"]; got != "描述" {
t.Fatalf("desc.text = %#v, want 描述", got)
}
if _, ok := runner.last.Params["desc"]; ok {
t.Fatalf("unexpected desc param in %#v", runner.last.Params)
}
}
func TestAitableViewUpdateUsesWukongToolParams(t *testing.T) {
t.Parallel()
runner := &aitableCommandRunner{}
cmd := newAitableViewUpdateCommand(runner)
var out, errOut bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetArgs([]string{
"--base-id", "BASE_001",
"--table-id", "TABLE_001",
"--view-id", "VIEW_001",
"--name", "新视图",
"--desc", `{"text":"描述"}`,
"--config", `{"visibleFieldIds":["fld1","fld2"]}`,
})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
}
if runner.last.Tool != "update_view" {
t.Fatalf("tool = %q, want update_view", runner.last.Tool)
}
if got := runner.last.Params["newViewName"]; got != "新视图" {
t.Fatalf("newViewName = %#v, want 新视图", got)
}
desc, ok := runner.last.Params["viewDescription"].(map[string]any)
if !ok {
t.Fatalf("viewDescription = %#v, want object", runner.last.Params["viewDescription"])
}
if got := desc["text"]; got != "描述" {
t.Fatalf("desc.text = %#v, want 描述", got)
}
config, ok := runner.last.Params["config"].(map[string]any)
if !ok {
t.Fatalf("config = %#v, want object", runner.last.Params["config"])
}
if _, ok := config["visibleFieldIds"]; !ok {
t.Fatalf("config missing visibleFieldIds: %#v", config)
}
}
func TestAitableRecordDeleteCallsDeleteOnlyOnce(t *testing.T) {
t.Parallel()
runner := &aitableRetryProbeRunner{}
cmd := newAitableRecordDeleteCommand(runner)
cmd.Flags().Bool("yes", false, "yes")
var out, errOut bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetArgs([]string{
"--base-id", "BASE_001",
"--table-id", "TABLE_001",
"--record-ids", "REC_001",
"--yes",
})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
}
if runner.calls != 1 {
t.Fatalf("runner calls = %d, want 1", runner.calls)
}
if got := runner.result.Invocation.Tool; got != "delete_records" {
t.Fatalf("tool = %q, want delete_records", got)
}
}
func TestAitableViewListUsesWukongGetViewsTool(t *testing.T) {
t.Parallel()
runner := &aitableCommandRunner{}
cmd := newAitableViewListCommand(runner)
var out, errOut bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetArgs([]string{
"--base-id", "BASE_001",
"--table-id", "TABLE_001",
"--view-ids", "VIEW_001,VIEW_002",
})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
}
if runner.last.Tool != "get_views" {
t.Fatalf("tool = %q, want get_views", runner.last.Tool)
}
viewIDs, ok := runner.last.Params["viewIds"].([]string)
if !ok {
t.Fatalf("viewIds = %#v, want []string", runner.last.Params["viewIds"])
}
if len(viewIDs) != 2 || viewIDs[0] != "VIEW_001" || viewIDs[1] != "VIEW_002" {
t.Fatalf("viewIds = %#v, want [VIEW_001 VIEW_002]", viewIDs)
}
}
func TestAitableDashboardAndChartHelpersUseWukongTools(t *testing.T) {
t.Parallel()
t.Run("dashboard create name shortcut", func(t *testing.T) {
runner := &aitableCommandRunner{}
cmd := newAitableDashboardCreateCommand(runner)
var out, errOut bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetArgs([]string{
"--base-id", "BASE_001",
"--name", "销售看板",
})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
}
if runner.last.Tool != "create_dashboard" {
t.Fatalf("tool = %q, want create_dashboard", runner.last.Tool)
}
config, ok := runner.last.Params["config"].(map[string]any)
if !ok {
t.Fatalf("config = %#v, want object", runner.last.Params["config"])
}
if got := config["dashboardName"]; got != "销售看板" {
t.Fatalf("dashboardName = %#v, want 销售看板", got)
}
})
t.Run("dashboard update name shortcut", func(t *testing.T) {
runner := &aitableCommandRunner{}
cmd := newAitableDashboardUpdateCommand(runner)
var out, errOut bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetArgs([]string{
"--base-id", "BASE_001",
"--dashboard-id", "DASHBOARD_001",
"--name", "新看板",
})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
}
if runner.last.Tool != "update_dashboard" {
t.Fatalf("tool = %q, want update_dashboard", runner.last.Tool)
}
config, ok := runner.last.Params["config"].(map[string]any)
if !ok {
t.Fatalf("config = %#v, want object", runner.last.Params["config"])
}
if got := config["dashboardName"]; got != "新看板" {
t.Fatalf("dashboardName = %#v, want 新看板", got)
}
})
t.Run("dashboard share update", func(t *testing.T) {
runner := &aitableCommandRunner{}
cmd := newAitableDashboardShareUpdateCommand(runner)
var out, errOut bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetArgs([]string{
"--base-id", "BASE_001",
"--dashboard-id", "DASHBOARD_001",
"--enabled", "true",
"--share-type", "PUBLIC",
"--allow-back-to-doc", "true",
})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
}
if runner.last.Tool != "update_dashboard_share" {
t.Fatalf("tool = %q, want update_dashboard_share", runner.last.Tool)
}
if got := runner.last.Params["shareType"]; got != "PUBLIC" {
t.Fatalf("shareType = %#v, want PUBLIC", got)
}
if got := runner.last.Params["allowBackToDoc"]; got != true {
t.Fatalf("allowBackToDoc = %#v, want true", got)
}
})
t.Run("chart widgets example", func(t *testing.T) {
runner := &aitableCommandRunner{}
cmd := newAitableChartWidgetsExampleCommand(runner)
var out, errOut bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errOut)
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
}
if runner.last.Tool != "get_dashboard_widgets_example" {
t.Fatalf("tool = %q, want get_dashboard_widgets_example", runner.last.Tool)
}
})
}
func TestAitableRecordUpdateAcceptsRecordIDAndCells(t *testing.T) {
t.Parallel()
runner := &aitableCommandRunner{}
cmd := newAitableRecordUpdateCommand(runner)
var out, errOut bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetArgs([]string{
"--base-id", "BASE_001",
"--table-id", "TABLE_001",
"--record-id", "REC_001",
"--cells", `{"fldX":"值"}`,
})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
}
record := singleAitableUpdateRecord(t, runner.last.Params["records"])
if got := record["recordId"]; got != "REC_001" {
t.Fatalf("recordId = %#v, want REC_001", got)
}
cells, ok := record["cells"].(map[string]any)
if !ok {
t.Fatalf("cells type = %T, want map[string]any", record["cells"])
}
if got := cells["fldX"]; got != "值" {
t.Fatalf("cells[fldX] = %#v, want 值", got)
}
}
func TestAitableRecordUpdateAcceptsDataCellsWithRecordID(t *testing.T) {
t.Parallel()
runner := &aitableCommandRunner{}
cmd := newAitableRecordUpdateCommand(runner)
var out, errOut bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetArgs([]string{
"--base-id", "BASE_001",
"--table-id", "TABLE_001",
"--record-id", "REC_002",
"--data", `{"fldY":"新值"}`,
})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
}
record := singleAitableUpdateRecord(t, runner.last.Params["records"])
if got := record["recordId"]; got != "REC_002" {
t.Fatalf("recordId = %#v, want REC_002", got)
}
cells, ok := record["cells"].(map[string]any)
if !ok {
t.Fatalf("cells type = %T, want map[string]any", record["cells"])
}
if got := cells["fldY"]; got != "新值" {
t.Fatalf("cells[fldY] = %#v, want 新值", got)
}
}
func TestAitableRecordUpdateAcceptsDataRecordsArray(t *testing.T) {
t.Parallel()
runner := &aitableCommandRunner{}
cmd := newAitableRecordUpdateCommand(runner)
var out, errOut bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetArgs([]string{
"--base-id", "BASE_001",
"--table-id", "TABLE_001",
"--data", `[{"recordId":"REC_003","cells":{"fldZ":"done"}}]`,
})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
}
record := singleAitableUpdateRecord(t, runner.last.Params["records"])
if got := record["recordId"]; got != "REC_003" {
t.Fatalf("recordId = %#v, want REC_003", got)
}
}
func singleAitableUpdateRecord(t *testing.T, value any) map[string]any {
t.Helper()
return singleAitableRecord(t, value)
}
func singleAitableRecord(t *testing.T, value any) map[string]any {
t.Helper()
records, ok := value.([]any)
if !ok {
t.Fatalf("records type = %T, want []any", value)
}
if len(records) != 1 {
t.Fatalf("records len = %d, want 1", len(records))
}
record, ok := records[0].(map[string]any)
if !ok {
t.Fatalf("record type = %T, want map[string]any", records[0])
}
return record
}
// base create 无模板时,CLI 兜底补建一张默认表(与 wukong 场景行为对齐:
// 服务端对 openClaw 不建默认表,CLI 侧补齐使新 base 立即含 tableId)。
func TestAitableBaseCreateAddsDefaultTableWithoutTemplate(t *testing.T) {
t.Parallel()
runner := &aitableSequencedRunner{responses: []map[string]any{
{"data": map[string]any{"baseId": "BASE_001"}},
{"data": map[string]any{"tableId": "TABLE_001"}},
}}
cmd := newAitableBaseCreateCommand(runner)
var out, errOut bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetArgs([]string{"--name", "项目跟踪"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
}
if len(runner.calls) != 2 {
t.Fatalf("calls = %d, want create_base + create_table", len(runner.calls))
}
if got := runner.calls[0].Tool; got != "create_base" {
t.Fatalf("first tool = %q, want create_base", got)
}
if got := runner.calls[1].Tool; got != "create_table" {
t.Fatalf("second tool = %q, want create_table", got)
}
if got := runner.calls[1].Params["baseId"]; got != "BASE_001" {
t.Fatalf("create_table baseId = %#v, want BASE_001", got)
}
}
// base create 带 --template-id 时不补建默认表(模板自带结构)。
func TestAitableBaseCreateWithTemplateSkipsDefaultTable(t *testing.T) {
t.Parallel()
runner := &aitableSequencedRunner{responses: []map[string]any{
{"data": map[string]any{"baseId": "BASE_001"}},
}}
cmd := newAitableBaseCreateCommand(runner)
var out, errOut bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetArgs([]string{"--name", "项目跟踪", "--template-id", "TPL_001"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
}
if len(runner.calls) != 1 {
t.Fatalf("calls = %d, want only create_base", len(runner.calls))
}
if got := runner.calls[0].Tool; got != "create_base" {
t.Fatalf("tool = %q, want create_base", got)
}
}