Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 119 additions & 0 deletions shortcuts/base/base_execute_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1383,6 +1383,125 @@ func TestBaseFieldExecuteCRUD(t *testing.T) {
}
})

t.Run("create array reports progress when a later field fails", func(t *testing.T) {
oldDelay := fieldCreateBatchDelay
fieldCreateBatchDelay = 0
t.Cleanup(func() { fieldCreateBatchDelay = oldDelay })

runPartial := func(input, createdType, failedName string, failedResponse map[string]interface{}) map[string]interface{} {
t.Helper()
factory, stdout, reg := newExecuteFactory(t)
register := func(name string, response map[string]interface{}) {
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields",
BodyFilter: func(body []byte) bool { return strings.Contains(string(body), `"name":"`+name+`"`) },
Body: response,
})
}
register("A", map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"id": "fld_a", "name": "A", "type": createdType},
})
register(failedName, failedResponse)
err := runShortcut(t, BaseFieldCreate, []string{
"+field-create", "--base-token", "app_x", "--table-id", "tbl_x", "--json", input,
}, factory, stdout)
var partialErr *output.PartialFailureError
if !errors.As(err, &partialErr) {
t.Fatalf("expected partial failure error, got %T: %v", err, err)
}
var envelope struct {
OK bool `json:"ok"`
Data map[string]interface{} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("decode partial failure output: %v\nstdout=%s", err, stdout.String())
}
if envelope.OK || envelope.Data == nil {
t.Fatalf("unexpected partial failure envelope: %#v", envelope)
}
return envelope.Data
}

conflictResponse := map[string]interface{}{
"code": 1254090,
"msg": "field already exists",
"error": map[string]interface{}{
"log_id": "202607300001",
"troubleshooter": "https://open.feishu.cn/document/troubleshoot/field-exists",
"details": []interface{}{map[string]interface{}{"value": "choose a different field name"}},
},
}
data := runPartial(`[{"name":"A","type":"auto_number"},{"name":"B","type":"text"},{"name":"C","type":"text"}]`, "auto_number", "B", conflictResponse)
summary, _ := data["summary"].(map[string]interface{})
if summary["requested"] != float64(3) || summary["attempted"] != float64(2) ||
summary["created"] != float64(1) || summary["failed"] != float64(1) || summary["not_attempted"] != float64(1) {
t.Fatalf("unexpected summary: %#v", summary)
}

items, _ := data["items"].([]interface{})
if len(items) != 3 {
t.Fatalf("items=%#v, want three outcomes", items)
}
created, _ := items[0].(map[string]interface{})
createdField, _ := created["field"].(map[string]interface{})
failed, _ := items[1].(map[string]interface{})
failedField, _ := failed["field"].(map[string]interface{})
notAttempted, _ := items[2].(map[string]interface{})
notAttemptedField, _ := notAttempted["field"].(map[string]interface{})
if created["status"] != "created" || createdField["id"] != "fld_a" ||
failed["status"] != "failed" || failed["index"] != float64(1) || failedField["name"] != "B" ||
notAttempted["status"] != "not_attempted" || notAttemptedField["name"] != "C" {
t.Fatalf("unexpected item outcomes: %#v", items)
}
if !strings.Contains(common.GetString(failed, "error"), "field already exists") {
t.Fatalf("failed item must include the API error: %#v", failed)
}
for key, want := range map[string]interface{}{
"type": "api",
"subtype": "unknown",
"code": float64(1254090),
"hint": "choose a different field name",
"retryable": false,
"log_id": "202607300001",
"troubleshooter": "https://open.feishu.cn/document/troubleshoot/field-exists",
} {
if failed[key] != want {
t.Fatalf("failed[%q]=%#v, want %#v; failed=%#v", key, failed[key], want, failed)
}
}
if _, ok := failed["error_type"]; ok {
t.Fatalf("failed item must use canonical type/subtype fields: %#v", failed)
}
if !strings.Contains(data["hint"].(string), "Do not retry failed items unless retryable is true") {
t.Fatalf("hint=%#v", data["hint"])
}
if data["field_get_recommended"] != true || data["next_step"] != "inspect_items" || data["verification_hint"] == nil {
t.Fatalf("partial success with auto_number must recommend readback: %#v", data)
}

simpleData := runPartial(`[{"name":"A","type":"text"},{"name":"B","type":"text"},{"name":"C","type":"text"}]`, "text", "B", conflictResponse)
if simpleData["field_get_recommended"] != false || simpleData["next_step"] != "inspect_items" {
t.Fatalf("simple-field partial failure must inspect items, not report done: %#v", simpleData)
}

