mirror of
https://github.com/larksuite/cli.git
synced 2026-09-14 18:42:53 +08:00
723f884e9b
* feat(base): add BaseApp workspace, page and block shortcuts Implement the CLI layer of the BaseApp CLI/OpenAPI protocol design: 17 new shortcuts covering workspace entities, blank app creation, page CRUD and page block CRUD, plus skill references and dry-run E2E for each. The data_config validator moves to a neutral block_data_config.go with chart logic unchanged; list and richText dispatch are new branches, so dashboard behaviour is untouched. Command spaces stay separate — dashboard commands never take --app-token and app block commands never take --dashboard-id. The one exception is +app-block-get-data, which shares the dashboard endpoint, execute and dry-run hooks and therefore takes --base-token instead of --app-token. This phase ships no +app-block-delete and no page arrange command; both the help text and the skill docs spell out that a block type cannot be changed after creation. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(base): implement app mode shortcuts and list components * feat: support deleting BaseApp via drive delete * fix: 修复 workspace scope * fix: correct BaseApp permission scopes * feat: 新增 moveIn workspace 逻辑 * feat(base): support multi-datasource data_config for BaseApp charts BaseApp page charts follow section 8 图表协议 of the App CLI RPC 协议, which differs from dashboard charts by supporting multiple data sources: base_token is a single top-level value shared by every source, while table_name/series/count_all/group_by/filter move into each data_sources[] element (plus top-level data_source_mode and sort). The per-source value semantics are identical to dashboard charts, so each data_sources[] element reuses normalizeDataConfig / validateChartDataConfig; the wrapper only adds the top-level structure. Dashboard charts keep the flat shape; the list protocol is untouched. - block_data_config.go: add normalizeAppChartDataConfig / validateAppChartDataConfig / validateAppBlockDataConfig - app_block_create/update: route chart blocks to the multi-datasource normalize/validate; refresh tips and examples - reference doc: rewrite the chart section for the multi-datasource shape - unit + e2e tests: migrate chart cases to data_sources; add a multi-datasource combo case Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(base): map BaseApp richText block type to the wire type "text" The rich-text widget's API type is "text" (App CLI RPC 协议 §10), but the CLI exposes the friendlier "richText" alias and was sending it verbatim, so the backend rejected +app-block-create --type richText with "type is invalid". Map richText -> text when building the request body; the user-facing --type richText is unchanged. Add TestAppRichTextTypeMapsToText. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(base): remove internal protocol doc link/reference from skills The BaseApp skill references pointed at an internal Lark doc (deep link with a private token) as the source of truth, which must not ship in this repo. Drop the link and the doc name entirely from the reference markdown and from code comments; describe behavior in neutral terms ("服务端协议 / 服务端返回和校验") instead. No functional change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat: create workspace for BaseApp when omitted * fix(base): align BaseApp block protocol * fix: 删掉废弃的 workspace-entity-remove * fix(base): align app get reference response * fix(base): align app shortcuts with API contract * fix(base): refine app mode shortcut contracts * fix: use entity_type for workspace entity filtering * fix: return workspace URLs for base workspace ops * fix(base): enforce unique app block names * fix(base): use chart token for app block data * 明确baseapp边界,不导向到dashboard-arrange * docs(base): clarify app copy is unsupported * docs(base): define unsupported app page operations * fix(base): align app block text type with dashboard AppMode 的文本组件此前对外叫 richText,发送时再映射成 wire 上的 text, 而读取方向没有反向映射:写进去用 richText、读回来是 text,同一个 CLI 表面自相矛盾,回填或幂等复建时会被枚举校验拒掉。 统一成 text,与 Dashboard 文本组件同名同义: - appBlockTypes/isAppBlockType/textBlockTypes 去掉 richText - 删除 appBlockBody 里的 richText → text 发送期映射 - help、枚举、示例、tips 与 baseapp block data_config reference 同步 - 新增回归测试,确保 richText 不再被接受也不再出现在枚举里 richText 不保留别名:+app-* 尚未随已发布版本对外,无存量调用方。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(base): resolve BaseApp URLs Migrate the net changes from bitable/base_cli!1308 onto the current BaseApp development branch. * fix(base): remove app block list type filter * docs: preserve explicit intent when reusing BaseApp blocks * fix(base): route +app-block-get-data to base_apps endpoint Move the shortcut off the dashboard route and onto the dedicated BaseApp block-data endpoint: - URL: /open-apis/base/v3/base_apps/:app_token/blocks/:block_id/data - base_token is passed as a required query parameter per the new IDL - Refresh --block-id description and tips to list all producers of the chart_token (create/list/get) and note the cht… prefix - Update the dryrun test to expect the new URL Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix: clarify unsupported BaseApp copy paths * fix: front-load BaseApp copy stop rule * fix: surface unsupported PageGroup operations * fix: preserve PageGroup support boundary * docs(base): clarify unsupported app block handling * docs(base): clarify how to read text block content Text blocks have no /data endpoint; calling +app-block-get-data on one returns a generic server 500. Point readers at +app-block-get, whose data_config.text carries the Markdown source. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * docs: clarify immutable BaseApp block types * docs(base): correct chart date filter format * docs(base): explain inaccessible app pages * docs: require workspace removal lookup * fix(base): return workspace move-in result faithfully * chore(base): adapt BaseApp changes to upstream main * fix(base): align app mode changes with upstream scope * fix(base): address app mode review feedback * test(base): assert app data transport error contract * fix(base): satisfy app mode merge requirements * refactor(base): align app mode filenames * docs: fix BaseApp rename guidance * fix(base): remove unsupported workspace icon * docs(base): clarify app mode concepts and config reuse --------- Co-authored-by: weibiao.x <weibiao.x@bytedance.com> Co-authored-by: zhangbinkai.zbk <zhangbinkai.zbk@bytedance.com> Co-authored-by: yurunjie <yurunjie.xx@bytedance.com> Co-authored-by: Codex <codex@example.com>
275 lines
12 KiB
Go
275 lines
12 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package base
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"github.com/tidwall/gjson"
|
|
)
|
|
|
|
func TestBaseWorkspaceDryRun(t *testing.T) {
|
|
t.Run("create", func(t *testing.T) {
|
|
result := runBaseDryRun(t, 0, "base", "+workspace-create", "--name", "Growth")
|
|
output := strings.TrimSpace(result.Stdout)
|
|
assert.Contains(t, output, "/open-apis/base/v3/workspaces")
|
|
assert.Contains(t, output, `"method": "POST"`)
|
|
assert.Contains(t, output, `"name": "Growth"`)
|
|
})
|
|
|
|
t.Run("create rejects unsupported icon", func(t *testing.T) {
|
|
result := runBaseDryRun(t, 2, "base", "+workspace-create", "--name", "Growth", "--icon", "icon_1")
|
|
assert.Contains(t, result.Stderr, "unknown flag")
|
|
})
|
|
|
|
t.Run("entity-list", func(t *testing.T) {
|
|
result := runBaseDryRun(t, 0, "base", "+workspace-entity-list", "--workspace-token", "ws_x", "--type", "baseapp")
|
|
output := strings.TrimSpace(result.Stdout)
|
|
assert.Contains(t, output, "/open-apis/base/v3/workspaces/ws_x/entities")
|
|
assert.Contains(t, output, `"method": "GET"`)
|
|
assert.Contains(t, output, `"entity_type": "baseapp"`)
|
|
})
|
|
|
|
t.Run("move-in", func(t *testing.T) {
|
|
result := runBaseDryRun(t, 0, "base", "+workspace-move-in",
|
|
"--workspace-token", "ws_x", "--entity-token", "bascn_1")
|
|
output := strings.TrimSpace(result.Stdout)
|
|
assert.Contains(t, output, "/open-apis/base/v3/workspaces/ws_x/move_in")
|
|
assert.Contains(t, output, `"entity_token": "bascn_1"`)
|
|
})
|
|
|
|
}
|
|
|
|
func TestBaseappDryRun(t *testing.T) {
|
|
t.Run("resolve app URL locally", func(t *testing.T) {
|
|
result := runBaseDryRun(t, 0, "base", "+url-resolve", "--url",
|
|
"https://example.larkoffice.com/app/app_x?pre_pathname=%2Fbase%2Fworkspace%2Fws_x&pageId=pg_1")
|
|
output := strings.TrimSpace(result.Stdout)
|
|
assert.Contains(t, output, `"resolution": "local"`)
|
|
assert.Contains(t, output, `"url"`)
|
|
assert.NotContains(t, output, `"/open-apis/`)
|
|
})
|
|
|
|
t.Run("create", func(t *testing.T) {
|
|
result := runBaseDryRun(t, 0, "base", "+app-create",
|
|
"--name", "Sales app", "--workspace-token", "ws_x", "--theme-style", "cloudBlue")
|
|
output := strings.TrimSpace(result.Stdout)
|
|
assert.Contains(t, output, "/open-apis/base/v3/base_apps")
|
|
assert.Contains(t, output, `"method": "POST"`)
|
|
assert.Contains(t, output, `"theme_style": "cloudBlue"`)
|
|
assert.NotContains(t, output, "/open-apis/base/v3/bases")
|
|
assert.NotContains(t, output, "/move_in")
|
|
})
|
|
|
|
t.Run("create requires workspace", func(t *testing.T) {
|
|
result := runBaseDryRun(t, 2, "base", "+app-create", "--name", "Sales app")
|
|
assert.Contains(t, result.Stderr, "workspace-token")
|
|
})
|
|
|
|
t.Run("get", func(t *testing.T) {
|
|
result := runBaseDryRun(t, 0, "base", "+app-get", "--app-token", "app_x")
|
|
output := strings.TrimSpace(result.Stdout)
|
|
assert.Contains(t, output, "/open-apis/base/v3/base_apps/app_x")
|
|
assert.NotContains(t, output, "with_pages")
|
|
assert.NotContains(t, output, "with_components")
|
|
})
|
|
|
|
}
|
|
|
|
func TestBaseappPageDryRun(t *testing.T) {
|
|
t.Run("list", func(t *testing.T) {
|
|
result := runBaseDryRun(t, 0, "base", "+app-page-list", "--app-token", "app_x")
|
|
assert.Contains(t, result.Stdout, "/open-apis/base/v3/base_apps/app_x/pages")
|
|
})
|
|
|
|
t.Run("get", func(t *testing.T) {
|
|
result := runBaseDryRun(t, 0, "base", "+app-page-get", "--app-token", "app_x", "--page-id", "pg_1")
|
|
output := strings.TrimSpace(result.Stdout)
|
|
assert.Contains(t, output, "/open-apis/base/v3/base_apps/app_x/pages/pg_1")
|
|
assert.NotContains(t, output, "with_components")
|
|
})
|
|
|
|
t.Run("create", func(t *testing.T) {
|
|
result := runBaseDryRun(t, 0, "base", "+app-page-create", "--app-token", "app_x", "--name", "Overview")
|
|
output := strings.TrimSpace(result.Stdout)
|
|
assert.Contains(t, output, "/open-apis/base/v3/base_apps/app_x/pages")
|
|
assert.Contains(t, output, `"name": "Overview"`)
|
|
assert.NotContains(t, output, "page_group_id")
|
|
})
|
|
|
|
t.Run("create rejects page group", func(t *testing.T) {
|
|
result := runBaseDryRun(t, 2, "base", "+app-page-create",
|
|
"--app-token", "app_x", "--name", "Overview", "--page-group-id", "pgrp_1")
|
|
assert.Contains(t, result.Stderr, "unknown flag")
|
|
})
|
|
|
|
t.Run("rename", func(t *testing.T) {
|
|
result := runBaseDryRun(t, 0, "base", "+app-page-update", "--app-token", "app_x", "--page-id", "pg_1", "--name", "Sales")
|
|
output := strings.TrimSpace(result.Stdout)
|
|
assert.Contains(t, output, "/open-apis/base/v3/base_apps/app_x/pages/pg_1")
|
|
assert.Contains(t, output, `"method": "PATCH"`)
|
|
})
|
|
|
|
t.Run("delete", func(t *testing.T) {
|
|
result := runBaseDryRun(t, 0, "base", "+app-page-delete", "--app-token", "app_x", "--page-id", "pg_1")
|
|
output := strings.TrimSpace(result.Stdout)
|
|
assert.Contains(t, output, "/open-apis/base/v3/base_apps/app_x/pages/pg_1")
|
|
assert.Contains(t, output, `"method": "DELETE"`)
|
|
})
|
|
}
|
|
|
|
func TestAppBlockDryRun(t *testing.T) {
|
|
t.Run("list", func(t *testing.T) {
|
|
result := runBaseDryRun(t, 0, "base", "+app-block-list", "--app-token", "app_x", "--page-id", "pg_1")
|
|
output := strings.TrimSpace(result.Stdout)
|
|
assert.Contains(t, output, "/open-apis/base/v3/base_apps/app_x/pages/pg_1/blocks")
|
|
assert.Contains(t, output, `"page_size": 20`)
|
|
})
|
|
|
|
t.Run("get", func(t *testing.T) {
|
|
result := runBaseDryRun(t, 0, "base", "+app-block-get", "--app-token", "app_x", "--page-id", "pg_1", "--block-id", "wid_1")
|
|
assert.Contains(t, result.Stdout, "/open-apis/base/v3/base_apps/app_x/pages/pg_1/blocks/wid_1")
|
|
})
|
|
|
|
t.Run("create chart", func(t *testing.T) {
|
|
result := runBaseDryRun(t, 0, "base", "+app-block-create",
|
|
"--app-token", "app_x", "--page-id", "pg_1",
|
|
"--name", "Sales by month", "--type", "line",
|
|
"--data-config", `{"base_token":"basx","data_sources":[{"table_name":"Orders","series":[{"field_name":"Amount","rollup":"sum"}]}]}`)
|
|
output := strings.TrimSpace(result.Stdout)
|
|
assert.Contains(t, output, "/open-apis/base/v3/base_apps/app_x/pages/pg_1/blocks")
|
|
assert.Contains(t, output, `"method": "POST"`)
|
|
assert.Contains(t, output, `"type": "line"`)
|
|
// App 图表:顶层 base_token + 多数据源 data_sources
|
|
assert.Contains(t, output, `"base_token": "basx"`)
|
|
assert.Contains(t, output, "data_sources")
|
|
// normalizeAppChartDataConfig 把每个数据源的 rollup 归一化为大写
|
|
assert.Contains(t, output, "SUM")
|
|
})
|
|
|
|
t.Run("create chart rejects missing base_token", func(t *testing.T) {
|
|
result := runBaseDryRun(t, 2, "base", "+app-block-create",
|
|
"--app-token", "app_x", "--page-id", "pg_1",
|
|
"--name", "Sales by month", "--type", "line",
|
|
"--data-config", `{"data_sources":[{"table_name":"Orders","series":[{"field_name":"Amount","rollup":"SUM"}]}]}`)
|
|
assert.Contains(t, result.Stderr, "base_token")
|
|
})
|
|
|
|
t.Run("create chart requires data_config", func(t *testing.T) {
|
|
result := runBaseDryRun(t, 2, "base", "+app-block-create",
|
|
"--app-token", "app_x", "--page-id", "pg_1",
|
|
"--name", "Sales by month", "--type", "line")
|
|
assert.Contains(t, result.Stderr, "data-config")
|
|
})
|
|
|
|
t.Run("create list", func(t *testing.T) {
|
|
result := runBaseDryRun(t, 0, "base", "+app-block-create",
|
|
"--app-token", "app_x", "--page-id", "pg_1",
|
|
"--name", "Open orders", "--type", "list",
|
|
"--data-config", `{"base_token":"basx","table_name":"Orders"}`)
|
|
output := strings.TrimSpace(result.Stdout)
|
|
assert.Contains(t, output, `"type": "list"`)
|
|
assert.Contains(t, output, "basx")
|
|
assert.NotContains(t, output, `"columns"`)
|
|
assert.NotContains(t, output, `"sub_type"`)
|
|
})
|
|
|
|
t.Run("create list requires data_config", func(t *testing.T) {
|
|
result := runBaseDryRun(t, 2, "base", "+app-block-create",
|
|
"--app-token", "app_x", "--page-id", "pg_1",
|
|
"--name", "Open orders", "--type", "list", "--sub-type", "standard")
|
|
assert.Contains(t, result.Stderr, "data-config")
|
|
})
|
|
|
|
t.Run("create text block allows omitted data_config", func(t *testing.T) {
|
|
result := runBaseDryRun(t, 0, "base", "+app-block-create",
|
|
"--app-token", "app_x", "--page-id", "pg_1",
|
|
"--name", "Notes", "--type", "text")
|
|
assert.NotContains(t, result.Stdout, `"data_config"`)
|
|
})
|
|
|
|
t.Run("create rejects an unsupported type", func(t *testing.T) {
|
|
result := runBaseDryRun(t, 2, "base", "+app-block-create",
|
|
"--app-token", "app_x", "--page-id", "pg_1", "--name", "X", "--type", "gantt")
|
|
assert.NotEqual(t, 0, result.ExitCode)
|
|
})
|
|
|
|
t.Run("create rejects an invalid chart data_config", func(t *testing.T) {
|
|
result := runBaseDryRun(t, 2, "base", "+app-block-create",
|
|
"--app-token", "app_x", "--page-id", "pg_1", "--name", "X", "--type", "line",
|
|
"--data-config", `{"base_token":"basx","data_sources":[{"series":[{"field_name":"Amount","rollup":"SUM"}]}]}`)
|
|
assert.Contains(t, result.Stderr, "table_name")
|
|
})
|
|
|
|
t.Run("create multi-datasource chart", func(t *testing.T) {
|
|
result := runBaseDryRun(t, 0, "base", "+app-block-create",
|
|
"--app-token", "app_x", "--page-id", "pg_1",
|
|
"--name", "Sales vs cost", "--type", "combo",
|
|
"--data-config", `{"base_token":"basx","data_source_mode":"compare","data_sources":[{"table_name":"Sales","series":[{"field_name":"Amount","rollup":"SUM"}]},{"table_name":"Cost","series":[{"field_name":"Cost","rollup":"SUM"}]}],"sort":{"type":"group","order":"asc"}}`)
|
|
output := strings.TrimSpace(result.Stdout)
|
|
assert.Contains(t, output, `"type": "combo"`)
|
|
assert.Contains(t, output, `"compare"`)
|
|
assert.Contains(t, output, "Sales")
|
|
assert.Contains(t, output, "Cost")
|
|
})
|
|
|
|
t.Run("update", func(t *testing.T) {
|
|
result := runBaseDryRun(t, 0, "base", "+app-block-update",
|
|
"--app-token", "app_x", "--page-id", "pg_1", "--block-id", "wid_1", "--name", "Monthly sales")
|
|
output := strings.TrimSpace(result.Stdout)
|
|
assert.Contains(t, output, "/open-apis/base/v3/base_apps/app_x/pages/pg_1/blocks/wid_1")
|
|
assert.Contains(t, output, `"method": "PATCH"`)
|
|
})
|
|
|
|
t.Run("update requires name or data_config", func(t *testing.T) {
|
|
result := runBaseDryRun(t, 2, "base", "+app-block-update",
|
|
"--app-token", "app_x", "--page-id", "pg_1", "--block-id", "wid_1")
|
|
require.Equal(t, "validation", gjson.Get(result.Stderr, "error.type").String(), result.Stderr)
|
|
require.Equal(t, "invalid_argument", gjson.Get(result.Stderr, "error.subtype").String(), result.Stderr)
|
|
require.Equal(t, "--name", gjson.Get(result.Stderr, "error.param").String(), result.Stderr)
|
|
require.Contains(t, gjson.Get(result.Stderr, "error.message").String(), "至少提供一个", result.Stderr)
|
|
})
|
|
|
|
t.Run("update rejects unknown data_config field", func(t *testing.T) {
|
|
result := runBaseDryRun(t, 2, "base", "+app-block-update",
|
|
"--app-token", "app_x", "--page-id", "pg_1", "--block-id", "wid_1",
|
|
"--data-config", `{"bogus":1}`)
|
|
require.Equal(t, "validation", gjson.Get(result.Stderr, "error.type").String(), result.Stderr)
|
|
require.Equal(t, "invalid_argument", gjson.Get(result.Stderr, "error.subtype").String(), result.Stderr)
|
|
require.Equal(t, "--data-config", gjson.Get(result.Stderr, "error.param").String(), result.Stderr)
|
|
require.Contains(t, gjson.Get(result.Stderr, "error.message").String(), "bogus", result.Stderr)
|
|
})
|
|
}
|
|
|
|
func TestAppBlockGetDataDryRun(t *testing.T) {
|
|
result := runBaseDryRun(t, 0, "base", "+app-block-get-data",
|
|
"--app-token", "app_x", "--base-token", "bas_x", "--block-id", "cht_chart")
|
|
output := strings.TrimSpace(result.Stdout)
|
|
assert.Contains(t, output, "/open-apis/base/v3/base_apps/app_x/blocks/cht_chart/data")
|
|
assert.Contains(t, output, `"method": "GET"`)
|
|
|
|
for _, tc := range []struct {
|
|
name string
|
|
args []string
|
|
want string
|
|
}{
|
|
{name: "app token", args: []string{"--base-token", "bas_x", "--block-id", "cht_chart"}, want: "app-token"},
|
|
{name: "base token", args: []string{"--app-token", "app_x", "--block-id", "cht_chart"}, want: "base-token"},
|
|
{name: "block id", args: []string{"--app-token", "app_x", "--base-token", "bas_x"}, want: "block-id"},
|
|
} {
|
|
t.Run("missing "+tc.name, func(t *testing.T) {
|
|
args := append([]string{"base", "+app-block-get-data"}, tc.args...)
|
|
missing := runBaseDryRun(t, 2, args...)
|
|
assert.Contains(t, missing.Stderr, tc.want)
|
|
})
|
|
}
|
|
|
|
unknownPage := runBaseDryRun(t, 2, "base", "+app-block-get-data",
|
|
"--app-token", "app_x", "--base-token", "bas_x", "--block-id", "cht_chart", "--page-id", "pg_x")
|
|
assert.Contains(t, unknownPage.Stderr, "unknown flag")
|
|
}
|