diff --git a/shortcuts/base/base_dryrun_ops_test.go b/shortcuts/base/base_dryrun_ops_test.go index 12ce4177c4..09c81c922e 100644 --- a/shortcuts/base/base_dryrun_ops_test.go +++ b/shortcuts/base/base_dryrun_ops_test.go @@ -60,6 +60,42 @@ func TestDryRunTemplateCenterOps(t *testing.T) { assertDryRunContains(t, dryRunTemplateSearch(ctx, searchRT), "GET /open-apis/base/v3/bases/templates/search", "keyword=AI", "limit=10", "offset=cursor_2") } +func TestDryRunFormUpdateDisplayMode(t *testing.T) { + ctx := context.Background() + + onRT := newBaseTestRuntime( + map[string]string{"base-token": "app_x", "table-id": "tbl_x", "form-id": "frm_x", "display-mode": "step"}, + nil, + nil, + ) + assertDryRunContains( + t, + BaseFormUpdate.DryRun(ctx, onRT), + "PATCH /open-apis/base/v3/bases/app_x/tables/tbl_x/forms/frm_x", + `"display_mode":2`, + ) + + offRT := newBaseTestRuntime( + map[string]string{"base-token": "app_x", "table-id": "tbl_x", "form-id": "frm_x", "display-mode": "list"}, + nil, + nil, + ) + assertDryRunContains(t, BaseFormUpdate.DryRun(ctx, offRT), `"display_mode":1`) + + nameOnlyRT := newBaseTestRuntime( + map[string]string{"base-token": "app_x", "table-id": "tbl_x", "form-id": "frm_x", "name": "Renamed"}, + nil, + nil, + ) + request := buildFormUpdateBody(nameOnlyRT) + if request.DisplayMode != nil { + t.Fatalf("display_mode must remain nil when --display-mode is omitted: %#v", request) + } + if out := BaseFormUpdate.DryRun(ctx, nameOnlyRT).Format(); strings.Contains(out, "display_mode") { + t.Fatalf("omitted --display-mode must not emit display_mode:\n%s", out) + } +} + func TestDryRunFieldExtensionOps(t *testing.T) { ctx := context.Background() diff --git a/shortcuts/base/base_form_execute_test.go b/shortcuts/base/base_form_execute_test.go index 83c702932f..576c77d4e4 100644 --- a/shortcuts/base/base_form_execute_test.go +++ b/shortcuts/base/base_form_execute_test.go @@ -6,10 +6,13 @@ package base import ( "encoding/json" "errors" + "reflect" "strings" "testing" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/shortcuts/common" ) func TestBaseFormExecuteList(t *testing.T) { @@ -24,8 +27,8 @@ func TestBaseFormExecuteList(t *testing.T) { "has_more": false, "total": 2, "forms": []interface{}{ - map[string]interface{}{"id": "vew_form1", "name": "用户调研问卷", "description": "2024年调研"}, - map[string]interface{}{"id": "vew_form2", "name": "产品反馈表", "description": ""}, + map[string]interface{}{"id": "vew_form1", "name": "用户调研问卷", "description": "2024年调研", "display_mode": 1}, + map[string]interface{}{"id": "vew_form2", "name": "产品反馈表", "description": "", "display_mode": 2, "future_field": "preserved"}, }, }, }, @@ -33,7 +36,7 @@ func TestBaseFormExecuteList(t *testing.T) { if err := runShortcut(t, BaseFormsList, []string{"+form-list", "--base-token", "app_x", "--table-id", "tbl_x"}, factory, stdout); err != nil { t.Fatalf("err=%v", err) } - if got := stdout.String(); !strings.Contains(got, `"vew_form1"`) || !strings.Contains(got, `"total": 2`) { + if got := stdout.String(); !strings.Contains(got, `"vew_form1"`) || !strings.Contains(got, `"display_mode": 2`) || !strings.Contains(got, `"future_field": "preserved"`) || !strings.Contains(got, `"description": ""`) || !strings.Contains(got, `"total": 2`) { t.Fatalf("stdout=%s", got) } }) @@ -92,20 +95,83 @@ func TestBaseFormExecuteGet(t *testing.T) { Body: map[string]interface{}{ "code": 0, "data": map[string]interface{}{ - "id": "vew_form1", - "name": "用户调研问卷", - "description": "2024年度用户满意度调研", + "id": "vew_form1", + "name": "用户调研问卷", + "description": "2024年度用户满意度调研", + "display_mode": 2, }, }, }) if err := runShortcut(t, BaseFormGet, []string{"+form-get", "--base-token", "app_x", "--table-id", "tbl_x", "--form-id", "vew_form1"}, factory, stdout); err != nil { t.Fatalf("err=%v", err) } - if got := stdout.String(); !strings.Contains(got, `"vew_form1"`) || !strings.Contains(got, `"用户调研问卷"`) { + if got := stdout.String(); !strings.Contains(got, `"vew_form1"`) || !strings.Contains(got, `"用户调研问卷"`) || !strings.Contains(got, `"display_mode": 2`) { t.Fatalf("stdout=%s", got) } } +func TestBaseFormResponsesRejectInvalidDisplayModeType(t *testing.T) { + tests := []struct { + name string + shortcut common.Shortcut + method string + url string + args []string + data map[string]interface{} + }{ + { + name: "get", + shortcut: BaseFormGet, + method: "GET", + url: "/open-apis/base/v3/bases/app_x/tables/tbl_x/forms/vew_form1", + args: []string{"+form-get", "--base-token", "app_x", "--table-id", "tbl_x", "--form-id", "vew_form1"}, + data: map[string]interface{}{"id": "vew_form1", "display_mode": "step"}, + }, + { + name: "list", + shortcut: BaseFormsList, + method: "GET", + url: "/open-apis/base/v3/bases/app_x/tables/tbl_x/forms", + args: []string{"+form-list", "--base-token", "app_x", "--table-id", "tbl_x"}, + data: map[string]interface{}{ + "has_more": false, + "forms": []interface{}{map[string]interface{}{"id": "vew_form1", "display_mode": "step"}}, + }, + }, + { + name: "update", + shortcut: BaseFormUpdate, + method: "PATCH", + url: "/open-apis/base/v3/bases/app_x/tables/tbl_x/forms/vew_form1", + args: []string{"+form-update", "--base-token", "app_x", "--table-id", "tbl_x", "--form-id", "vew_form1", "--display-mode", "step"}, + data: map[string]interface{}{"id": "vew_form1", "display_mode": "step"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: tt.method, + URL: tt.url, + Body: map[string]interface{}{"code": 0, "data": tt.data}, + }) + + err := runShortcut(t, tt.shortcut, tt.args, factory, stdout) + if err == nil { + t.Fatalf("expected invalid display_mode response to fail, stdout=%s", stdout.String()) + } + problem, ok := errs.ProblemOf(err) + if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeInvalidResponse { + t.Fatalf("error = %T %v, problem=%#v", err, err, problem) + } + if errors.Unwrap(err) == nil { + t.Fatalf("invalid response error must preserve its JSON decoding cause: %v", err) + } + }) + } +} + func TestBaseFormExecuteCreate(t *testing.T) { t.Run("name only", func(t *testing.T) { factory, stdout, reg := newExecuteFactory(t) @@ -179,6 +245,44 @@ func TestBaseFormExecuteCreate(t *testing.T) { } func TestBaseFormExecuteUpdate(t *testing.T) { + t.Run("update name description and display mode together", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + stub := &httpmock.Stub{ + Method: "PATCH", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/forms/vew_form1", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "id": "vew_form1", + "name": "Updated Form", + "description": "Updated description", + "display_mode": 2, + }, + }, + } + reg.Register(stub) + args := []string{ + "+form-update", "--base-token", "app_x", "--table-id", "tbl_x", "--form-id", "vew_form1", + "--name", "Updated Form", "--description", "Updated description", "--display-mode", "step", + } + if err := runShortcut(t, BaseFormUpdate, args, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + + var body map[string]interface{} + if err := json.Unmarshal(stub.CapturedBody, &body); err != nil { + t.Fatalf("captured body json err=%v body=%s", err, string(stub.CapturedBody)) + } + want := map[string]interface{}{ + "name": "Updated Form", + "description": "Updated description", + "display_mode": float64(2), + } + if !reflect.DeepEqual(body, want) { + t.Fatalf("body=%#v want=%#v", body, want) + } + }) + t.Run("update name", func(t *testing.T) { factory, stdout, reg := newExecuteFactory(t) reg.Register(&httpmock.Stub{ @@ -187,9 +291,10 @@ func TestBaseFormExecuteUpdate(t *testing.T) { Body: map[string]interface{}{ "code": 0, "data": map[string]interface{}{ - "id": "vew_form1", - "name": "更新后的表单", - "description": "", + "id": "vew_form1", + "name": "更新后的表单", + "description": "", + "display_mode": 1, }, }, }) @@ -209,9 +314,10 @@ func TestBaseFormExecuteUpdate(t *testing.T) { Body: map[string]interface{}{ "code": 0, "data": map[string]interface{}{ - "id": "vew_form1", - "name": "Form", - "description": "更新的描述内容", + "id": "vew_form1", + "name": "Form", + "description": "更新的描述内容", + "display_mode": 1, }, }, }) @@ -224,6 +330,72 @@ func TestBaseFormExecuteUpdate(t *testing.T) { t.Fatalf("stdout=%s", got) } }) + + t.Run("update display mode on", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + stub := &httpmock.Stub{ + Method: "PATCH", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/forms/vew_form1", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "id": "vew_form1", + "name": "Form", + "description": "", + "display_mode": 2, + }, + }, + } + reg.Register(stub) + args := []string{"+form-update", "--base-token", "app_x", "--table-id", "tbl_x", "--form-id", "vew_form1", + "--display-mode", "step"} + if err := runShortcut(t, BaseFormUpdate, args, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"display_mode": 2`) { + t.Fatalf("stdout=%s", got) + } + var body map[string]interface{} + if err := json.Unmarshal(stub.CapturedBody, &body); err != nil { + t.Fatalf("captured body json err=%v body=%s", err, string(stub.CapturedBody)) + } + if body["display_mode"] != float64(2) { + t.Fatalf("display_mode=%#v; body=%s", body["display_mode"], string(stub.CapturedBody)) + } + }) + + t.Run("update display mode off", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + stub := &httpmock.Stub{ + Method: "PATCH", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/forms/vew_form1", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "id": "vew_form1", + "name": "Form", + "description": "", + "display_mode": 1, + }, + }, + } + reg.Register(stub) + args := []string{"+form-update", "--base-token", "app_x", "--table-id", "tbl_x", "--form-id", "vew_form1", + "--display-mode", "list"} + if err := runShortcut(t, BaseFormUpdate, args, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"display_mode": 1`) { + t.Fatalf("stdout=%s", got) + } + var body map[string]interface{} + if err := json.Unmarshal(stub.CapturedBody, &body); err != nil { + t.Fatalf("captured body json err=%v body=%s", err, string(stub.CapturedBody)) + } + if body["display_mode"] != float64(1) { + t.Fatalf("display_mode=%#v; body=%s", body["display_mode"], string(stub.CapturedBody)) + } + }) } func TestBaseFormExecuteDelete(t *testing.T) { diff --git a/shortcuts/base/base_form_get.go b/shortcuts/base/base_form_get.go index a4c3a2eeb3..6a40b230f6 100644 --- a/shortcuts/base/base_form_get.go +++ b/shortcuts/base/base_form_get.go @@ -41,15 +41,13 @@ var BaseFormGet = common.Shortcut{ if err != nil { return err } + form, err := decodeBaseFormResponse(data) + if err != nil { + return err + } - runtime.OutFormat(data, nil, func(w io.Writer) { - output.PrintTable(w, []map[string]interface{}{ - { - "id": data["id"], - "name": data["name"], - "description": data["description"], - }, - }) + runtime.OutFormat(form, nil, func(w io.Writer) { + output.PrintTable(w, []map[string]interface{}{baseFormTableRow(form)}) }) return nil }, diff --git a/shortcuts/base/base_form_list.go b/shortcuts/base/base_form_list.go index e2b81251e0..27e7c3c2bb 100644 --- a/shortcuts/base/base_form_list.go +++ b/shortcuts/base/base_form_list.go @@ -40,7 +40,7 @@ var BaseFormsList = common.Shortcut{ baseToken := runtime.Str("base-token") tableId := runtime.Str("table-id") - var allForms []interface{} + var allForms []baseFormResponse pageToken := "" for { params := map[string]interface{}{ @@ -56,23 +56,24 @@ var BaseFormsList = common.Shortcut{ return err } - forms, _ := data["forms"].([]interface{}) - allForms = append(allForms, forms...) + page, err := decodeBaseFormsPageResponse(data) + if err != nil { + return err + } + allForms = append(allForms, page.Forms...) - hasMore, _ := data["has_more"].(bool) - if !hasMore { + if !page.HasMore { break } - nextToken, _ := data["page_token"].(string) - if nextToken == "" { + if page.PageToken == "" { break } - pageToken = nextToken + pageToken = page.PageToken } - outData := map[string]interface{}{ - "forms": allForms, - "total": len(allForms), + outData := baseFormsListOutput{ + Forms: allForms, + Total: len(allForms), } runtime.OutFormat(outData, nil, func(w io.Writer) { if len(allForms) == 0 { @@ -80,13 +81,8 @@ var BaseFormsList = common.Shortcut{ return } var rows []map[string]interface{} - for _, item := range allForms { - m, _ := item.(map[string]interface{}) - rows = append(rows, map[string]interface{}{ - "id": m["id"], - "name": m["name"], - "description": m["description"], - }) + for _, form := range allForms { + rows = append(rows, baseFormTableRow(form)) } output.PrintTable(w, rows) fmt.Fprintf(w, "\n%d form(s) total\n", len(allForms)) diff --git a/shortcuts/base/base_form_types.go b/shortcuts/base/base_form_types.go new file mode 100644 index 0000000000..391bd7e913 --- /dev/null +++ b/shortcuts/base/base_form_types.go @@ -0,0 +1,103 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "bytes" + "encoding/json" + + "github.com/larksuite/cli/errs" +) + +type baseFormResponse struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + Type string `json:"type,omitempty"` + DisplayMode *int `json:"display_mode,omitempty"` + raw json.RawMessage +} + +type baseFormResponseAlias baseFormResponse + +func (form *baseFormResponse) UnmarshalJSON(data []byte) error { + var decoded baseFormResponseAlias + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.UseNumber() + if err := decoder.Decode(&decoded); err != nil { + return err + } + *form = baseFormResponse(decoded) + form.raw = append(form.raw[:0], data...) + return nil +} + +func (form baseFormResponse) MarshalJSON() ([]byte, error) { + if len(form.raw) != 0 { + return form.raw, nil + } + return json.Marshal(baseFormResponseAlias(form)) +} + +type baseFormsPageResponse struct { + Forms []baseFormResponse `json:"forms"` + HasMore bool `json:"has_more"` + PageToken string `json:"page_token"` +} + +type baseFormsListOutput struct { + Forms []baseFormResponse `json:"forms"` + Total int `json:"total"` +} + +type baseFormUpdateRequest struct { + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + DisplayMode *int `json:"display_mode,omitempty"` +} + +func decodeBaseFormResponse(data map[string]interface{}) (baseFormResponse, error) { + return decodeBaseFormData[baseFormResponse](data, "form") +} + +func decodeBaseFormsPageResponse(data map[string]interface{}) (baseFormsPageResponse, error) { + return decodeBaseFormData[baseFormsPageResponse](data, "form list page") +} + +func decodeBaseFormData[T any](data map[string]interface{}, responseName string) (T, error) { + var response T + raw, err := json.Marshal(data) + if err != nil { + return response, errs.NewInternalError( + errs.SubtypeInvalidResponse, + "encode %s response for typed decoding: %v", + responseName, + err, + ).WithCause(err) + } + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.UseNumber() + if err := decoder.Decode(&response); err != nil { + return response, errs.NewInternalError( + errs.SubtypeInvalidResponse, + "decode %s response: %v", + responseName, + err, + ).WithCause(err) + } + return response, nil +} + +func baseFormTableRow(form baseFormResponse) map[string]interface{} { + var displayMode interface{} + if form.DisplayMode != nil { + displayMode = *form.DisplayMode + } + return map[string]interface{}{ + "id": form.ID, + "name": form.Name, + "description": form.Description, + "display_mode": displayMode, + } +} diff --git a/shortcuts/base/base_form_update.go b/shortcuts/base/base_form_update.go index 53096e2068..9d284c901d 100644 --- a/shortcuts/base/base_form_update.go +++ b/shortcuts/base/base_form_update.go @@ -11,6 +11,11 @@ import ( "github.com/larksuite/cli/shortcuts/common" ) +const ( + formDisplayModeList = "list" + formDisplayModeStep = "step" +) + var BaseFormUpdate = common.Shortcut{ Service: "base", Command: "+form-update", @@ -25,10 +30,12 @@ var BaseFormUpdate = common.Shortcut{ {Name: "form-id", Desc: "form ID", Required: true}, {Name: "name", Desc: "new form name"}, {Name: "description", Desc: "new form description (plain text or markdown link like [text](https://example.com))"}, + {Name: "display-mode", Desc: "form display mode: list (traditional) or step (one question per page)", Enum: []string{formDisplayModeList, formDisplayModeStep}}, }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { return common.NewDryRunAPI(). PATCH("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms/:form_id"). + Body(buildFormUpdateBody(runtime)). Set("base_token", runtime.Str("base-token")). Set("table_id", runtime.Str("table-id")). Set("form_id", runtime.Str("form-id")) @@ -37,32 +44,42 @@ var BaseFormUpdate = common.Shortcut{ baseToken := runtime.Str("base-token") tableId := runtime.Str("table-id") formId := runtime.Str("form-id") - name := runtime.Str("name") - description := runtime.Str("description") - - body := map[string]interface{}{} - if name != "" { - body["name"] = name - } - if description != "" { - body["description"] = description - } + body := buildFormUpdateBody(runtime) data, err := baseV3Call(runtime, "PATCH", baseV3Path("bases", baseToken, "tables", tableId, "forms", formId), nil, body) if err != nil { return err } + form, err := decodeBaseFormResponse(data) + if err != nil { + return err + } - runtime.OutFormat(data, nil, func(w io.Writer) { - output.PrintTable(w, []map[string]interface{}{ - { - "id": data["id"], - "name": data["name"], - "description": data["description"], - }, - }) + runtime.OutFormat(form, nil, func(w io.Writer) { + output.PrintTable(w, []map[string]interface{}{baseFormTableRow(form)}) }) return nil }, } + +func buildFormUpdateBody(runtime *common.RuntimeContext) baseFormUpdateRequest { + body := baseFormUpdateRequest{} + if name := runtime.Str("name"); name != "" { + body.Name = name + } + if description := runtime.Str("description"); description != "" { + body.Description = description + } + if runtime.Changed("display-mode") { + var displayMode int + switch runtime.Str("display-mode") { + case formDisplayModeStep: + displayMode = 2 + case formDisplayModeList: + displayMode = 1 + } + body.DisplayMode = &displayMode + } + return body +} diff --git a/skills/lark-base/SKILL.md b/skills/lark-base/SKILL.md index b85c72ea6d..82e5aecfbd 100644 --- a/skills/lark-base/SKILL.md +++ b/skills/lark-base/SKILL.md @@ -208,7 +208,7 @@ View 是同一 Table records 上的持久化筛选、排序、分组和展示配 Form 依附于 Table,以 Field 作为题目,每次有效提交会创建一条 Record,适合信息收集、外部填写、条件题目和附件提交。 1. **读取 Table 中的表单配置:** 使用 `+form-list` / `+form-get` 读取表单,使用 `+form-questions-list` 读取题目配置;这些命令使用表单所属的 `base_token + table_id`。 -2. **创建或修改 Table 中的表单配置:** 使用 `+form-create` / `+form-update` / `+form-delete` 管理表单;题目由 Table Field 承载,question ID 对应 `field_id`,创建和更新分别读取 [questions create](references/lark-base-form-questions-create.md) / [questions update](references/lark-base-form-questions-update.md),删除使用 `+form-questions-delete`。 +2. **创建或修改 Table 中的表单配置:** 使用 `+form-create` / `+form-update` / `+form-delete` 管理表单;表单显示模式由 `+form-update` 管理。题目由 Table Field 承载,question ID 对应 `field_id`,创建和更新分别读取 [questions create](references/lark-base-form-questions-create.md) / [questions update](references/lark-base-form-questions-update.md),删除使用 `+form-questions-delete`。 3. **管理表单分享:** 使用 `+form-share-get` / `+form-share-update` 管理启停、访问范围和匿名/登录要求;更新前先读取现状,每次只修改一个字段,布尔值显式传 `true` 或 `false`。 4. **填写分享表单并提交:** 对表单分享链接使用 `+url-resolve` 取得 `share_token`,按 [Form detail](references/lark-base-form-detail.md) 执行 `+form-detail` 读取真实题目、必填项和显示条件,再按 [Form submit](references/lark-base-form-submit.md) 构造字段与附件并执行 `+form-submit`。 diff --git a/tests/cli_e2e/base/base_form_update_dryrun_test.go b/tests/cli_e2e/base/base_form_update_dryrun_test.go new file mode 100644 index 0000000000..e6422aa9f7 --- /dev/null +++ b/tests/cli_e2e/base/base_form_update_dryrun_test.go @@ -0,0 +1,40 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "testing" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/require" +) + +func TestBaseFormUpdateDisplayModeDryRun(t *testing.T) { + tests := []struct { + mode string + want int64 + }{ + {mode: "list", want: 1}, + {mode: "step", want: 2}, + } + + for _, tt := range tests { + t.Run(tt.mode, func(t *testing.T) { + result := runBaseDryRun(t, 0, + "base", "+form-update", + "--base-token", "app_x", + "--table-id", "tbl_x", + "--form-id", "vew_form_x", + "--display-mode", tt.mode, + ) + + out := result.Stdout + require.Equal(t, "PATCH", clie2e.DryRunGet(out, "api.0.method").String(), out) + require.Equal(t, "/open-apis/base/v3/bases/app_x/tables/tbl_x/forms/vew_form_x", clie2e.DryRunGet(out, "api.0.url").String(), out) + require.Equal(t, tt.want, clie2e.DryRunGet(out, "api.0.body.display_mode").Int(), out) + require.False(t, clie2e.DryRunGet(out, "api.0.body.name").Exists(), out) + require.False(t, clie2e.DryRunGet(out, "api.0.body.description").Exists(), out) + }) + } +} diff --git a/tests/cli_e2e/base/base_form_update_workflow_test.go b/tests/cli_e2e/base/base_form_update_workflow_test.go new file mode 100644 index 0000000000..2b966409f1 --- /dev/null +++ b/tests/cli_e2e/base/base_form_update_workflow_test.go @@ -0,0 +1,94 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "os" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestBaseFormUpdateDisplayModeWorkflow(t *testing.T) { + if os.Getenv("LARK_CLI_E2E_BASE_FORM_DISPLAY_MODE_READY") != "1" { + t.Skip("set LARK_CLI_E2E_BASE_FORM_DISPLAY_MODE_READY=1 after the form display-mode OpenAPI is deployed") + } + clie2e.SkipWithoutTenantAccessToken(t) + parentT := t + ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute) + t.Cleanup(cancel) + + suffix := clie2e.GenerateSuffix() + baseToken := createBaseWithRetry(t, ctx, "lark-cli-e2e-form-display-mode-"+suffix) + tableID, _, _ := createTableWithRetry( + t, + parentT, + ctx, + baseToken, + "Form Display Mode "+suffix, + `[{"name":"Question","type":"text"}]`, + `{"name":"Main","type":"grid"}`, + ) + + createResult, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{ + Args: []string{ + "base", "+form-create", + "--base-token", baseToken, + "--table-id", tableID, + "--name", "Display Mode " + suffix, + }, + DefaultAs: "bot", + }, clie2e.RetryOptions{}) + require.NoError(t, err) + createResult.AssertExitCode(t, 0) + createResult.AssertStdoutStatus(t, true) + formID := gjson.Get(createResult.Stdout, "data.id").String() + if formID == "" { + formID = gjson.Get(createResult.Stdout, "data.form_id").String() + } + require.NotEmpty(t, formID, "stdout:\n%s", createResult.Stdout) + + for _, tt := range []struct { + mode string + want int64 + }{ + {mode: "step", want: 2}, + {mode: "list", want: 1}, + } { + t.Run(tt.mode, func(t *testing.T) { + updateResult, runErr := clie2e.RunCmdWithRetry(ctx, clie2e.Request{ + Args: []string{ + "base", "+form-update", + "--base-token", baseToken, + "--table-id", tableID, + "--form-id", formID, + "--display-mode", tt.mode, + }, + DefaultAs: "bot", + }, clie2e.RetryOptions{}) + require.NoError(t, runErr) + updateResult.AssertExitCode(t, 0) + updateResult.AssertStdoutStatus(t, true) + require.Equal(t, tt.want, gjson.Get(updateResult.Stdout, "data.display_mode").Int(), "stdout:\n%s", updateResult.Stdout) + + var lastGet *clie2e.Result + require.Eventually(t, func() bool { + lastGet, runErr = clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "base", "+form-get", + "--base-token", baseToken, + "--table-id", tableID, + "--form-id", formID, + }, + DefaultAs: "bot", + }) + return runErr == nil && lastGet.ExitCode == 0 && gjson.Get(lastGet.Stdout, "data.display_mode").Int() == tt.want + }, 30*time.Second, time.Second, "form display_mode did not become %d; last result=%+v err=%v", tt.want, lastGet, runErr) + }) + } +} diff --git a/tests/cli_e2e/base/coverage.md b/tests/cli_e2e/base/coverage.md index 1d4928ff48..138f062bb7 100644 --- a/tests/cli_e2e/base/coverage.md +++ b/tests/cli_e2e/base/coverage.md @@ -2,8 +2,8 @@ ## Metrics - Denominator: 99 leaf commands -- Covered: 49 -- Coverage: 49.5% +- Covered: 52 +- Coverage: 52.5% ## Summary - TestBase_BasicWorkflow: proves `+base-create`, `+base-get`, `+table-create`, `+table-get`, and `+table-list`; key `t.Run(...)` proof points are `get base as bot`, `get table as bot`, and `list tables and find created table as bot`. @@ -14,6 +14,8 @@ - TestBaseFieldExtensionDryRun: proves `+field-extension-get`, `+field-extension-update`, and `+field-extension-update-cells` request shapes, including row-vs-column update bodies; TestBaseFieldExtensionUpdateCellsDryRunRejectsRowWithoutRecordID proves row updates require an explicit record. - TestBaseFormQuestionsCreateDryRun: proves `+form-questions-create` preserves its POST body and renders the existing-question guard in command help. - TestBaseFormDetailDryRun / TestBaseFormSubmitDryRun: prove shared-form detail and submission request shapes. +- TestBaseFormUpdateDisplayModeDryRun: proves `+form-update --display-mode=list|step` emits the form PATCH path and maps to `display_mode=1|2` without unrelated fields. +- TestBaseFormUpdateDisplayModeWorkflow: deployment-gated by `LARK_CLI_E2E_BASE_FORM_DISPLAY_MODE_READY=1`; creates an isolated Base, table, and form, switches step/list modes, reads each value back with `+form-get`, and removes the Base fixture. - TestBaseDashboardBlockGetDataDryRun: proves dashboard block data request shapes and identifier handling. - TestBaseDashboardBlockLayoutPrecisionWorkflow: creates a temporary Base/table/dashboard, creates a statistics block with `position` and omitted `number_format`, asserts the server default, updates to a custom format, then verifies a precision-only update preserves `formatName`, and cleans up the block/dashboard/base. `+dashboard-create`, `+dashboard-delete`, `+dashboard-block-get` and `+dashboard-block-delete` have no dry-run coverage and rest on this test alone. This workflow was executed successfully against a live tenant on 2026-08-20 while validating PR #2118. - TestBaseDashboardBlockRankingCreateDryRun / TestBaseDashboardBlockRankingUpdateDryRunPreservesPatch / TestBaseDashboardBlockRankingDryRunRejectsInvalidConfig: prove ranking create defaults, top-level patch preservation, and typed validation failures for unsupported fields and malformed filters. @@ -30,7 +32,7 @@ - TestBaseTableCopyWorkflow: feature-gated by `LARK_CLI_E2E_BASE_TABLE_COPY_READY=1` until the OpenAPI is deployed; creates a source table and record, proves schema-only copy, all no-wait plus status, all wait, record inclusion, and cleanup. - TestBaseTemplateCenterDryRun: proves `+template-categories`, `+template-list`, and `+template-search` request shapes; the list case covers category, limit, and offset parameters. - Cleanup note: `+table-delete` and `+role-delete` only run in cleanup and are intentionally left uncovered. -- Blocked area: table-copy live integration remains deployment-gated; remaining dashboard, field, most record operations, most form operations, view, and workflow operations still lack deterministic create/read/update workflows in this suite. +- Blocked area: table-copy and form display-mode live integration remain deployment-gated; remaining dashboard, field, most record operations, form question reads, view, and workflow operations still lack deterministic create/read/update workflows in this suite. ## Command Table @@ -73,10 +75,10 @@ | ✕ | base +field-list | shortcut | | none | field workflows not covered | | ✕ | base +field-search-options | shortcut | | none | field workflows not covered | | ✕ | base +field-update | shortcut | | none | field workflows not covered | -| ✕ | base +form-create | shortcut | | none | form workflows not covered | +| ✓ | base +form-create | shortcut | base_form_update_workflow_test.go::TestBaseFormUpdateDisplayModeWorkflow | `--base-token`; `--table-id`; `--name`; deployment-gated live | creates the isolated form fixture | | ✕ | base +form-delete | shortcut | | none | form workflows not covered | | ✓ | base +form-detail | shortcut | base_form_detail_dryrun_test.go::TestBaseFormDetailDryRun | `--share-token`; dry-run only | shared-form request shape | -| ✕ | base +form-get | shortcut | | none | form workflows not covered | +| ✓ | base +form-get | shortcut | base_form_update_workflow_test.go::TestBaseFormUpdateDisplayModeWorkflow | `--base-token`; `--table-id`; `--form-id`; deployment-gated live | reads back both display modes | | ✓ | base +form-list | shortcut | base_form_detail_dryrun_test.go::TestBaseFormListDryRun_UsesBaseAndTableIdentifiers | `--base-token`; `--table-id`; dry-run only | request shape only | | ✓ | base +form-share-get | shortcut | base_share_dryrun_test.go::TestBaseShareDryRun/form get; base_share_workflow_test.go::TestBaseShareWorkflow/form share update and get | `--base-token`; `--table-id`; `--form-id`; dry-run + deployment-gated live | live requires `LARK_CLI_E2E_BASE_SHARE_READY=1` | | ✓ | base +form-share-update | shortcut | base_share_dryrun_test.go::TestBaseShareDryRun/form settings update; base_share_workflow_test.go::TestBaseShareWorkflow/form share update and get | one of share enablement; `access-scope=invite`; anonymous/login settings per request | single-field updates, login-plus-anonymous across separate requests, explicit false, and live read-back covered | @@ -85,7 +87,7 @@ | ✕ | base +form-questions-list | shortcut | | none | form workflows not covered | | ✓ | base +form-questions-update | shortcut | TestBaseFormQuestionsUpdateVisibleRuleDryRun | questions[].visible_rule | dry-run: request shape + visible_rule body passthrough | | ✓ | base +form-submit | shortcut | base_form_submit_dryrun_test.go::TestBaseFormSubmitDryRun | `--share-token`; `--json`; dry-run only | submission request shape | -| ✕ | base +form-update | shortcut | | none | form workflows not covered | +| ✓ | base +form-update | shortcut | base_form_update_dryrun_test.go::TestBaseFormUpdateDisplayModeDryRun; base_form_update_workflow_test.go::TestBaseFormUpdateDisplayModeWorkflow | `--display-mode=list|step`; dry-run + deployment-gated live | request body and live read-back; live requires `LARK_CLI_E2E_BASE_FORM_DISPLAY_MODE_READY=1` | | ✓ | base +record-batch-create | shortcut | base_record_batch_update_workflow_test.go::TestBaseRecordBatchUpdatePerRecordWorkflow | `--base-token`; `--table-id`; `--json.create_records` | seeds heterogeneous live workflow records | | ✓ | base +record-batch-update | shortcut | base_record_batch_update_dryrun_test.go::TestBaseRecordBatchUpdatePerRecordDryRun; base_record_batch_update_workflow_test.go::TestBaseRecordBatchUpdatePerRecordWorkflow | `--base-token`; `--table-id`; `--json.update_records`; dry-run + live | heterogeneous select/number update with write-back verification | | ✕ | base +record-delete | shortcut | | none | record workflows not covered |