feat(base): add dashboard and form share shortcuts (#2282)

- preserve explicit false values and validate partial share updates
- add dry-run and deployment-gated live E2E coverage
- document share routing in the bundled Base skill
This commit is contained in:
wanghm25
2026-08-20 22:23:40 +08:00
committed by GitHub
parent bbcdf65110
commit da371dc242
10 changed files with 903 additions and 7 deletions
+39 -2
View File
@@ -172,8 +172,8 @@ func TestShortcutsCatalog(t *testing.T) {
"+data-query",
"+form-create", "+form-delete", "+form-list", "+form-update", "+form-get", "+form-detail",
"+form-questions-create", "+form-questions-delete", "+form-questions-update", "+form-questions-list",
"+form-submit",
"+dashboard-list", "+dashboard-get", "+dashboard-create", "+dashboard-update", "+dashboard-delete", "+dashboard-arrange",
"+form-submit", "+form-share-get", "+form-share-update",
"+dashboard-list", "+dashboard-get", "+dashboard-share-get", "+dashboard-share-update", "+dashboard-create", "+dashboard-update", "+dashboard-delete", "+dashboard-arrange",
"+dashboard-block-list", "+dashboard-block-get", "+dashboard-block-get-data", "+dashboard-block-create", "+dashboard-block-update", "+dashboard-block-delete",
"+workspace-create", "+workspace-entity-list", "+workspace-move-in",
"+app-create", "+app-get",
@@ -190,6 +190,43 @@ func TestShortcutsCatalog(t *testing.T) {
}
}
func TestShareManagementShortcutScopes(t *testing.T) {
tests := []struct {
name string
scopes []string
want []string
}{
{
name: "dashboard get requires update scope",
scopes: BaseDashboardShareGet.Scopes,
want: []string{"base:dashboard:update"},
},
{
name: "dashboard update requires update scope",
scopes: BaseDashboardShareUpdate.Scopes,
want: []string{"base:dashboard:update"},
},
{
name: "form get requires update scope",
scopes: BaseFormShareGet.Scopes,
want: []string{"base:form:update"},
},
{
name: "form update requires update scope",
scopes: BaseFormShareUpdate.Scopes,
want: []string{"base:form:update"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if !reflect.DeepEqual(tt.scopes, tt.want) {
t.Fatalf("Scopes=%v want=%v", tt.scopes, tt.want)
}
})
}
}
func TestShortcutsDryRunCoverage(t *testing.T) {
for _, shortcut := range Shortcuts() {
if shortcut.DryRun == nil {
+104
View File
@@ -0,0 +1,104 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"context"
"github.com/larksuite/cli/shortcuts/common"
)
var dashboardShareUpdateFlagNames = []string{
"enabled",
"access-scope",
"show-source",
"enable-auto-analysis",
}
var BaseDashboardShareGet = common.Shortcut{
Service: "base",
Command: "+dashboard-share-get",
Description: "Get dashboard share status and settings",
Risk: "read",
Scopes: []string{"base:dashboard:update"},
AuthTypes: authTypes(),
Flags: []common.Flag{
baseTokenFlag(true),
dashboardIDFlag(true),
},
DryRun: func(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
GET("/open-apis/base/v3/bases/:base_token/dashboards/:dashboard_id/share").
Set("base_token", runtime.Str("base-token")).
Set("dashboard_id", runtime.Str("dashboard-id"))
},
Execute: func(_ context.Context, runtime *common.RuntimeContext) error {
data, err := baseV3Call(runtime, "GET", baseV3Path(
"bases", runtime.Str("base-token"), "dashboards", runtime.Str("dashboard-id"), "share",
), nil, nil)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}
var BaseDashboardShareUpdate = common.Shortcut{
Service: "base",
Command: "+dashboard-share-update",
Description: "Update dashboard share status and settings",
Risk: "write",
Scopes: []string{"base:dashboard:update"},
AuthTypes: authTypes(),
Flags: []common.Flag{
baseTokenFlag(true),
dashboardIDFlag(true),
{Name: "enabled", Type: "bool", Desc: "enable or disable dashboard sharing"},
{Name: "access-scope", Desc: "share access scope", Enum: shareAccessScopeEnums},
{Name: "show-source", Type: "bool", Desc: "show the entry back to the source Base"},
{Name: "enable-auto-analysis", Type: "bool", Desc: "enable intelligent analysis on the shared dashboard"},
},
Tips: []string{
"Boolean settings use PATCH semantics: pass --show-source=false or --enable-auto-analysis=false to explicitly turn them off.",
"Update exactly one field per invocation; run separate commands to change multiple share fields.",
},
Validate: func(_ context.Context, runtime *common.RuntimeContext) error {
return validateSingleShareUpdate(runtime, dashboardShareUpdateFlagNames...)
},
DryRun: func(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
PATCH("/open-apis/base/v3/bases/:base_token/dashboards/:dashboard_id/share").
Body(buildDashboardShareUpdateBody(runtime)).
Set("base_token", runtime.Str("base-token")).
Set("dashboard_id", runtime.Str("dashboard-id"))
},
Execute: func(_ context.Context, runtime *common.RuntimeContext) error {
data, err := baseV3Call(runtime, "PATCH", baseV3Path(
"bases", runtime.Str("base-token"), "dashboards", runtime.Str("dashboard-id"), "share",
), nil, buildDashboardShareUpdateBody(runtime))
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}
func buildDashboardShareUpdateBody(runtime *common.RuntimeContext) map[string]interface{} {
body := map[string]interface{}{}
addCommonShareUpdateFields(runtime, body)
settings := map[string]interface{}{}
if runtime.Changed("show-source") {
settings["show_source"] = runtime.Bool("show-source")
}
if runtime.Changed("enable-auto-analysis") {
settings["enable_auto_analysis"] = runtime.Bool("enable-auto-analysis")
}
if len(settings) > 0 {
body["settings"] = settings
}
return body
}
+114
View File
@@ -0,0 +1,114 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"context"
"github.com/larksuite/cli/shortcuts/common"
)
var formShareUpdateFlagNames = []string{
"enabled",
"access-scope",
"allow-anonymous",
"require-login",
}
var BaseFormShareGet = common.Shortcut{
Service: "base",
Command: "+form-share-get",
Description: "Get form share status and settings",
Risk: "read",
Scopes: []string{"base:form:update"},
AuthTypes: authTypes(),
Flags: []common.Flag{
baseTokenFlag(true),
{Name: "table-id", Desc: "table ID", Required: true},
{Name: "form-id", Desc: "form ID", Required: true},
},
DryRun: func(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
GET("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms/:form_id/share").
Set("base_token", runtime.Str("base-token")).
Set("table_id", runtime.Str("table-id")).
Set("form_id", runtime.Str("form-id"))
},
Execute: func(_ context.Context, runtime *common.RuntimeContext) error {
data, err := baseV3Call(runtime, "GET", baseV3Path(
"bases", runtime.Str("base-token"), "tables", runtime.Str("table-id"), "forms", runtime.Str("form-id"), "share",
), nil, nil)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}
var BaseFormShareUpdate = common.Shortcut{
Service: "base",
Command: "+form-share-update",
Description: "Update form share status and settings",
Risk: "write",
Scopes: []string{"base:form:update"},
AuthTypes: authTypes(),
Flags: []common.Flag{
baseTokenFlag(true),
{Name: "table-id", Desc: "table ID", Required: true},
{Name: "form-id", Desc: "form ID", Required: true},
{Name: "enabled", Type: "bool", Desc: "enable or disable form sharing"},
{Name: "access-scope", Desc: "share access scope", Enum: shareAccessScopeEnums},
{Name: "allow-anonymous", Type: "bool", Desc: "anonymize the submitter identity"},
{Name: "require-login", Type: "bool", Desc: "require submitters to sign in before submitting"},
},
Tips: []string{
"Boolean settings use PATCH semantics: pass --allow-anonymous=false or another boolean flag with =false to explicitly turn it off.",
"--allow-anonymous controls submitter identity and --require-login controls sign-in; run separate update commands to change both settings.",
"Update exactly one field per invocation; run separate commands to change multiple share fields.",
},
Validate: func(_ context.Context, runtime *common.RuntimeContext) error {
return validateFormShareUpdate(runtime)
},
DryRun: func(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
PATCH("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms/:form_id/share").
Body(buildFormShareUpdateBody(runtime)).
Set("base_token", runtime.Str("base-token")).
Set("table_id", runtime.Str("table-id")).
Set("form_id", runtime.Str("form-id"))
},
Execute: func(_ context.Context, runtime *common.RuntimeContext) error {
data, err := baseV3Call(runtime, "PATCH", baseV3Path(
"bases", runtime.Str("base-token"), "tables", runtime.Str("table-id"), "forms", runtime.Str("form-id"), "share",
), nil, buildFormShareUpdateBody(runtime))
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}
func validateFormShareUpdate(runtime *common.RuntimeContext) error {
return validateSingleShareUpdate(runtime, formShareUpdateFlagNames...)
}
func buildFormShareUpdateBody(runtime *common.RuntimeContext) map[string]interface{} {
body := map[string]interface{}{}
addCommonShareUpdateFields(runtime, body)
settings := map[string]interface{}{}
if runtime.Changed("allow-anonymous") {
settings["allow_anonymous"] = runtime.Bool("allow-anonymous")
}
if runtime.Changed("require-login") {
settings["require_login"] = runtime.Bool("require-login")
}
if len(settings) > 0 {
body["settings"] = settings
}
return body
}
+48
View File
@@ -0,0 +1,48 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
var shareAccessScopeEnums = []string{"invite", "tenant", "anyone"}
func validateSingleShareUpdate(runtime *common.RuntimeContext, flagNames ...string) error {
changedNames := make([]string, 0, len(flagNames))
for _, name := range flagNames {
if runtime.Changed(name) {
changedNames = append(changedNames, name)
}
}
switch len(changedNames) {
case 1:
return nil
case 0:
return errs.NewValidationError(errs.SubtypeInvalidArgument, "exactly one share field must be provided").
WithHint("use one of: %s", shareFlagNames(flagNames))
default:
return baseFlagErrorf("share update accepts exactly one field; do not combine %s", shareFlagNames(changedNames))
}
}
func shareFlagNames(flagNames []string) string {
names := make([]string, 0, len(flagNames))
for _, name := range flagNames {
names = append(names, "--"+name)
}
return strings.Join(names, ", ")
}
func addCommonShareUpdateFields(runtime *common.RuntimeContext, body map[string]interface{}) {
if runtime.Changed("enabled") {
body["enabled"] = runtime.Bool("enabled")
}
if runtime.Changed("access-scope") {
body["access_scope"] = runtime.Str("access-scope")
}
}
+354
View File
@@ -0,0 +1,354 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"reflect"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/httpmock"
)
func TestDashboardShareGetCallsResourceEndpoint(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/base/v3/bases/app_x/dashboards/dsh_1/share",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"enabled": true,
"access_scope": "tenant",
},
},
})
err := runShortcut(t, BaseDashboardShareGet, []string{
"+dashboard-share-get",
"--base-token", "app_x",
"--dashboard-id", "dsh_1",
}, factory, stdout)
if err != nil {
t.Fatalf("run shortcut: %v", err)
}
if got := stdout.String(); !strings.Contains(got, `"access_scope": "tenant"`) {
t.Fatalf("stdout=%s", got)
}
}
func TestDashboardShareUpdatePreservesExplicitFalse(t *testing.T) {
tests := []struct {
name string
flag string
want map[string]interface{}
}{
{
name: "show source",
flag: "--show-source=false",
want: map[string]interface{}{"settings": map[string]interface{}{"show_source": false}},
},
{
name: "auto analysis",
flag: "--enable-auto-analysis=false",
want: map[string]interface{}{"settings": map[string]interface{}{"enable_auto_analysis": false}},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
stub := &httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/base/v3/bases/app_x/dashboards/dsh_1/share",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"enabled": true},
},
}
reg.Register(stub)
err := runShortcut(t, BaseDashboardShareUpdate, []string{
"+dashboard-share-update",
"--base-token", "app_x",
"--dashboard-id", "dsh_1",
tt.flag,
}, factory, stdout)
if err != nil {
t.Fatalf("run shortcut: %v", err)
}
if got := decodeCapturedJSONBody(t, stub); !reflect.DeepEqual(got, tt.want) {
t.Fatalf("request body=%#v, want %#v", got, tt.want)
}
})
}
}
func TestDashboardShareUpdateBuildsCommonFields(t *testing.T) {
tests := []struct {
name string
flags []string
want map[string]interface{}
}{
{name: "enabled", flags: []string{"--enabled=true"}, want: map[string]interface{}{"enabled": true}},
{name: "access scope", flags: []string{"--access-scope", "invite"}, want: map[string]interface{}{"access_scope": "invite"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
stub := &httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/base/v3/bases/app_x/dashboards/dsh_1/share",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"enabled": true},
},
}
reg.Register(stub)
args := []string{
"+dashboard-share-update",
"--base-token", "app_x",
"--dashboard-id", "dsh_1",
}
args = append(args, tt.flags...)
if err := runShortcut(t, BaseDashboardShareUpdate, args, factory, stdout); err != nil {
t.Fatalf("run shortcut: %v", err)
}
if got := decodeCapturedJSONBody(t, stub); !reflect.DeepEqual(got, tt.want) {
t.Fatalf("request body=%#v, want %#v", got, tt.want)
}
})
}
}
func TestDashboardShareUpdatePreservesExplicitFalseForEnabled(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
stub := &httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/base/v3/bases/app_x/dashboards/dsh_1/share",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"enabled": false},
},
}
reg.Register(stub)
err := runShortcut(t, BaseDashboardShareUpdate, []string{
"+dashboard-share-update",
"--base-token", "app_x",
"--dashboard-id", "dsh_1",
"--enabled=false",
}, factory, stdout)
if err != nil {
t.Fatalf("run shortcut: %v", err)
}
want := map[string]interface{}{"enabled": false}
if got := decodeCapturedJSONBody(t, stub); !reflect.DeepEqual(got, want) {
t.Fatalf("request body=%#v, want %#v", got, want)
}
}
func TestFormShareGetCallsResourceEndpoint(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_1/forms/vew_1/share",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"enabled": false,
"access_scope": "tenant",
},
},
})
err := runShortcut(t, BaseFormShareGet, []string{
"+form-share-get",
"--base-token", "app_x",
"--table-id", "tbl_1",
"--form-id", "vew_1",
}, factory, stdout)
if err != nil {
t.Fatalf("run shortcut: %v", err)
}
if got := stdout.String(); !strings.Contains(got, `"enabled": false`) {
t.Fatalf("stdout=%s", got)
}
}
func TestFormShareUpdateBuildsAccessScope(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
stub := &httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_1/forms/vew_1/share",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"enabled": true},
},
}
reg.Register(stub)
err := runShortcut(t, BaseFormShareUpdate, []string{
"+form-share-update",
"--base-token", "app_x",
"--table-id", "tbl_1",
"--form-id", "vew_1",
"--access-scope", "anyone",
}, factory, stdout)
if err != nil {
t.Fatalf("run shortcut: %v", err)
}
want := map[string]interface{}{"access_scope": "anyone"}
if got := decodeCapturedJSONBody(t, stub); !reflect.DeepEqual(got, want) {
t.Fatalf("request body=%#v, want %#v", got, want)
}
}
func TestFormShareUpdateBuildsSingleSettingsField(t *testing.T) {
tests := []struct {
name string
flag string
want map[string]interface{}
}{
{
name: "allow anonymous false",
flag: "--allow-anonymous=false",
want: map[string]interface{}{"settings": map[string]interface{}{"allow_anonymous": false}},
},
{
name: "require login true",
flag: "--require-login=true",
want: map[string]interface{}{"settings": map[string]interface{}{"require_login": true}},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
stub := &httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_1/forms/vew_1/share",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"enabled": true},
},
}
reg.Register(stub)
err := runShortcut(t, BaseFormShareUpdate, []string{
"+form-share-update",
"--base-token", "app_x",
"--table-id", "tbl_1",
"--form-id", "vew_1",
tt.flag,
}, factory, stdout)
if err != nil {
t.Fatalf("run shortcut: %v", err)
}
if got := decodeCapturedJSONBody(t, stub); !reflect.DeepEqual(got, tt.want) {
t.Fatalf("request body=%#v, want %#v", got, tt.want)
}
})
}
}
func TestFormShareUpdatePreservesExplicitFalseForEnabled(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
stub := &httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_1/forms/vew_1/share",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"enabled": false},
},
}
reg.Register(stub)
err := runShortcut(t, BaseFormShareUpdate, []string{
"+form-share-update",
"--base-token", "app_x",
"--table-id", "tbl_1",
"--form-id", "vew_1",
"--enabled=false",
}, factory, stdout)
if err != nil {
t.Fatalf("run shortcut: %v", err)
}
want := map[string]interface{}{"enabled": false}
if got := decodeCapturedJSONBody(t, stub); !reflect.DeepEqual(got, want) {
t.Fatalf("request body=%#v, want %#v", got, want)
}
}
func TestDashboardShareUpdateRejectsMultipleFields(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcut(t, BaseDashboardShareUpdate, []string{
"+dashboard-share-update",
"--base-token", "app_x",
"--dashboard-id", "dsh_1",
"--enabled=false",
"--access-scope", "tenant",
"--show-source=true",
}, factory, stdout)
assertInvalidArgumentValidation(t, err, "--enabled", []string{"--enabled", "--access-scope", "--show-source"}, "exactly one")
}
func TestFormShareUpdateRejectsMultipleSettings(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcut(t, BaseFormShareUpdate, []string{
"+form-share-update",
"--base-token", "app_x",
"--table-id", "tbl_1",
"--form-id", "vew_1",
"--allow-anonymous=true",
"--require-login=true",
}, factory, stdout)
assertInvalidArgumentValidation(t, err, "--allow-anonymous", []string{"--allow-anonymous", "--require-login"}, "exactly one")
}
func TestShareUpdateRejectsUnsupportedAccessScope(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcut(t, BaseDashboardShareUpdate, []string{
"+dashboard-share-update",
"--base-token", "app_x",
"--dashboard-id", "dsh_1",
"--access-scope", "off",
}, factory, stdout)
assertInvalidArgumentValidation(t, err, "--access-scope", nil, "allowed")
}
func TestShareUpdateRequiresExactlyOneChange(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcut(t, BaseFormShareUpdate, []string{
"+form-share-update",
"--base-token", "app_x",
"--table-id", "tbl_1",
"--form-id", "vew_1",
}, factory, stdout)
assertInvalidArgumentValidation(t, err, "", []string{}, "exactly one")
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T %v", err, err)
}
for _, flag := range []string{
"--enabled",
"--access-scope",
"--allow-anonymous",
"--require-login",
} {
if !strings.Contains(problem.Hint, flag) {
t.Fatalf("hint=%q, want flag %q", problem.Hint, flag)
}
}
}
+4
View File
@@ -85,8 +85,12 @@ func Shortcuts() []common.Shortcut {
BaseFormQuestionsUpdate,
BaseFormQuestionsList,
BaseFormSubmit,
BaseFormShareGet,
BaseFormShareUpdate,
BaseDashboardList,
BaseDashboardGet,
BaseDashboardShareGet,
BaseDashboardShareUpdate,
BaseDashboardCreate,
BaseDashboardUpdate,
BaseDashboardDelete,
+5 -2
View File
@@ -1,6 +1,6 @@
---
name: lark-base
version: 1.2.19
version: 1.2.20
description: "飞书多维表格(Base)操作:建表、字段、记录、视图、统计、公式/lookup、表单、仪表盘、应用模式(BaseApp/AppMode 页面与组件)、Workspace 目录、workflow、角色权限;遇到 Base/多维表格/bitable、BaseApp/AppMode、/base/ 或 /app/ 链接时使用。BaseApp 不走 lark-apps;文件导入/导出转 lark-drive,认证/授权转 lark-shared。"
metadata:
requires:
@@ -98,12 +98,15 @@ Form 依附于 Table,以 Field 作为题目,每次有效提交会创建一
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`
3. **填写分享表单并提交:** 对表单分享链接使用 `+url-resolve` 取得 `share_token`,按 [Form detail](references/lark-base-form-detail.md) 执行 `+form-detail` 读取真实题目、必填项和显示条件,再按 [Form submit](references/lark-base-form-submit.md) 构造字段与附件并执行 `+form-submit`
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`
## Dashboard Block
Dashboard Block 是 Base Block 树中的仪表盘容器,负责承载页面主题、布局和内部组件集合,本身不表示某一项图表数据。使用 `+dashboard-list` 定位容器,`+dashboard-get` 读取容器信息,`+dashboard-update` 修改主题,`+dashboard-arrange` 统一编排内部组件布局。
**管理 Dashboard 分享:** 使用 `+dashboard-share-get` / `+dashboard-share-update` 管理启停、访问范围、返回源 Base 入口和智能分析;更新前先读取现状,每次只修改一个字段,显式 `false` 会被保留。
容器内部的图表、指标卡和文本等组件在 Dashboard API 中也称为 Block,但不属于 Base Block 树。内部 Block 分为三条操作路径:
1. **读取配置:** `+dashboard-block-list` / `+dashboard-block-get` 读取组件类型、布局和 `data_config`;文本组件的正文也属于配置。
@@ -0,0 +1,74 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestBaseShareDryRun(t *testing.T) {
t.Run("dashboard get", func(t *testing.T) {
result := runBaseDryRun(t, 0,
"base", "+dashboard-share-get",
"--base-token", "app_x",
"--dashboard-id", "dsh_1",
)
assert.Contains(t, result.Stdout, `"method": "GET"`)
assert.Contains(t, result.Stdout, "/open-apis/base/v3/bases/app_x/dashboards/dsh_1/share")
})
t.Run("dashboard partial update", func(t *testing.T) {
result := runBaseDryRun(t, 0,
"base", "+dashboard-share-update",
"--base-token", "app_x",
"--dashboard-id", "dsh_1",
"--show-source=false",
)
assert.Contains(t, result.Stdout, `"method": "PATCH"`)
assert.Contains(t, result.Stdout, "/open-apis/base/v3/bases/app_x/dashboards/dsh_1/share")
assert.Contains(t, result.Stdout, `"show_source": false`)
assert.NotContains(t, result.Stdout, `"enabled":`)
assert.NotContains(t, result.Stdout, `"enable_auto_analysis":`)
})
t.Run("form get", func(t *testing.T) {
result := runBaseDryRun(t, 0,
"base", "+form-share-get",
"--base-token", "app_x",
"--table-id", "tbl_1",
"--form-id", "vew_1",
)
assert.Contains(t, result.Stdout, `"method": "GET"`)
assert.Contains(t, result.Stdout, "/open-apis/base/v3/bases/app_x/tables/tbl_1/forms/vew_1/share")
})
t.Run("form settings update", func(t *testing.T) {
result := runBaseDryRun(t, 0,
"base", "+form-share-update",
"--base-token", "app_x",
"--table-id", "tbl_1",
"--form-id", "vew_1",
"--allow-anonymous=true",
)
assert.Contains(t, result.Stdout, `"method": "PATCH"`)
assert.Contains(t, result.Stdout, "/open-apis/base/v3/bases/app_x/tables/tbl_1/forms/vew_1/share")
assert.Contains(t, result.Stdout, `"allow_anonymous": true`)
assert.NotContains(t, result.Stdout, `"enabled":`)
assert.NotContains(t, result.Stdout, `"require_login":`)
})
t.Run("form submission policy is not exposed", func(t *testing.T) {
result := runBaseDryRun(t, 2,
"base", "+form-share-update",
"--base-token", "app_x",
"--table-id", "tbl_1",
"--form-id", "vew_1",
"--valid-period-enabled=true",
)
assert.Contains(t, result.Stderr, "unknown flag")
assert.Contains(t, result.Stderr, "--valid-period-enabled")
})
}
@@ -0,0 +1,152 @@
// 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 TestBaseShareWorkflow(t *testing.T) {
if os.Getenv("LARK_CLI_E2E_BASE_SHARE_READY") != "1" {
t.Skip("set LARK_CLI_E2E_BASE_SHARE_READY=1 after the dashboard/form share OpenAPI is deployed")
}
clie2e.SkipWithoutTenantAccessToken(t)
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 6*time.Minute)
t.Cleanup(cancel)
baseToken := createBaseWithRetry(t, ctx, "lark-cli-e2e-base-share-"+clie2e.GenerateSuffix())
tableID, _, _ := createTableWithRetry(
t,
parentT,
ctx,
baseToken,
"Share workflow "+clie2e.GenerateSuffix(),
`[{"name":"Name","type":"text"}]`,
`{"name":"Main","type":"grid"}`,
)
formCreate, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"base", "+form-create",
"--base-token", baseToken,
"--table-id", tableID,
"--name", "Share form " + clie2e.GenerateSuffix(),
},
DefaultAs: "bot",
})
require.NoError(t, err)
formCreate.AssertExitCode(t, 0)
formCreate.AssertStdoutStatus(t, true)
formID := gjson.Get(formCreate.Stdout, "data.id").String()
require.NotEmpty(t, formID, formCreate.Stdout)
dashboardCreate, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"base", "+dashboard-create",
"--base-token", baseToken,
"--name", "Share dashboard " + clie2e.GenerateSuffix(),
},
DefaultAs: "bot",
})
require.NoError(t, err)
dashboardCreate.AssertExitCode(t, 0)
dashboardCreate.AssertStdoutStatus(t, true)
dashboardID := gjson.Get(dashboardCreate.Stdout, "data.dashboard.dashboard_id").String()
require.NotEmpty(t, dashboardID, dashboardCreate.Stdout)
t.Cleanup(func() {
cleanupCtx, cleanupCancel := cleanupContext()
defer cleanupCancel()
for _, args := range [][]string{
{"base", "+dashboard-share-update", "--base-token", baseToken, "--dashboard-id", dashboardID, "--enabled=false"},
{"base", "+form-share-update", "--base-token", baseToken, "--table-id", tableID, "--form-id", formID, "--enabled=false"},
} {
result, cleanupErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{Args: args, DefaultAs: "bot"})
if cleanupErr != nil || result == nil || result.ExitCode != 0 {
reportCleanupFailure(parentT, "disable share", result, cleanupErr)
}
}
})
t.Run("dashboard share update and get", func(t *testing.T) {
runUpdate := func(fieldArgs ...string) {
args := append([]string{
"base", "+dashboard-share-update",
"--base-token", baseToken,
"--dashboard-id", dashboardID,
}, fieldArgs...)
update, runErr := clie2e.RunCmd(ctx, clie2e.Request{Args: args, DefaultAs: "bot"})
require.NoError(t, runErr)
update.AssertExitCode(t, 0)
update.AssertStdoutStatus(t, true)
}
runUpdate("--enabled=true")
runUpdate("--access-scope", "invite")
runUpdate("--show-source=true")
runUpdate("--enable-auto-analysis=true")
runUpdate("--show-source=false")
runUpdate("--enable-auto-analysis=false")
get, runErr := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"base", "+dashboard-share-get", "--base-token", baseToken, "--dashboard-id", dashboardID},
DefaultAs: "bot",
})
require.NoError(t, runErr)
get.AssertExitCode(t, 0)
get.AssertStdoutStatus(t, true)
require.True(t, gjson.Get(get.Stdout, "data.enabled").Bool(), get.Stdout)
require.Equal(t, "invite", gjson.Get(get.Stdout, "data.access_scope").String(), get.Stdout)
showSource := gjson.Get(get.Stdout, "data.settings.show_source")
require.True(t, showSource.Exists(), get.Stdout)
require.False(t, showSource.Bool(), get.Stdout)
autoAnalysis := gjson.Get(get.Stdout, "data.settings.enable_auto_analysis")
require.True(t, autoAnalysis.Exists(), get.Stdout)
require.False(t, autoAnalysis.Bool(), get.Stdout)
})
t.Run("form share update and get", func(t *testing.T) {
runUpdate := func(fieldArgs ...string) {
args := append([]string{
"base", "+form-share-update",
"--base-token", baseToken,
"--table-id", tableID,
"--form-id", formID,
}, fieldArgs...)
update, runErr := clie2e.RunCmd(ctx, clie2e.Request{Args: args, DefaultAs: "bot"})
require.NoError(t, runErr)
update.AssertExitCode(t, 0)
update.AssertStdoutStatus(t, true)
}
runUpdate("--enabled=true")
runUpdate("--access-scope", "invite")
runUpdate("--allow-anonymous=true")
runUpdate("--require-login=true")
runUpdate("--allow-anonymous=false")
get, runErr := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"base", "+form-share-get", "--base-token", baseToken, "--table-id", tableID, "--form-id", formID},
DefaultAs: "bot",
})
require.NoError(t, runErr)
get.AssertExitCode(t, 0)
get.AssertStdoutStatus(t, true)
require.True(t, gjson.Get(get.Stdout, "data.enabled").Bool(), get.Stdout)
require.Equal(t, "invite", gjson.Get(get.Stdout, "data.access_scope").String(), get.Stdout)
allowAnonymous := gjson.Get(get.Stdout, "data.settings.allow_anonymous")
require.True(t, allowAnonymous.Exists(), get.Stdout)
require.False(t, allowAnonymous.Bool(), get.Stdout)
requireLogin := gjson.Get(get.Stdout, "data.settings.require_login")
require.True(t, requireLogin.Exists(), get.Stdout)
require.True(t, requireLogin.Bool(), get.Stdout)
})
}
+9 -3
View File
@@ -1,9 +1,9 @@
# Base CLI E2E Coverage
## Metrics
- Denominator: 89 leaf commands
- Covered: 37
- Coverage: 41.6%
- Denominator: 93 leaf commands
- Covered: 41
- Coverage: 44.1%
## 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`.
@@ -15,6 +15,8 @@
- TestBaseFormDetailDryRun / TestBaseFormSubmitDryRun: prove shared-form detail and submission request shapes.
- 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.
- TestBaseShareDryRun: proves dashboard/form share GET and PATCH routes, one-field update requests, explicit false preservation, and nested form settings without touching live data.
- TestBaseShareWorkflow: deployment-gated by `LARK_CLI_E2E_BASE_SHARE_READY=1`; creates a Base, table, form, and dashboard, updates each share field in a separate request, verifies get round trips for both resources, disables sharing, and cleans up the Base.
- TestBaseRecordBatchUpdatePerRecordDryRun: proves `+record-batch-update` preserves the per-record `update_records` request shape.
- TestBaseRecordBatchUpdatePerRecordWorkflow: creates two records, updates different field types in one request, asserts the minimal response contract, reads both records back, verifies a missing record ID is not prevalidated, and cleans up the temporary Base.
- TestBaseRecordHistoryListDryRunUsesExplicitRecordID / TestBaseRecordHistoryListDryRunRejectsNonPositiveMaxVersion: prove the history request keeps the explicit record ID and rejects explicitly non-positive cursors with a typed validation error.
@@ -51,6 +53,8 @@
| ✓ | base +dashboard-delete | shortcut | base_dashboard_block_layout_precision_workflow_test.go::TestBaseDashboardBlockLayoutPrecisionWorkflow (cleanup) | `--base-token`; `--dashboard-id`; `--yes`; live cleanup | deletes the temporary dashboard |
| ✕ | base +dashboard-get | shortcut | | none | dashboard workflows not covered |
| ✕ | base +dashboard-list | shortcut | | none | dashboard workflows not covered |
| ✓ | base +dashboard-share-get | shortcut | base_share_dryrun_test.go::TestBaseShareDryRun/dashboard get; base_share_workflow_test.go::TestBaseShareWorkflow/dashboard share update and get | `--base-token`; `--dashboard-id`; dry-run + deployment-gated live | live requires `LARK_CLI_E2E_BASE_SHARE_READY=1` |
| ✓ | base +dashboard-share-update | shortcut | base_share_dryrun_test.go::TestBaseShareDryRun/dashboard partial update; base_share_workflow_test.go::TestBaseShareWorkflow/dashboard share update and get | one of `--enabled`; `--access-scope=invite`; `--show-source`; `--enable-auto-analysis` per request | single-field updates, explicit false, invite-only scope, and live read-back covered |
| ✕ | base +dashboard-update | shortcut | | none | dashboard workflows not covered |
| ✕ | base +data-query | shortcut | | none | no data-query assertions yet |
| ✓ | base +field-create | shortcut | base_field_dryrun_test.go::TestBaseFieldCreateDryRunArrayCompat | `--base-token`; `--table-id`; `--json`; dry-run only | request shape only |
@@ -64,6 +68,8 @@
| ✓ | 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-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 |
| ✓ | base +form-questions-create | shortcut | TestBaseFormQuestionsCreateVisibleRuleDryRun; base_form_questions_create_dryrun_test.go | questions[].visible_rule; dry-run | request body, visible_rule passthrough, and help guard covered |
| ✕ | base +form-questions-delete | shortcut | | none | form workflows not covered |
| ✕ | base +form-questions-list | shortcut | | none | form workflows not covered |