permissionData := runPartial(`[{"name":"A","type":"text"},{"name":"P","type":"text"}]`, "text", "P", map[string]interface{}{
"code": 99991672,
"msg": "app scope not applied",
"error": map[string]interface{}{
"permission_violations": []interface{}{map[string]interface{}{"subject": "base:field:create"}},
},
})
items, _ = permissionData["items"].([]interface{})
permissionFailure, _ := items[1].(map[string]interface{})
missingScopes, _ := permissionFailure["missing_scopes"].([]interface{})
if len(missingScopes) != 1 || missingScopes[0] != "base:field:create" ||
permissionFailure["identity"] != "bot" || common.GetString(permissionFailure, "console_url") == "" {
t.Fatalf("permission failure must retain typed extensions: %#v", permissionFailure)
}
})

t.Run("create array with generated field recommends readback", func(t *testing.T) {
oldDelay := fieldCreateBatchDelay
fieldCreateBatchDelay = 0
Expand Down
42 changes: 41 additions & 1 deletion shortcuts/base/base_shortcuts_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -246,10 +246,50 @@ func TestBaseHighRiskShortcutsTipsGuideAgents(t *testing.T) {
}
}

func TestBaseFieldCreateHelpHidesReadGuideFlag(t *testing.T) {
func TestBaseFieldCreateTipsGuideTypeSelectionByStoredValue(t *testing.T) {
tips := strings.Join(BaseFieldCreate.Tips, "\n")
for _, want := range []string{
"+field-create defines storage schema only",
"a documented field type",
"value being stored",
"never from the field name or business purpose",
"use style only to format that type",
"Do not probe source code, web search, or raw OpenAPI",
"formula, lookup, link, workflow, or automation",
"only when the user explicitly requests",
"automatically populated, synchronized, or backfilled values",
} {
if !strings.Contains(tips, want) {
t.Fatalf("field-create tips should contain %q, got:\n%s", want, tips)
}
}
lowerTips := strings.ToLower(tips)
for _, caseArtifact := range []string{
"base_table_",
"larkoffice.com/base/",
"grading_pass_rate",
"benchmark",
} {
if strings.Contains(lowerTips, caseArtifact) {
t.Fatalf("field-create tips should remain generic, found %q:\n%s", caseArtifact, tips)
}
}
}

func TestBaseFieldCreateHelpDocumentsBatchAndHidesReadGuideFlag(t *testing.T) {
parent := &cobra.Command{Use: "base"}
BaseFieldCreate.Mount(parent, &cmdutil.Factory{})
cmd := parent.Commands()[0]
if !strings.Contains(cmd.Short, "one or more fields") {
t.Fatalf("help should describe creating one or more fields, got %q", cmd.Short)
}
jsonFlag := cmd.Flags().Lookup("json")
if jsonFlag == nil {
t.Fatal("flag json must exist")
}
if !strings.Contains(jsonFlag.Usage, "JSON object or non-empty array") {
t.Fatalf("json flag help should document object and array input, got %q", jsonFlag.Usage)
}
if cmd.Flags().Lookup("i-have-read-guide") == nil {
t.Fatalf("flag i-have-read-guide must exist for runtime validation")
}
Expand Down
97 changes: 97 additions & 0 deletions shortcuts/base/base_skill_contract_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT

package base

import (
"strings"
"testing"

"github.com/larksuite/cli/internal/vfs"
)

func TestBaseSkillKeepsCreateSemanticsLiteralAndGeneric(t *testing.T) {
const skillPath = "../../skills/lark-base/SKILL.md"
content, err := vfs.ReadFile(skillPath)
if err != nil {
t.Fatalf("read lark-base skill: %v", err)
}

skill := string(content)
normalizedSkill := strings.Join(strings.Fields(skill), " ")
for _, want := range []string{
"用户要求“新增/创建”时",
"本轮 create 返回的对象、ID 或数量",
"同名目标已存在时报告冲突",
"不能把已有资源算作本轮新增",
"不能静默复用或更新",
"对每类资源只做一次必要盘点",
"只有命令明确返回逐项结果时才优先使用批量创建",
"继续配置本轮返回的 ID",
"明确要求确保存在、复用或更新",
} {
if !strings.Contains(normalizedSkill, want) {
t.Fatalf("lark-base skill missing %q", want)
}
}

for _, forbidden := range []string{
"base_table_",
"larkoffice.com/base/",
"grading_pass_rate",
} {
if strings.Contains(skill, forbidden) {
t.Fatalf("lark-base skill must remain generic, found %q", forbidden)
}
}
}

func TestFieldCreateBatchContractStaysConsistentAcrossSkillAndReferences(t *testing.T) {
readNormalized := func(path string) string {
t.Helper()
content, err := vfs.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
return strings.Join(strings.Fields(string(content)), " ")
}

skill := readNormalized("../../skills/lark-base/SKILL.md")
fieldJSON := readNormalized("../../skills/lark-base/references/lark-base-field-json.md")
fieldCreate := readNormalized("../../skills/lark-base/references/lark-base-field-create.md")

for _, want := range []string{
"同一表创建多个字段时,一次向 `+field-create --json` 传字段对象数组",
"只有命令明确返回逐项结果时才优先使用批量创建",
} {
if !strings.Contains(skill, want) {
t.Fatalf("lark-base skill missing %q", want)
}
}
for _, want := range []string{
"单个字段定义始终是 JSON 对象",
"`+field-create --json` 接受一个字段对象或非空字段对象数组",
"`+field-update --json` 只接受一个字段对象",
} {
if !strings.Contains(fieldJSON, want) {
t.Fatalf("field JSON SSOT missing %q", want)
}
}
if strings.Contains(fieldJSON, "`--json` 必须是 JSON 对象") {
t.Fatal("field JSON SSOT must not apply the update-only top-level object rule to field-create")
}
for _, want := range []string{
"遇到首个失败即停止且不自动回滚已创建字段",
"部分失败返回 `ok:false`",
"`summary`",
"`items`",
"`next_step:\"inspect_items\"`",
"`missing_scopes`、`identity`、`console_url`",
"只有 `retryable:true` 的 `failed` 项可重试",
"`not_attempted` 项应单独继续",
} {
if !strings.Contains(fieldCreate, want) {
t.Fatalf("field-create reference missing %q", want)
}
}
}
57 changes: 57 additions & 0 deletions shortcuts/base/data_query_guide_contract_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT

package base

import (
"strings"
"testing"

"github.com/larksuite/cli/internal/vfs"
)

func TestDataQueryQuickGuideCoversConditionValueShapesWithoutScenarioTemplate(t *testing.T) {
const guidePath = "../../skills/lark-base/references/lark-base-data-query-guide.md"
content, err := vfs.ReadFile(guidePath)
if err != nil {
t.Fatalf("read data-query quick guide: %v", err)
}
guide := string(content)
normalizedGuide := strings.Join(strings.Fields(guide), " ")

for _, want := range []string{
"Common `Condition.value` shapes",
"`is` / `isNot`",
"exactly one option name",
"`isGreater`",
"`isLess`",
"`isEmpty`",
"`isNotEmpty`",
"uses `[]`",
`["Today"]`,
`["ExactDate","<epoch_ms>"]`,
"Use relative date keywords only for relative requests",
"[lark-base-data-query.md](lark-base-data-query.md)",
} {
if !strings.Contains(normalizedGuide, want) {
t.Fatalf("quick guide missing %q", want)
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if len(content) > 6*1024 {
t.Fatalf("quick guide grew to %d bytes; keep full DSL details in lark-base-data-query.md", len(content))
}

for _, forbidden := range []string{
"base_table_",
"bytedance.larkoffice.com/base/",
"Combine a date boundary and a status exclusion",
`"<date_field>"`,
`"<status_field>"`,
`"<status_value>"`,
} {
if strings.Contains(normalizedGuide, forbidden) {
t.Fatalf("quick guide must remain generic, found %q", forbidden)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
}
6 changes: 4 additions & 2 deletions shortcuts/base/field_create.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,21 @@ import (
var BaseFieldCreate = common.Shortcut{
Service: "base",
Command: "+field-create",
Description: "Create a field",
Description: "Create one or more fields",
Risk: "write",
Scopes: []string{"base:field:create"},
AuthTypes: authTypes(),
Flags: []common.Flag{
baseTokenFlag(true),
tableRefFlag(true),
{Name: "json", Desc: "field property JSON object", Required: true},
{Name: "json", Desc: "field property JSON object or non-empty array of field objects", Required: true},
{Name: "i-have-read-guide", Type: "bool", Desc: "set only after you have read the formula/lookup guide for those field types", Hidden: true},
},
Tips: []string{
`Example text: lark-cli base +field-create --base-token <base_token> --table-id <table_id> --json '{"name":"Status","type":"text"}'`,
`Example select: lark-cli base +field-create --base-token <base_token> --table-id <table_id> --json '{"name":"Status","type":"select","multiple":false,"options":[{"name":"Todo"},{"name":"Done"}]}'`,
`+field-create defines storage schema only: choose a documented field type from the value being stored, never from the field name or business purpose, and use style only to format that type.`,
`Do not probe source code, web search, or raw OpenAPI for a purpose-named field type. Explore formula, lookup, link, workflow, or automation only when the user explicitly requests derived, related, automatically populated, synchronized, or backfilled values.`,
"Agent hint: use the lark-base skill's field-create guide for usage and limits.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
Expand Down
Loading
Loading