feat: declarative LeafSpec command framework + schema generation/consumption separation
== LeafSpec command framework (internal/helpers/leaf.go) ==
Declarative command construction: LeafSpec/LeafFlag/NewLeafCommand with
Call (pluggable dispatch), LeafInt, PostMount, Trim, Validate. Collapses
per-command hand-written required validation, alias/env fallback, value
transform, and toolArgs assembly into one declarative path.
== devapp migration (28/31 commands) ==
All MCP-direct devapp leaf commands migrated to LeafSpec. Factories
(devAppCall/devAppCallCursor/devAppMeta) fold 33 repeated closures.
fakeDevAppRunner asserts toolArgs for every migrated command. 4 complex
commands (delete/robot submit/result/config) kept hand-written.
== Schema generation/consumption separation ==
- gen.go: isolated //go:generate pragmas from business code.
- command_meta.go: ResolveMeta(cliPath) -> CommandMeta{Identity,Safety,Selection}.
- command_safety.go: SafetyForCLIPath + RenderSafetyAnnotation; safety metadata
flows from embedded catalog into --help output.
- calendar.go HelpFunc fix: delegates to root HelpFunc at help-time.
- schema_catalog_structure.go: closed catalog structure validation gate.
== Registry + catalog per-product sharding ==
schema_command_registry and schema_catalog split into per-product shards,
eliminating concurrent-PR merge conflicts on these files.
== MCP metadata refresh tool ==
cmd/fetch_mcp_metadata: iterates 26 MCP server endpoints, merges with previous
data for cross-server interface_ref. make fetch-mcp-metadata target.
== AGENTS.md ==
Documents the generation/consumption split.
Verified: make policy exit 0, drift zero, all tests pass.
This commit is contained in:
1 parent
e69a1084a7
commit
51dc237d8a
83 files changed
+447387
-444470
No files matched your search
@@ -9,6 +9,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -62,6 +63,12 @@ func installUnknownVerbFallback(group *cobra.Command) {
|
||||
// Cobra intercepts --help before RunE, so without this the fallback
|
||||
// would never fire when --help is present.
|
||||
origHelp := group.HelpFunc()
|
||||
// captureBaseHelpFunc stores the unwrapped cobra default help renderer,
|
||||
// captured once at install time (before configureRootHelp wraps root's
|
||||
// HelpFunc). This avoids infinite recursion when calendar commands are
|
||||
// tested in isolation (calendar IS the Root in test trees, so
|
||||
// cmd.Root().HelpFunc() would return calendar's own wrapper → loop).
|
||||
captureBaseHelpFunc := origHelp
|
||||
group.SetHelpFunc(func(cmd *cobra.Command, args []string) {
|
||||
if cmd == group {
|
||||
// HelpFunc receives os.Args[1:] (full arg slice without binary).
|
||||
@@ -73,8 +80,14 @@ func installUnknownVerbFallback(group *cobra.Command) {
|
||||
printUnknownSubcmdError(cmd, bad)
|
||||
return
|
||||
}
|
||||
origHelp(cmd, args)
|
||||
return
|
||||
}
|
||||
origHelp(cmd, args)
|
||||
// For non-group commands, call the base (unwrapped) help renderer,
|
||||
// then apply safety annotation. We do NOT call cmd.Root().HelpFunc()
|
||||
// because in test trees calendar IS the root — that would recurse.
|
||||
captureBaseHelpFunc(cmd, args)
|
||||
cli.RenderSafetyAnnotation(cmd)
|
||||
})
|
||||
|
||||
prev := group.RunE
|
||||
|
||||
+579
-611
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,526 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// fakeDevAppRunner 捕获 runDevAppTool 派发的 invocation,用于断言 LeafSpec.Call
|
||||
// 装配出的 toolArgs 与手写版逐字等价。这是 catalog drift 不覆盖的运行时维度。
|
||||
type fakeDevAppRunner struct {
|
||||
got executor.Invocation
|
||||
}
|
||||
|
||||
func (f *fakeDevAppRunner) Run(_ context.Context, inv executor.Invocation) (executor.Result, error) {
|
||||
f.got = inv
|
||||
// 空 Response:normalizeDevAppServiceResult 无 "content" 时早返回,安全。
|
||||
return executor.Result{Invocation: inv, Response: map[string]any{}}, nil
|
||||
}
|
||||
|
||||
// TestDevAppCredentialsGetLeafDispatchesTrimmedArgs 验证迁移到 LeafSpec 后:
|
||||
// toolArgs 键/值/trim 与手写版等价,且 PostMount 设上了 schema 注解与 NoArgs。
|
||||
func TestDevAppCredentialsGetLeafDispatchesTrimmedArgs(t *testing.T) {
|
||||
r := &fakeDevAppRunner{}
|
||||
cmd := newDevAppCredentialsGetCommand(r)
|
||||
// 含首尾空白:验证 LeafFlag.Trim 等价手写 devAppStringFlag 的 TrimSpace。
|
||||
if err := cmd.Flags().Set("unified-app-id", " APP-123 "); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cmd.RunE(cmd, nil); err != nil {
|
||||
t.Fatalf("RunE() error = %v", err)
|
||||
}
|
||||
if r.got.Tool != devAppCredentialsGetTool {
|
||||
t.Fatalf("tool = %q, want %q", r.got.Tool, devAppCredentialsGetTool)
|
||||
}
|
||||
if v, _ := r.got.Params["unifiedAppId"].(string); v != "APP-123" {
|
||||
t.Fatalf("unifiedAppId = %q, want trimmed \"APP-123\"", v)
|
||||
}
|
||||
if cmd.Annotations["mcp-tool"] != devAppCredentialsGetTool {
|
||||
t.Fatalf("mcp-tool annotation = %q (PostMount 未生效)", cmd.Annotations["mcp-tool"])
|
||||
}
|
||||
if cmd.Args == nil {
|
||||
t.Fatal("Args == nil, want NoArgs via PostMount")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDevAppLifecycleLeafWriteGuardAndArgs 验证写守卫拦截/放行 + toolArgs 装配。
|
||||
func TestDevAppLifecycleLeafWriteGuardAndArgs(t *testing.T) {
|
||||
r := &fakeDevAppRunner{}
|
||||
cmd := newDevAppLifecycleCommand(r, "enable", "启用应用", devAppEnableTool)
|
||||
if err := cmd.Flags().Set("unified-app-id", "APP-9"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 无 --yes / --dry-run:写守卫必须拦下(devAppRequireWriteGuard)。
|
||||
if err := cmd.RunE(cmd, nil); err == nil {
|
||||
t.Fatal("RunE() without --yes: want write-guard error, got nil")
|
||||
}
|
||||
// devAppYes 在 cmd.Flags 里找 "yes";注册并置位,守卫放行。
|
||||
cmd.Flags().Bool("yes", false, "")
|
||||
if err := cmd.Flags().Set("yes", "true"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cmd.RunE(cmd, nil); err != nil {
|
||||
t.Fatalf("RunE() with --yes: error = %v", err)
|
||||
}
|
||||
if r.got.Tool != devAppEnableTool {
|
||||
t.Fatalf("tool = %q, want %q", r.got.Tool, devAppEnableTool)
|
||||
}
|
||||
if v, _ := r.got.Params["unifiedAppId"].(string); v != "APP-9" {
|
||||
t.Fatalf("unifiedAppId = %q, want \"APP-9\"", v)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDevAppCredentialsGetMissingRequired 验证自定义必填报错(apperrors)逐字保留。
|
||||
func TestDevAppCredentialsGetMissingRequired(t *testing.T) {
|
||||
r := &fakeDevAppRunner{}
|
||||
cmd := newDevAppCredentialsGetCommand(r)
|
||||
err := cmd.RunE(cmd, nil)
|
||||
if err == nil {
|
||||
t.Fatal("want required error for missing --unified-app-id")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--unified-app-id 为必填") {
|
||||
t.Fatalf("err = %v, want 含 \"--unified-app-id 为必填\"", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDevAppVersionLeafs 验证 version get/status/check-approval 的 toolArgs
|
||||
// 装配:双必填 trim + check-approval 经 Call 注入 precheckOnly=true 常量。
|
||||
func TestDevAppVersionLeafs(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
build func(*fakeDevAppRunner) *cobra.Command
|
||||
tool string
|
||||
precheckOnly any
|
||||
}{
|
||||
{"get", func(r *fakeDevAppRunner) *cobra.Command { return newDevAppVersionGetCommand(r) }, devAppVersionDetailTool, nil},
|
||||
{"status", func(r *fakeDevAppRunner) *cobra.Command { return newDevAppVersionStatusCommand(r) }, devAppVersionStatusTool, nil},
|
||||
{"check-approval", func(r *fakeDevAppRunner) *cobra.Command { return newDevAppVersionCheckApprovalCommand(r) }, devAppVersionPublishTool, true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
r := &fakeDevAppRunner{}
|
||||
cmd := c.build(r)
|
||||
if err := cmd.Flags().Set("unified-app-id", " APP-1 "); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cmd.Flags().Set("version-id", " v9 "); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cmd.RunE(cmd, nil); err != nil {
|
||||
t.Fatalf("RunE() error = %v", err)
|
||||
}
|
||||
if r.got.Tool != c.tool {
|
||||
t.Fatalf("tool = %q, want %q", r.got.Tool, c.tool)
|
||||
}
|
||||
if v, _ := r.got.Params["unifiedAppId"].(string); v != "APP-1" {
|
||||
t.Fatalf("unifiedAppId = %q, want trimmed \"APP-1\"", v)
|
||||
}
|
||||
if v, _ := r.got.Params["versionId"].(string); v != "v9" {
|
||||
t.Fatalf("versionId = %q, want trimmed \"v9\"", v)
|
||||
}
|
||||
if c.precheckOnly != nil {
|
||||
if v, _ := r.got.Params["precheckOnly"].(bool); v != c.precheckOnly {
|
||||
t.Fatalf("precheckOnly = %v, want %v", r.got.Params["precheckOnly"], c.precheckOnly)
|
||||
}
|
||||
} else if _, present := r.got.Params["precheckOnly"]; present {
|
||||
t.Fatalf("precheckOnly present = %v, want absent", r.got.Params["precheckOnly"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDevAppMemberListLeaf 验证 member list 的 unified-id 装配。
|
||||
func TestDevAppMemberListLeaf(t *testing.T) {
|
||||
r := &fakeDevAppRunner{}
|
||||
cmd := newDevAppMemberListCommand(r)
|
||||
if err := cmd.Flags().Set("unified-app-id", "APP-7"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cmd.RunE(cmd, nil); err != nil {
|
||||
t.Fatalf("RunE() error = %v", err)
|
||||
}
|
||||
if r.got.Tool != devAppMemberListTool {
|
||||
t.Fatalf("tool = %q, want %q", r.got.Tool, devAppMemberListTool)
|
||||
}
|
||||
if v, _ := r.got.Params["unifiedAppId"].(string); v != "APP-7" {
|
||||
t.Fatalf("unifiedAppId = %q, want \"APP-7\"", v)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDevAppPermissionListLeaf 验证 ToUpper transform + auth-status 默认 + 命令别名。
|
||||
func TestDevAppPermissionListLeaf(t *testing.T) {
|
||||
r := &fakeDevAppRunner{}
|
||||
cmd := newDevAppPermissionListCommand(r)
|
||||
// 命令别名 "search" 经 PostMount 设回。
|
||||
if !containsString(cmd.Aliases, "search") {
|
||||
t.Fatalf("cmd.Aliases = %v, want contain search", cmd.Aliases)
|
||||
}
|
||||
_ = cmd.Flags().Set("unified-app-id", "APP-P")
|
||||
_ = cmd.Flags().Set("scope-type", "app")
|
||||
if err := cmd.RunE(cmd, nil); err != nil {
|
||||
t.Fatalf("RunE() error = %v", err)
|
||||
}
|
||||
// auth-status 默认 ALL → ToUpper ALL。
|
||||
if v, _ := r.got.Params["authStatus"].(string); v != "ALL" {
|
||||
t.Fatalf("authStatus = %v, want ALL (default ToUpper)", r.got.Params["authStatus"])
|
||||
}
|
||||
// scope-type 小写 app → ToUpper APP。
|
||||
if v, _ := r.got.Params["scopeType"].(string); v != "APP" {
|
||||
t.Fatalf("scopeType = %v, want APP (ToUpper)", r.got.Params["scopeType"])
|
||||
}
|
||||
if _, present := r.got.Params["pageSize"]; !present {
|
||||
t.Fatal("pageSize missing, want cursor injection")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDevAppVersionPublishLeaf 验证 precheckOnly=false 常量 + confirmed-sensitive Changed() 语义。
|
||||
func TestDevAppVersionPublishLeaf(t *testing.T) {
|
||||
r := &fakeDevAppRunner{}
|
||||
cmd := newDevAppVersionPublishCommand(r)
|
||||
cmd.Flags().Bool("yes", false, "")
|
||||
_ = cmd.Flags().Set("yes", "true")
|
||||
_ = cmd.Flags().Set("unified-app-id", "APP-V")
|
||||
_ = cmd.Flags().Set("version-id", "v1")
|
||||
// confirmed-sensitive 未显式设:不入参。
|
||||
if err := cmd.RunE(cmd, nil); err != nil {
|
||||
t.Fatalf("RunE() error = %v", err)
|
||||
}
|
||||
if v, _ := r.got.Params["precheckOnly"].(bool); v != false {
|
||||
t.Fatalf("precheckOnly = %v, want false", r.got.Params["precheckOnly"])
|
||||
}
|
||||
if _, present := r.got.Params["confirmedSensitive"]; present {
|
||||
t.Fatalf("confirmedSensitive present = %v, want omitted (not Changed)", r.got.Params["confirmedSensitive"])
|
||||
}
|
||||
// 显式设 confirmed-sensitive=true:入参。
|
||||
r2 := &fakeDevAppRunner{}
|
||||
cmd2 := newDevAppVersionPublishCommand(r2)
|
||||
cmd2.Flags().Bool("yes", false, "")
|
||||
_ = cmd2.Flags().Set("yes", "true")
|
||||
_ = cmd2.Flags().Set("unified-app-id", "APP-V")
|
||||
_ = cmd2.Flags().Set("version-id", "v1")
|
||||
_ = cmd2.Flags().Set("confirmed-sensitive", "true")
|
||||
_ = cmd2.RunE(cmd2, nil)
|
||||
if v, _ := r2.got.Params["confirmedSensitive"].(bool); v != true {
|
||||
t.Fatalf("confirmedSensitive = %v, want true", r2.got.Params["confirmedSensitive"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestDevAppGetLeafDualKey 验证二选一定位键。
|
||||
func TestDevAppGetLeafDualKey(t *testing.T) {
|
||||
// 都不传:报错。
|
||||
r := &fakeDevAppRunner{}
|
||||
cmd := newDevAppGetCommand(r)
|
||||
if err := cmd.RunE(cmd, nil); err == nil ||
|
||||
!strings.Contains(err.Error(), "请传入 --unified-app-id 或 --app-key") {
|
||||
t.Fatalf("err = %v, want 二选一报错", err)
|
||||
}
|
||||
// 只 app-key:params 含 appKey,无 unifiedAppId。
|
||||
_ = cmd.Flags().Set("app-key", "dingABC")
|
||||
if err := cmd.RunE(cmd, nil); err != nil {
|
||||
t.Fatalf("RunE() error = %v", err)
|
||||
}
|
||||
if v, _ := r.got.Params["appKey"].(string); v != "dingABC" {
|
||||
t.Fatalf("appKey = %v, want dingABC", r.got.Params["appKey"])
|
||||
}
|
||||
if _, present := r.got.Params["unifiedAppId"]; present {
|
||||
t.Fatalf("unifiedAppId present, want omitted")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDevAppListLeaf 是 showcase:退役 devAppPutString/devAppPutInt/devAppFlagOrFallback。
|
||||
// 验证 name/keyword 别名回退、LeafInt(!=0 入参)、cursor 注入、OmitEmpty。
|
||||
func TestDevAppListLeaf(t *testing.T) {
|
||||
// keyword 别名回退:只设 keyword,name 应取 keyword 值。
|
||||
r := &fakeDevAppRunner{}
|
||||
cmd := newDevAppListCommand(r)
|
||||
_ = cmd.Flags().Set("keyword", "DemoApp")
|
||||
_ = cmd.Flags().Set("app-group-id", "5")
|
||||
_ = cmd.Flags().Set("develop-type", "0") // 0:LeafInt 不入参
|
||||
if err := cmd.RunE(cmd, nil); err != nil {
|
||||
t.Fatalf("RunE() error = %v", err)
|
||||
}
|
||||
if v, _ := r.got.Params["name"].(string); v != "DemoApp" {
|
||||
t.Fatalf("name = %q, want keyword-fallback \"DemoApp\"", v)
|
||||
}
|
||||
if v, _ := r.got.Params["appGroupId"].(int); v != 5 {
|
||||
t.Fatalf("appGroupId = %v, want int(5)", r.got.Params["appGroupId"])
|
||||
}
|
||||
if _, present := r.got.Params["developType"]; present {
|
||||
t.Fatalf("developType present = %v, want omitted (LeafInt 0)", r.got.Params["developType"])
|
||||
}
|
||||
if _, present := r.got.Params["pageSize"]; !present {
|
||||
t.Fatal("pageSize missing, want cursor injection")
|
||||
}
|
||||
if _, present := r.got.Params["creator"]; present {
|
||||
t.Fatalf("creator present, want omitted (OmitEmpty)")
|
||||
}
|
||||
// name 优先于 keyword。
|
||||
r2 := &fakeDevAppRunner{}
|
||||
cmd2 := newDevAppListCommand(r2)
|
||||
_ = cmd2.Flags().Set("name", "Primary")
|
||||
_ = cmd2.Flags().Set("keyword", "Fallback")
|
||||
_ = cmd2.RunE(cmd2, nil)
|
||||
if v, _ := r2.got.Params["name"].(string); v != "Primary" {
|
||||
t.Fatalf("name = %q, want Primary (name beats keyword)", v)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDevAppEventListLeaf 验证 cursor/pageSize 经 devAppApplyCursorParams 注入。
|
||||
func TestDevAppEventListLeaf(t *testing.T) {
|
||||
r := &fakeDevAppRunner{}
|
||||
cmd := newDevAppEventListCommand(r)
|
||||
_ = cmd.Flags().Set("unified-app-id", "APP-E")
|
||||
if err := cmd.RunE(cmd, nil); err != nil {
|
||||
t.Fatalf("RunE() error = %v", err)
|
||||
}
|
||||
if v, _ := r.got.Params["unifiedAppId"].(string); v != "APP-E" {
|
||||
t.Fatalf("unifiedAppId = %q, want APP-E", v)
|
||||
}
|
||||
// page-size 默认 20,经 devAppApplyCursorParams 注入为 float64(20)。
|
||||
if _, present := r.got.Params["pageSize"]; !present {
|
||||
t.Fatal("pageSize missing, want injected by devAppApplyCursorParams")
|
||||
}
|
||||
// cursor 默认空:不入参。
|
||||
if _, present := r.got.Params["cursor"]; present {
|
||||
t.Fatalf("cursor present = %v, want omitted when empty", r.got.Params["cursor"])
|
||||
}
|
||||
// keyword 未设:OmitEmpty 省略。
|
||||
if _, present := r.got.Params["keyword"]; present {
|
||||
t.Fatalf("keyword present, want omitted (OmitEmpty)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDevAppUpdateLeaf 验证「至少一项」校验 + OmitEmpty 装配。
|
||||
func TestDevAppUpdateLeaf(t *testing.T) {
|
||||
r := &fakeDevAppRunner{}
|
||||
cmd := newDevAppUpdateCommand(r)
|
||||
cmd.Flags().Bool("yes", false, "")
|
||||
_ = cmd.Flags().Set("yes", "true")
|
||||
_ = cmd.Flags().Set("unified-app-id", "APP-U")
|
||||
// 全空:至少一项拦。
|
||||
if err := cmd.RunE(cmd, nil); err == nil ||
|
||||
!strings.Contains(err.Error(), "至少提供一项待更新字段") {
|
||||
t.Fatalf("err = %v, want 至少提供一项待更新字段", err)
|
||||
}
|
||||
_ = cmd.Flags().Set("desc", " 新描述 ")
|
||||
if err := cmd.RunE(cmd, nil); err != nil {
|
||||
t.Fatalf("RunE() error = %v", err)
|
||||
}
|
||||
if v, _ := r.got.Params["desc"].(string); v != "新描述" {
|
||||
t.Fatalf("desc = %q, want trimmed", v)
|
||||
}
|
||||
if _, present := r.got.Params["name"]; present {
|
||||
t.Fatalf("name present, want omitted (OmitEmpty)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDevAppMemberAddLeaf 验证 userIds []string 注入 + memberType 装配 + 写守卫/必填顺序。
|
||||
func TestDevAppMemberAddLeaf(t *testing.T) {
|
||||
r := &fakeDevAppRunner{}
|
||||
cmd := newDevAppMemberAddCommand(r)
|
||||
_ = cmd.Flags().Set("unified-app-id", "APP-M")
|
||||
// 无 --yes:写守卫拦。
|
||||
if err := cmd.RunE(cmd, nil); err == nil {
|
||||
t.Fatal("want write-guard error without --yes")
|
||||
}
|
||||
cmd.Flags().Bool("yes", false, "")
|
||||
_ = cmd.Flags().Set("yes", "true")
|
||||
// 缺 user-ids/member-type:必填拦。
|
||||
if err := cmd.RunE(cmd, nil); err == nil ||
|
||||
!strings.Contains(err.Error(), "--user-ids 为必填") {
|
||||
t.Fatalf("err = %v, want --user-ids 为必填", err)
|
||||
}
|
||||
_ = cmd.Flags().Set("user-ids", "u1, u2")
|
||||
if err := cmd.RunE(cmd, nil); err == nil ||
|
||||
!strings.Contains(err.Error(), "--member-type 为必填") {
|
||||
t.Fatalf("err = %v, want --member-type 为必填", err)
|
||||
}
|
||||
// 齐全:userIds []string + memberType 装配。
|
||||
_ = cmd.Flags().Set("member-type", "DEVELOPER")
|
||||
if err := cmd.RunE(cmd, nil); err != nil {
|
||||
t.Fatalf("RunE() error = %v", err)
|
||||
}
|
||||
users, ok := r.got.Params["userIds"].([]string)
|
||||
if !ok || len(users) != 2 || users[0] != "u1" || users[1] != "u2" {
|
||||
t.Fatalf("userIds = %v, want [u1 u2]", r.got.Params["userIds"])
|
||||
}
|
||||
if v, _ := r.got.Params["memberType"].(string); v != "DEVELOPER" {
|
||||
t.Fatalf("memberType = %v, want DEVELOPER", r.got.Params["memberType"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestDevAppSecurityConfigLeaf 验证 3 个列表 flag 的 Call 注入 + 「至少一项」校验。
|
||||
func TestDevAppSecurityConfigLeaf(t *testing.T) {
|
||||
r := &fakeDevAppRunner{}
|
||||
cmd := newDevAppSecurityConfigCommand(r)
|
||||
cmd.Flags().Bool("yes", false, "")
|
||||
_ = cmd.Flags().Set("yes", "true")
|
||||
_ = cmd.Flags().Set("unified-app-id", "APP-S")
|
||||
// 全空:至少一项拦。
|
||||
if err := cmd.RunE(cmd, nil); err == nil ||
|
||||
!strings.Contains(err.Error(), "至少提供一项安全配置") {
|
||||
t.Fatalf("err = %v, want 至少提供一项安全配置", err)
|
||||
}
|
||||
// 设 ip-whitelist:注入 []string,其余省略。
|
||||
_ = cmd.Flags().Set("ip-whitelist", "10.0.0.1; 10.0.0.2")
|
||||
if err := cmd.RunE(cmd, nil); err != nil {
|
||||
t.Fatalf("RunE() error = %v", err)
|
||||
}
|
||||
ips, ok := r.got.Params["ipWhitelist"].([]string)
|
||||
if !ok || len(ips) != 2 || ips[0] != "10.0.0.1" || ips[1] != "10.0.0.2" {
|
||||
t.Fatalf("ipWhitelist = %v, want [10.0.0.1 10.0.0.2]", r.got.Params["ipWhitelist"])
|
||||
}
|
||||
if _, present := r.got.Params["redirectUrls"]; present {
|
||||
t.Fatalf("redirectUrls present, want omitted")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDevAppPermissionAddLeaf 验证 scope-values 经 Call 注入为 []string + 写守卫/必填。
|
||||
func TestDevAppPermissionAddLeaf(t *testing.T) {
|
||||
r := &fakeDevAppRunner{}
|
||||
cmd := newDevAppPermissionAddCommand(r)
|
||||
// scope-values 在 PostMount 注册。
|
||||
if err := cmd.Flags().Set("unified-app-id", "APP-3"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 无 --yes 且无 scope-values:写守卫先拦。
|
||||
if err := cmd.RunE(cmd, nil); err == nil {
|
||||
t.Fatal("want write-guard error without --yes")
|
||||
}
|
||||
cmd.Flags().Bool("yes", false, "")
|
||||
_ = cmd.Flags().Set("yes", "true")
|
||||
// 有 --yes 但无 scope-values:必填校验拦。
|
||||
if err := cmd.RunE(cmd, nil); err == nil ||
|
||||
!strings.Contains(err.Error(), "--scope-values 为必填") {
|
||||
t.Fatalf("err = %v, want --scope-values 为必填", err)
|
||||
}
|
||||
// 齐全:scopeValues 注入为 []string。
|
||||
_ = cmd.Flags().Set("scope-values", "Contact.User.mobile, qyapi_robot_sendmsg")
|
||||
if err := cmd.RunE(cmd, nil); err != nil {
|
||||
t.Fatalf("RunE() error = %v", err)
|
||||
}
|
||||
scopes, ok := r.got.Params["scopeValues"].([]string)
|
||||
if !ok || len(scopes) != 2 || scopes[0] != "Contact.User.mobile" || scopes[1] != "qyapi_robot_sendmsg" {
|
||||
t.Fatalf("scopeValues = %v, want [Contact.User.mobile qyapi_robot_sendmsg]", r.got.Params["scopeValues"])
|
||||
}
|
||||
if v, _ := r.got.Params["unifiedAppId"].(string); v != "APP-3" {
|
||||
t.Fatalf("unifiedAppId = %q, want APP-3", v)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDevAppWebappConfigLeaf 验证 OmitEmpty 装配 + 「至少一项」跨 flag 校验。
|
||||
func TestDevAppWebappConfigLeaf(t *testing.T) {
|
||||
r := &fakeDevAppRunner{}
|
||||
cmd := newDevAppWebappConfigCommand(r)
|
||||
cmd.Flags().Bool("yes", false, "")
|
||||
_ = cmd.Flags().Set("yes", "true")
|
||||
|
||||
// 全空:写守卫放行后,「至少一项」必须拦。
|
||||
if err := cmd.Flags().Set("unified-app-id", "APP-1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := cmd.RunE(cmd, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "至少提供一项网页应用配置") {
|
||||
t.Fatalf("err = %v, want 至少提供一项网页应用配置", err)
|
||||
}
|
||||
|
||||
// 只设一项:该项入参,其余 OmitEmpty 省略。
|
||||
if err := cmd.Flags().Set("homepage-url", " https://a.example.invalid "); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r = &fakeDevAppRunner{}
|
||||
cmd2 := newDevAppWebappConfigCommand(r)
|
||||
cmd2.Flags().Bool("yes", false, "")
|
||||
_ = cmd2.Flags().Set("yes", "true")
|
||||
_ = cmd2.Flags().Set("unified-app-id", "APP-1")
|
||||
_ = cmd2.Flags().Set("homepage-url", " https://a.example.invalid ")
|
||||
if err := cmd2.RunE(cmd2, nil); err != nil {
|
||||
t.Fatalf("RunE() error = %v", err)
|
||||
}
|
||||
if v, _ := r.got.Params["homepageUrl"].(string); v != "https://a.example.invalid" {
|
||||
t.Fatalf("homepageUrl = %q, want trimmed", v)
|
||||
}
|
||||
if _, present := r.got.Params["h5PageType"]; present {
|
||||
t.Fatalf("h5PageType present = %v, want omitted (OmitEmpty)", r.got.Params["h5PageType"])
|
||||
}
|
||||
if _, present := r.got.Params["ompUrl"]; present {
|
||||
t.Fatalf("ompUrl present = %v, want omitted (OmitEmpty)", r.got.Params["ompUrl"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestDevAppLeafToolArgsTable 表驱动补齐剩余迁移命令的 toolArgs 断言。
|
||||
// 覆盖此前无断言的:robot enable/disable、permission remove、event sub/unsub、
|
||||
// member remove、version create、app create、robot config-get、lifecycle disable。
|
||||
func TestDevAppLeafToolArgsTable(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
build func(executor.Runner) *cobra.Command
|
||||
flags map[string]string
|
||||
needsYes bool
|
||||
wantTool string
|
||||
wantParam map[string]any
|
||||
wantAbsent []string // 应缺席的 params 键(OmitEmpty 校验)
|
||||
}{
|
||||
{"robot enable", newDevAppRobotEnableCommand, map[string]string{"unified-app-id": "APP-R"}, true, devAppRobotEnableTool, map[string]any{"unifiedAppId": "APP-R"}, nil},
|
||||
{"robot disable", newDevAppRobotOfflineCommand, map[string]string{"unified-app-id": "APP-R"}, true, devAppRobotOfflineTool, map[string]any{"unifiedAppId": "APP-R"}, nil},
|
||||
{"permission remove", newDevAppPermissionRemoveCommand, map[string]string{"unified-app-id": "APP-P", "scope-values": "a.b, c.d"}, true, devAppPermissionRmTool, map[string]any{"unifiedAppId": "APP-P", "scopeValues": []string{"a.b", "c.d"}}, nil},
|
||||
{"event subscribe", newDevAppEventSubscribeCommand, map[string]string{"unified-app-id": "APP-E", "event-codes": "x, y"}, true, devAppEventSubscribeTool, map[string]any{"unifiedAppId": "APP-E", "eventCodes": []string{"x", "y"}}, nil},
|
||||
{"event unsubscribe", newDevAppEventUnsubscribeCommand, map[string]string{"unified-app-id": "APP-E", "event-codes": "x, y"}, true, devAppEventUnsubscribeTool, map[string]any{"unifiedAppId": "APP-E", "eventCodes": []string{"x", "y"}}, nil},
|
||||
{"member remove", newDevAppMemberRemoveCommand, map[string]string{"unified-app-id": "APP-M", "user-ids": "u1,u2", "member-type": "DEVELOPER"}, true, devAppMemberRemoveTool, map[string]any{"unifiedAppId": "APP-M", "userIds": []string{"u1", "u2"}, "memberType": "DEVELOPER"}, nil},
|
||||
{"version create", newDevAppVersionCreateCommand, map[string]string{"unified-app-id": "APP-V", "desc": "新增机器人"}, true, devAppVersionCreateTool, map[string]any{"unifiedAppId": "APP-V", "desc": "新增机器人"}, []string{"version"}},
|
||||
{"app create", newDevAppCreateCommand, map[string]string{"name": "DemoApp"}, true, devAppCreateTool, map[string]any{"name": "DemoApp"}, []string{"desc", "iconMediaId"}},
|
||||
{"robot config get", newDevAppRobotConfigGetCommand, map[string]string{"unified-app-id": "APP-C"}, false, devAppRobotConfigGetTool, map[string]any{"unifiedAppId": "APP-C"}, nil},
|
||||
{"lifecycle disable", func(r executor.Runner) *cobra.Command {
|
||||
return newDevAppLifecycleCommand(r, "disable", "停用应用", devAppDisableTool)
|
||||
}, map[string]string{"unified-app-id": "APP-D"}, true, devAppDisableTool, map[string]any{"unifiedAppId": "APP-D"}, nil},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
r := &fakeDevAppRunner{}
|
||||
cmd := c.build(r)
|
||||
if c.needsYes {
|
||||
cmd.Flags().Bool("yes", false, "")
|
||||
_ = cmd.Flags().Set("yes", "true")
|
||||
}
|
||||
for k, v := range c.flags {
|
||||
if err := cmd.Flags().Set(k, v); err != nil {
|
||||
t.Fatalf("set %s: %v", k, err)
|
||||
}
|
||||
}
|
||||
if err := cmd.RunE(cmd, nil); err != nil {
|
||||
t.Fatalf("RunE() error = %v", err)
|
||||
}
|
||||
if r.got.Tool != c.wantTool {
|
||||
t.Fatalf("tool = %q, want %q", r.got.Tool, c.wantTool)
|
||||
}
|
||||
for k, want := range c.wantParam {
|
||||
if got := r.got.Params[k]; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("param %s = %#v (%T), want %#v (%T)", k, got, got, want, want)
|
||||
}
|
||||
}
|
||||
for _, k := range c.wantAbsent {
|
||||
if _, present := r.got.Params[k]; present {
|
||||
t.Fatalf("param %s present = %#v, want absent (OmitEmpty)", k, r.got.Params[k])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// leaf.go 是叶子命令的统一构建框架。
|
||||
//
|
||||
// 现状问题:每个叶子命令手写 cobra.Command,required 校验、别名回退、环境
|
||||
// 变量回退、值转换、参数装配、派发调用在各个产品文件里各写一份,行为难以
|
||||
// 保持一致。LeafSpec 把这些共性收敛为声明式结构:命令只声明「flag 集合 +
|
||||
// 绑定关系」,框架统一执行。默认派发走 MCP 直连(callMCPTool);非 MCP
|
||||
// 命令(如 devapp 走 executor.Runner)通过 LeafSpec.Call 注入自己的派发器,
|
||||
// 复用同一套 flag/校验/装配逻辑。复杂命令可通过 LeafSpec.RunE 完全自定义
|
||||
// (逃生舱),不在框架适用范围内强行套用。
|
||||
//
|
||||
// 迁移纪律:从手写命令迁移到 LeafSpec 时,flag 名、默认值、usage 文案、
|
||||
// MarkFlagRequired、required 错误格式、toolArgs 键与值必须逐字保持一致,
|
||||
// 由 check-generated-drift(catalog 零漂移)与命令兼容性检查兜底证明等价。
|
||||
|
||||
// LeafFlagKind 是 flag 的值类型。
|
||||
type LeafFlagKind int
|
||||
|
||||
const (
|
||||
// LeafString 字符串 flag(默认)。
|
||||
LeafString LeafFlagKind = iota
|
||||
// LeafInt64 整型 flag;仅在值 > 0 时进入 toolArgs(分页游标语义)。
|
||||
LeafInt64
|
||||
// LeafInt 整型 flag(注册为 cobra Int);仅在值 != 0 时进入 toolArgs,
|
||||
// 对应手写「putInt 仅在非零才入参」语义(如 devapp app-group-id)。
|
||||
LeafInt
|
||||
)
|
||||
|
||||
// LeafFlag 声明一个 flag 的注册方式与到 MCP toolArgs 的绑定。
|
||||
type LeafFlag struct {
|
||||
Name string // flag 名(kebab-case)
|
||||
Usage string // 注册 usage 文案
|
||||
Kind LeafFlagKind // 值类型,默认 LeafString
|
||||
Default string // 注册默认值(仅 LeafString 有效)
|
||||
|
||||
// Required 为 true 时在 RunE 期校验有效值非空。普通 Required 汇聚为
|
||||
// cmdutil.ValidateRequiredFlags 兼容的统一报错;配置 EnvVar 时回退读
|
||||
// 环境变量,仍为空则报 RequiredHint(或默认文案)。
|
||||
Required bool
|
||||
RequiredHint string
|
||||
// MarkRequired 为 true 时调用 cobra MarkFlagRequired(catalog required
|
||||
// 投影的硬下限),cobra 会在 RunE 之前先行报错。
|
||||
MarkRequired bool
|
||||
|
||||
Aliases []string // 隐藏别名,主 flag 为空时按序回退(字符串)
|
||||
EnvVar string // 有效值为空时回退读取的环境变量
|
||||
// ArgDefault:注册默认值为空、但 toolArgs 需要兜底的场景(如 list type
|
||||
// 注册默认 "ALL" 之外的旧命令);有效值为空时以 ArgDefault 入参。
|
||||
ArgDefault string
|
||||
// Bind 是 toolArgs 的键;为空时使用 Name。
|
||||
Bind string
|
||||
// Transform 把字符串有效值转为入参值;nil 时原样入参。返回
|
||||
// (nil, nil) 表示跳过该键(用于「可空数值:为空或解析失败都不入参」
|
||||
// 的手写语义)。
|
||||
Transform func(raw string) (any, error)
|
||||
// OmitEmpty 为 true 时有效值为空则不进入 toolArgs(LeafInt64 恒为
|
||||
// 「值 > 0 才入参」,忽略此字段)。
|
||||
OmitEmpty bool
|
||||
// Trim 为 true 时对有效值做 strings.TrimSpace(主 flag/别名/env 统一),
|
||||
// 对应手写 devAppStringFlag 恒 trim 的语义;亦使「纯空白」值在 required
|
||||
// 校验中视为空。
|
||||
Trim bool
|
||||
}
|
||||
|
||||
// LeafSpec 声明一个 MCP 直连叶子命令。
|
||||
type LeafSpec struct {
|
||||
Use string
|
||||
Short string
|
||||
Long string
|
||||
Example string
|
||||
|
||||
// Server 非空时走 callMCPToolOnServer(显式 server 路由),否则走
|
||||
// callMCPTool(按 product 路由)。Call 非空时两者都被忽略。
|
||||
Server string
|
||||
Tool string
|
||||
Flags []LeafFlag
|
||||
|
||||
// Call 是可插拔派发函数,非空时替代默认的 callMCPTool/callMCPToolOnServer。
|
||||
// 供非 MCP 直连命令(如 devapp 走 executor.Runner)复用本框架:调用方用
|
||||
// 闭包捕获自己的 runner/派发器即可。签名与默认路径一致——收到框架装配好
|
||||
// 的 toolArgs,自行派发。
|
||||
Call func(cmd *cobra.Command, tool string, args map[string]any) error
|
||||
|
||||
// Validate 是跨 flag 校验钩子(如时间区间、互斥关系),在 required
|
||||
// 校验之后、toolArgs 装配之前执行;nil 时跳过。单 flag 的格式转换
|
||||
// 应放在 LeafFlag.Transform,不要放进 Validate。
|
||||
Validate func(cmd *cobra.Command, args []string) error
|
||||
|
||||
// RunE 非空时完全自定义执行体(逃生舱),框架只负责注册 flag。
|
||||
RunE func(cmd *cobra.Command, args []string) error
|
||||
|
||||
// PostMount 在 flag 注册完成之后、RunE 设定之前对构建好的 cmd 做最终
|
||||
// 调整(设置 Args/DisableAutoGenTag、调用 annotate/preferLegacy 等)。
|
||||
// 无论是否使用 RunE 逃生舱都会执行。对标 lark shortcut 的 PostMount。
|
||||
PostMount func(cmd *cobra.Command)
|
||||
}
|
||||
|
||||
// NewLeafCommand 按 LeafSpec 构建叶子命令。
|
||||
func NewLeafCommand(spec LeafSpec) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: spec.Use,
|
||||
Short: spec.Short,
|
||||
Long: spec.Long,
|
||||
Example: spec.Example,
|
||||
}
|
||||
for _, flag := range spec.Flags {
|
||||
switch flag.Kind {
|
||||
case LeafInt64:
|
||||
cmd.Flags().Int64(flag.Name, 0, flag.Usage)
|
||||
case LeafInt:
|
||||
cmd.Flags().Int(flag.Name, 0, flag.Usage)
|
||||
default:
|
||||
cmd.Flags().String(flag.Name, flag.Default, flag.Usage)
|
||||
}
|
||||
for _, alias := range flag.Aliases {
|
||||
cmd.Flags().String(alias, "", flag.Usage+" (alias)")
|
||||
_ = cmd.Flags().MarkHidden(alias)
|
||||
}
|
||||
if flag.MarkRequired {
|
||||
_ = cmd.MarkFlagRequired(flag.Name)
|
||||
}
|
||||
}
|
||||
if spec.PostMount != nil {
|
||||
spec.PostMount(cmd)
|
||||
}
|
||||
if spec.RunE != nil {
|
||||
cmd.RunE = spec.RunE
|
||||
return cmd
|
||||
}
|
||||
cmd.RunE = func(cmd *cobra.Command, args []string) error {
|
||||
if err := leafValidateRequired(cmd, spec); err != nil {
|
||||
return err
|
||||
}
|
||||
if spec.Validate != nil {
|
||||
if err := spec.Validate(cmd, args); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
toolArgs, err := leafArgs(cmd, spec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if spec.Call != nil {
|
||||
return spec.Call(cmd, spec.Tool, toolArgs)
|
||||
}
|
||||
if spec.Server != "" {
|
||||
return callMCPToolOnServer(spec.Server, spec.Tool, toolArgs)
|
||||
}
|
||||
return callMCPTool(spec.Tool, toolArgs)
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
// leafValidateRequired 复现手写命令的 required 语义:普通 Required 统一报
|
||||
// 「missing required flag(s)」;带 EnvVar 的 Required 单独报 RequiredHint。
|
||||
// 普通组先于环境变量组校验,保持与手写顺序一致。
|
||||
func leafValidateRequired(cmd *cobra.Command, spec LeafSpec) error {
|
||||
var plain []string
|
||||
for _, flag := range spec.Flags {
|
||||
if flag.Required && flag.EnvVar == "" && flag.RequiredHint == "" {
|
||||
plain = append(plain, flag.Name)
|
||||
}
|
||||
}
|
||||
if err := validateRequiredFlags(cmd, plain...); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, flag := range spec.Flags {
|
||||
if !flag.Required || (flag.EnvVar == "" && flag.RequiredHint == "") {
|
||||
continue
|
||||
}
|
||||
if leafEffectiveValue(cmd, flag) == "" {
|
||||
hint := flag.RequiredHint
|
||||
if hint == "" {
|
||||
hint = fmt.Sprintf("flag --%s is required", flag.Name)
|
||||
}
|
||||
return fmt.Errorf("%s", hint)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// leafArgs 按绑定关系装配 toolArgs。
|
||||
func leafArgs(cmd *cobra.Command, spec LeafSpec) (map[string]any, error) {
|
||||
toolArgs := map[string]any{}
|
||||
for _, flag := range spec.Flags {
|
||||
bind := flag.Bind
|
||||
if bind == "" {
|
||||
bind = flag.Name
|
||||
}
|
||||
if flag.Kind == LeafInt64 {
|
||||
if v, _ := cmd.Flags().GetInt64(flag.Name); v > 0 {
|
||||
toolArgs[bind] = v
|
||||
}
|
||||
continue
|
||||
}
|
||||
if flag.Kind == LeafInt {
|
||||
if v, _ := cmd.Flags().GetInt(flag.Name); v != 0 {
|
||||
toolArgs[bind] = v
|
||||
}
|
||||
continue
|
||||
}
|
||||
effective := leafEffectiveValue(cmd, flag)
|
||||
if effective == "" && flag.ArgDefault != "" {
|
||||
effective = flag.ArgDefault
|
||||
}
|
||||
if effective == "" && flag.OmitEmpty {
|
||||
continue
|
||||
}
|
||||
if flag.Transform != nil {
|
||||
value, err := flag.Transform(effective)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if value == nil {
|
||||
continue
|
||||
}
|
||||
toolArgs[bind] = value
|
||||
continue
|
||||
}
|
||||
toolArgs[bind] = effective
|
||||
}
|
||||
return toolArgs, nil
|
||||
}
|
||||
|
||||
// leafEffectiveValue 按「主 flag → 别名 → 环境变量」顺序取有效值;Trim 为
|
||||
// true 时对结果统一 TrimSpace。
|
||||
func leafEffectiveValue(cmd *cobra.Command, flag LeafFlag) string {
|
||||
v := leafRawValue(cmd, flag)
|
||||
if flag.Trim {
|
||||
v = strings.TrimSpace(v)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// leafRawValue 取未 trim 的原始有效值(主 flag → 别名 → 环境变量)。
|
||||
func leafRawValue(cmd *cobra.Command, flag LeafFlag) string {
|
||||
if v := mustGetFlag(cmd, flag.Name); v != "" {
|
||||
return v
|
||||
}
|
||||
for _, alias := range flag.Aliases {
|
||||
if v, _ := cmd.Flags().GetString(alias); v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
if flag.EnvVar != "" {
|
||||
return os.Getenv(flag.EnvVar)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func leafTestSpec() LeafSpec {
|
||||
return LeafSpec{
|
||||
Use: "send",
|
||||
Short: "发送",
|
||||
Tool: "send_thing",
|
||||
Flags: []LeafFlag{
|
||||
{Name: "token", Usage: "令牌", Required: true, EnvVar: "DWS_LEAF_TEST_TOKEN", RequiredHint: "flag --token is required (or set DWS_LEAF_TEST_TOKEN)", Bind: "accessToken"},
|
||||
{Name: "users", Usage: "用户列表", Required: true, Bind: "userList", Transform: leafTestCSV},
|
||||
{Name: "content", Usage: "内容", Required: true},
|
||||
{Name: "type", Usage: "类型", Default: "app", Bind: "remindType"},
|
||||
{Name: "note", Usage: "备注", Aliases: []string{"remark"}, OmitEmpty: true, Bind: "noteText"},
|
||||
{Name: "cursor", Usage: "游标", Kind: LeafInt64, Bind: "cursor"},
|
||||
{Name: "scope", Usage: "范围", ArgDefault: "ALL", Bind: "scope"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// leafTestCSV 把逗号分隔字符串拆成 []string(测试专用,避免依赖产品文件的 helper)。
|
||||
func leafTestCSV(raw string) (any, error) {
|
||||
if raw == "" {
|
||||
return []string{}, nil
|
||||
}
|
||||
parts := strings.Split(raw, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
if t := strings.TrimSpace(p); t != "" {
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func TestNewLeafCommandRegistersFlags(t *testing.T) {
|
||||
cmd := NewLeafCommand(leafTestSpec())
|
||||
|
||||
f := cmd.Flags().Lookup("type")
|
||||
if f == nil || f.DefValue != "app" || f.Usage != "类型" {
|
||||
t.Fatalf("type flag = %+v, want default app with usage", f)
|
||||
}
|
||||
alias := cmd.Flags().Lookup("remark")
|
||||
if alias == nil || !alias.Hidden {
|
||||
t.Fatalf("alias remark = %+v, want registered and hidden", alias)
|
||||
}
|
||||
if cmd.Flags().Lookup("cursor") == nil {
|
||||
t.Fatal("cursor flag not registered")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewLeafCommandMarksRequired(t *testing.T) {
|
||||
cmd := NewLeafCommand(LeafSpec{
|
||||
Use: "get", Tool: "get_thing",
|
||||
Flags: []LeafFlag{{Name: "id", Usage: "ID", Required: true, MarkRequired: true}},
|
||||
})
|
||||
f := cmd.Flags().Lookup("id")
|
||||
if f == nil || len(f.Annotations) == 0 {
|
||||
t.Fatalf("id flag = %+v, want MarkFlagRequired annotations", f)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeafValidateRequiredPlainGroup(t *testing.T) {
|
||||
cmd := NewLeafCommand(leafTestSpec())
|
||||
t.Setenv("DWS_LEAF_TEST_TOKEN", "tok")
|
||||
if err := cmd.Flags().Set("users", "u1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := leafValidateRequired(cmd, leafTestSpec())
|
||||
if err == nil || !strings.Contains(err.Error(), "missing required flag(s): --content") {
|
||||
t.Fatalf("leafValidateRequired() = %v, want missing --content", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeafValidateRequiredEnvFallback(t *testing.T) {
|
||||
cmd := NewLeafCommand(leafTestSpec())
|
||||
err := leafValidateRequired(cmd, leafTestSpec())
|
||||
// 普通组(users/content)先报错,不触及 env 组。
|
||||
if err == nil || !strings.Contains(err.Error(), "missing required flag(s): --users, --content") {
|
||||
t.Fatalf("leafValidateRequired() = %v, want plain group first", err)
|
||||
}
|
||||
// 普通组满足后,env 缺失时走 RequiredHint。
|
||||
if err := cmd.Flags().Set("users", "u1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cmd.Flags().Set("content", "c"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = leafValidateRequired(cmd, leafTestSpec())
|
||||
if err == nil || !strings.Contains(err.Error(), "DWS_LEAF_TEST_TOKEN") {
|
||||
t.Fatalf("leafValidateRequired() = %v, want env hint", err)
|
||||
}
|
||||
// env 提供后通过。
|
||||
t.Setenv("DWS_LEAF_TEST_TOKEN", "tok")
|
||||
if err := leafValidateRequired(cmd, leafTestSpec()); err != nil {
|
||||
t.Fatalf("leafValidateRequired() = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeafArgs(t *testing.T) {
|
||||
cmd := NewLeafCommand(leafTestSpec())
|
||||
t.Setenv("DWS_LEAF_TEST_TOKEN", "tok")
|
||||
if err := cmd.Flags().Set("users", "u1, u2"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cmd.Flags().Set("content", "hello"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cmd.Flags().Set("remark", "via-alias"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cmd.Flags().Set("cursor", "10"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
args, err := leafArgs(cmd, leafTestSpec())
|
||||
if err != nil {
|
||||
t.Fatalf("leafArgs() error = %v", err)
|
||||
}
|
||||
if args["accessToken"] != "tok" {
|
||||
t.Fatalf("accessToken = %v, want env fallback tok", args["accessToken"])
|
||||
}
|
||||
users, ok := args["userList"].([]string)
|
||||
if !ok || len(users) != 2 || users[0] != "u1" || users[1] != "u2" {
|
||||
t.Fatalf("userList = %v, want [u1 u2]", args["userList"])
|
||||
}
|
||||
if args["content"] != "hello" || args["remindType"] != "app" {
|
||||
t.Fatalf("content/remindType = %v/%v", args["content"], args["remindType"])
|
||||
}
|
||||
if args["noteText"] != "via-alias" {
|
||||
t.Fatalf("noteText = %v, want alias fallback", args["noteText"])
|
||||
}
|
||||
if args["cursor"] != int64(10) {
|
||||
t.Fatalf("cursor = %v (%T), want int64(10)", args["cursor"], args["cursor"])
|
||||
}
|
||||
if args["scope"] != "ALL" {
|
||||
t.Fatalf("scope = %v, want ArgDefault ALL", args["scope"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeafArgsOmitsEmptyAndNonPositive(t *testing.T) {
|
||||
cmd := NewLeafCommand(leafTestSpec())
|
||||
args, err := leafArgs(cmd, leafTestSpec())
|
||||
if err != nil {
|
||||
t.Fatalf("leafArgs() error = %v", err)
|
||||
}
|
||||
if _, present := args["noteText"]; present {
|
||||
t.Fatalf("noteText present = %v, want omitted when empty", args["noteText"])
|
||||
}
|
||||
if _, present := args["cursor"]; present {
|
||||
t.Fatalf("cursor present = %v, want omitted when <= 0", args["cursor"])
|
||||
}
|
||||
// 未配置 OmitEmpty 的 flag 即使为空也入参(复现手写语义;Required 校验在
|
||||
// leafArgs 之前执行,保证真实路径不会发出空值)。
|
||||
if v, present := args["accessToken"]; !present || v != "" {
|
||||
t.Fatalf("accessToken = %v/%v, want present-but-empty without OmitEmpty", v, present)
|
||||
}
|
||||
// 未设置 OmitEmpty 的字符串即使为空也入参(复现手写 remindType 恒入参语义)。
|
||||
if v, present := args["remindType"]; !present || v != "app" {
|
||||
t.Fatalf("remindType = %v/%v, want registered default", v, present)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewLeafCommandCustomRunE(t *testing.T) {
|
||||
called := false
|
||||
spec := leafTestSpec()
|
||||
spec.RunE = func(cmd *cobra.Command, args []string) error {
|
||||
called = true
|
||||
return nil
|
||||
}
|
||||
cmd := NewLeafCommand(spec)
|
||||
if err := cmd.RunE(cmd, nil); err != nil || !called {
|
||||
t.Fatalf("custom RunE not used: called=%v err=%v", called, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeafValidateHookRunsAfterRequired(t *testing.T) {
|
||||
validated := false
|
||||
spec := LeafSpec{
|
||||
Use: "list", Tool: "list_thing",
|
||||
Flags: []LeafFlag{{Name: "start", Usage: "开始", Required: true}},
|
||||
Validate: func(cmd *cobra.Command, args []string) error {
|
||||
validated = true
|
||||
return fmt.Errorf("range invalid")
|
||||
},
|
||||
}
|
||||
cmd := NewLeafCommand(spec)
|
||||
// required 未满足时先报 required,不触发 Validate。
|
||||
err := cmd.RunE(cmd, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "missing required flag(s): --start") {
|
||||
t.Fatalf("RunE() = %v, want required error first", err)
|
||||
}
|
||||
if validated {
|
||||
t.Fatal("Validate ran before required check passed")
|
||||
}
|
||||
// required 满足后 Validate 拦截。
|
||||
if err := cmd.Flags().Set("start", "s"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cmd.RunE(cmd, nil); err == nil || err.Error() != "range invalid" {
|
||||
t.Fatalf("RunE() = %v, want Validate error", err)
|
||||
}
|
||||
if !validated {
|
||||
t.Fatal("Validate did not run")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeafArgsTransformNilSkipsKey(t *testing.T) {
|
||||
spec := LeafSpec{
|
||||
Use: "list", Tool: "list_thing",
|
||||
Flags: []LeafFlag{
|
||||
{Name: "page", Usage: "页码", OmitEmpty: true, Bind: "pageNum", Transform: func(raw string) (any, error) {
|
||||
return nil, nil // 解析失败语义:跳过
|
||||
}},
|
||||
},
|
||||
}
|
||||
cmd := NewLeafCommand(spec)
|
||||
if err := cmd.Flags().Set("page", "abc"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
args, err := leafArgs(cmd, spec)
|
||||
if err != nil {
|
||||
t.Fatalf("leafArgs() error = %v", err)
|
||||
}
|
||||
if _, present := args["pageNum"]; present {
|
||||
t.Fatalf("pageNum present = %v, want skipped on (nil, nil) transform", args["pageNum"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeafArgsLeafIntOmitsZero(t *testing.T) {
|
||||
spec := LeafSpec{
|
||||
Use: "list", Tool: "list_thing",
|
||||
Flags: []LeafFlag{
|
||||
{Name: "app-group-id", Usage: "分组", Kind: LeafInt, Bind: "appGroupId"},
|
||||
{Name: "develop-type", Usage: "类型", Kind: LeafInt, Bind: "developType"},
|
||||
},
|
||||
}
|
||||
cmd := NewLeafCommand(spec)
|
||||
// 默认 0:不入参。
|
||||
args, err := leafArgs(cmd, spec)
|
||||
if err != nil {
|
||||
t.Fatalf("leafArgs() error = %v", err)
|
||||
}
|
||||
if _, present := args["appGroupId"]; present {
|
||||
t.Fatalf("appGroupId present = %v, want omitted when 0", args["appGroupId"])
|
||||
}
|
||||
// 非 0 入参,类型为 int(与手写 devAppPutInt 一致)。
|
||||
if err := cmd.Flags().Set("develop-type", "2"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
args, err = leafArgs(cmd, spec)
|
||||
if err != nil {
|
||||
t.Fatalf("leafArgs() error = %v", err)
|
||||
}
|
||||
v, ok := args["developType"].(int)
|
||||
if !ok || v != 2 {
|
||||
t.Fatalf("developType = %v (%T), want int(2)", args["developType"], args["developType"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeafCommandCallDispatch(t *testing.T) {
|
||||
// Call 非空时替代默认 callMCPTool,收到框架装配好的 toolArgs。
|
||||
var gotTool string
|
||||
var gotArgs map[string]any
|
||||
spec := LeafSpec{
|
||||
Use: "list", Tool: "list_thing",
|
||||
Flags: []LeafFlag{{Name: "name", Usage: "名称", Bind: "name"}},
|
||||
Call: func(cmd *cobra.Command, tool string, args map[string]any) error {
|
||||
gotTool, gotArgs = tool, args
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd := NewLeafCommand(spec)
|
||||
if err := cmd.Flags().Set("name", "demo"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cmd.RunE(cmd, nil); err != nil {
|
||||
t.Fatalf("RunE() error = %v", err)
|
||||
}
|
||||
if gotTool != "list_thing" {
|
||||
t.Fatalf("Call tool = %q, want list_thing", gotTool)
|
||||
}
|
||||
if gotArgs["name"] != "demo" {
|
||||
t.Fatalf("Call args = %v, want name=demo", gotArgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeafPostMountApplied(t *testing.T) {
|
||||
// PostMount 在 flag 注册后执行,用于设置 Args/annotation 等。
|
||||
spec := LeafSpec{
|
||||
Use: "get", Tool: "get_thing",
|
||||
Flags: []LeafFlag{{Name: "id", Usage: "ID"}},
|
||||
PostMount: func(cmd *cobra.Command) {
|
||||
cmd.DisableAutoGenTag = true
|
||||
if cmd.Annotations == nil {
|
||||
cmd.Annotations = map[string]string{}
|
||||
}
|
||||
cmd.Annotations["mcp-tool"] = "get_thing"
|
||||
},
|
||||
}
|
||||
cmd := NewLeafCommand(spec)
|
||||
if !cmd.DisableAutoGenTag {
|
||||
t.Fatal("PostMount did not set DisableAutoGenTag")
|
||||
}
|
||||
if cmd.Annotations["mcp-tool"] != "get_thing" {
|
||||
t.Fatalf("annotations = %v, want mcp-tool=get_thing", cmd.Annotations)
|
||||
}
|
||||
if cmd.Flags().Lookup("id") == nil {
|
||||
t.Fatal("flag id not registered before PostMount")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeafArgsTrimsValue(t *testing.T) {
|
||||
spec := LeafSpec{
|
||||
Use: "get", Tool: "get_thing",
|
||||
Flags: []LeafFlag{
|
||||
{Name: "name", Usage: "名称", Bind: "appName", Trim: true},
|
||||
{Name: "note", Usage: "备注", Bind: "note"},
|
||||
},
|
||||
}
|
||||
cmd := NewLeafCommand(spec)
|
||||
if err := cmd.Flags().Set("name", " Demo "); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cmd.Flags().Set("note", " x "); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
args, err := leafArgs(cmd, spec)
|
||||
if err != nil {
|
||||
t.Fatalf("leafArgs() error = %v", err)
|
||||
}
|
||||
if args["appName"] != "Demo" {
|
||||
t.Fatalf("appName = %q, want trimmed \"Demo\"", args["appName"])
|
||||
}
|
||||
if args["note"] != " x " {
|
||||
t.Fatalf("note = %q, want untrimmed", args["note"])
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user